# Build a Reproducible Local Agent Tool with ArcLM (No GPU Required)

> Source: <https://dev.to/ahmadaldibo/build-a-reproducible-local-agent-tool-with-arclm-no-gpu-required-46kl>
> Published: 2026-10-16 20:37:34+00:00

An AI agent becomes useful when it can do more than generate text. It needs a way to call functions, validate their inputs, execute operations, and return structured results.

But giving an agent access to tools introduces engineering questions:

In this tutorial, we'll build a small calculator agent with [ArcLM](https://github.com/Ahmad-Al-Dibo/ArcLM).

We'll define a typed Python function, register it as a tool, execute a bounded agent loop, inspect its trace, and examine how permissions and redaction fit into the design.

**No GPU or external LLM API is required.** We'll use a deterministic fake model adapter so that the same example can be reproduced without API credentials or the variability of a real language model.

The fake model is only a test adapter. It demonstrates the tool-calling pipeline, not the reasoning capabilities of an LLM.

Create and activate a Python virtual environment, then install the lightweight ArcLM package:

```
python -m venv .venv
```

On Windows:

```
.venv\Scripts\activate
```

On macOS or Linux:

```
source .venv/bin/activate
```

Install ArcLM:

```
python -m pip install arclm
```

The agent runtime does not require PyTorch for this example. You don't need a GPU, a downloaded language model, or an API key.

For the implementation details, see the [ArcLM agent tools example](https://github.com/Ahmad-Al-Dibo/ArcLM/blob/cleanup-2.0/examples/10_agents_tools/01_custom_tool_agent.py).

Let's start with a simple function that adds two integers.

```
from arclm.agents import (
    SideEffectLevel,
    ToolPermission,
    tool,
)

@tool(
    category="utilities",
    permission=ToolPermission.PURE,
    side_effect=SideEffectLevel.NONE,
)
def add_numbers(left: int, right: int) -> int:
    """Add two integers."""
    return left + right
```

There are only a few lines of implementation, but the metadata matters.

The `@tool` decorator exposes the function as an ArcLM tool. Its type hints describe the expected arguments and return type, while the docstring supplies a description.

The `category` field groups the tool with related functionality.

The permission and side-effect declarations describe its operational behavior:

`ToolPermission.PURE` identifies an operation that doesn't require access to external resources.`SideEffectLevel.NONE` declares that the operation has no side effects.
The function itself remains ordinary Python. ArcLM supplies the tool abstraction around it.

This distinction is useful when building larger systems: the business logic can remain independent of the agent runtime.

ArcLM provides `ToolRegistry` for managing tools by name.

Create a registry and register the function:

``` python
from arclm.agents import ToolRegistry

registry = ToolRegistry()
registry.register_tool(add_numbers)

registered_tool = registry.get_tool("add_numbers")

print(registered_tool.name)
print(registered_tool.description)
print(registered_tool.input_schema)
```

The registry gives you a consistent way to retrieve registered tools and inspect their metadata.

The `input_schema` describes the expected arguments. An agent adapter can use this information when deciding how to formulate a tool call.

This is more explicit than simply exposing arbitrary Python functions to a model.

A schema can describe what a function accepts, but it doesn't prove that a tool is safe. You still need to validate arguments and control access to operations with side effects.

ArcLM's tool abstraction includes argument validation before invoking the underlying function. See [`src/arclm/agents/tool.py`](https://github.com/Ahmad-Al-Dibo/ArcLM/blob/cleanup-2.0/src/arclm/agents/tool.py).

A real language model would decide when to call `add_numbers` and what arguments to provide. That introduces an external dependency and potentially nondeterministic behavior.

For this tutorial, we'll remove that variable by implementing a minimal fake model.

It will do two things:

`add_numbers` with the arguments `2` and `5`.
Create a file named `calculator_agent.py` and use the following complete example:

``` python
import json

from arclm.agents import (
    Agent,
    AgentRunner,
    ModelResponse,
    SideEffectLevel,
    ToolCall,
    ToolPermission,
    ToolRegistry,
    tool,
)
from arclm.security import redact_sensitive_text

# 1. Define a typed tool.

@tool(
    category="utilities",
    permission=ToolPermission.PURE,
    side_effect=SideEffectLevel.NONE,
)
def add_numbers(left: int, right: int) -> int:
    """Add two integers."""
    return left + right

# 2. Register the tool.

registry = ToolRegistry()
registry.register_tool(add_numbers)

# 3. Create a deterministic fake model adapter.

class DeterministicFakeModel:
    def __init__(self):
        self.calls = 0

    def generate_response(
        self,
        *,
        messages,
        tools,
        instructions,
        session,
    ):
        self.calls += 1

        if self.calls == 1:
            return ModelResponse(
                tool_calls=[
                    ToolCall(
                        id="call_1",
                        name="add_numbers",
                        arguments={
                            "left": 2,
                            "right": 5,
                        },
                    )
                ]
            )

        # Read the actual result returned by the tool.
        for message in reversed(messages):
            if message.role == "tool":
                result = json.loads(message.content)

                if result["success"]:
                    return ModelResponse(
                        content=f"The sum is {result['value']}."
                    )

                return ModelResponse(
                    error_code="tool_failed",
                    error_message="The calculator tool failed.",
                )

        return ModelResponse(
            error_code="missing_tool_result",
            error_message="No calculator result was received.",
        )

# 4. Configure and run the agent.

agent = Agent(
    name="calculator",
    instructions="Use the available calculator tool to add integers.",
    model=DeterministicFakeModel(),
    tools=registry.list_tools(),
)

runner = AgentRunner(max_steps=2)

result = runner.run(
    agent,
    "Add 2 and 5.",
)

if not result.success:
    raise RuntimeError(
        f"Agent run failed: {result.error_code}"
    )

# 5. Inspect the result and execution trace.

report = {
    "success": result.success,
    "output": result.output,
    "tool_calls": [
        call.name for call in result.session.tool_calls
    ],
    "trace_events": [
        event.event_type for event in result.trace.events
    ],
}

print(json.dumps(report, indent=2))

# 6. Demonstrate sensitive-text redaction.

sample = "api_key=demo-secret"
print(redact_sensitive_text(sample))
```

Run the script:

```
python calculator_agent.py
```

This code is intended to produce a successful calculator run followed by a redaction demonstration. Execute it with your installed ArcLM version and capture the actual terminal output before publishing the tutorial.

The execution follows a sequence:

`ToolCall`.
Notice that the second response derives its answer from the returned tool result rather than hardcoding the number `7`.

This makes the example useful for examining the interaction between an agent, a tool, and a model adapter.

The line below is an important part of the implementation:

```
runner = AgentRunner(max_steps=2)
```

An agent that repeatedly requests tools without reaching a final response can consume resources indefinitely unless execution is constrained.

`max_steps` limits the number of model-response steps in a run. In this example, two steps are sufficient: one tool request and one final response.

The limit is not a guarantee that every run will succeed. The model could request a nonexistent tool, return an invalid response, or fail to produce a final answer.

ArcLM represents execution outcomes through structured result objects, which lets the caller inspect success and failure without relying exclusively on printed text.

You can explore the implementation in [`src/arclm/agents/runner.py`](https://github.com/Ahmad-Al-Dibo/ArcLM/blob/cleanup-2.0/src/arclm/agents/runner.py).

A calculator is a convenient first tool because it doesn't modify external state.

A file-writing tool is different. It may overwrite data, create files in unintended locations, or expose information to another process.

ArcLM separates permission declarations from side-effect classification.

For example, a tool that writes to a local workspace could declare:

```
from arclm.agents import (
    SideEffectLevel,
    ToolPermission,
    tool,
)

@tool(
    category="filesystem",
    permission=ToolPermission.WRITE_LOCAL,
    side_effect=SideEffectLevel.REVERSIBLE,
)
def write_report(path: str, content: str) -> str:
    """Write a report to an approved workspace location."""
    # Implement explicit path validation and file-writing policy here.
    ...
```

This is a classification example, not a file-writing implementation. A production tool also needs to validate its destination, restrict allowed paths, handle errors, and define overwrite behavior.

The distinction between the two metadata fields is important:

`permission` declares the kind of access a tool requires.`side_effect` describes the operational consequences of the tool.
**Declaring a side effect alone does not enforce approval.** ArcLM's approval policy uses the tool's permission classification and the configured `ToolPolicy`.

By default, permissions such as `WRITE_LOCAL`, `READ_NETWORK`, `EXTERNAL_ACTION`, and `SENSITIVE` require approval. The default approval handler denies operations that require approval unless an appropriate handler is configured.

For a real application, provide a `ToolApprovalHandler` through `AgentRunner(approval_handler=...)` and define how approval is requested, granted, denied, and audited.

Never treat a descriptive tool schema as a substitute for access control.

Tool outputs and execution logs can contain credentials, tokens, or other sensitive values.

ArcLM provides a redaction utility:

``` python
from arclm.security import redact_sensitive_text

sample = "api_key=demo-secret"

safe_text = redact_sensitive_text(sample)

print(safe_text)
```

The intended transformation is:

```
api_key=[REDACTED]
```

ArcLM also redacts tool-result payloads when trace redaction is enabled by the agent's security policy.

However, redaction has limits. It detects supported patterns; it cannot guarantee that every secret or item of personal information will be recognized.

It is also important to distinguish tool-result redaction from complete session sanitization. For example, the session records tool-call arguments. You should not pass real credentials as tool arguments simply because result redaction is enabled.

For sensitive workflows:

The relevant implementation is available in [`src/arclm/security/redaction.py`](https://github.com/Ahmad-Al-Dibo/ArcLM/blob/cleanup-2.0/src/arclm/security/redaction.py).

A final answer tells you what the agent returned. A trace helps explain how it reached that outcome.

The example collects the trace event names using:

```
trace_events = [
    event.event_type
    for event in result.trace.events
]
```

The runner records events such as:

`run_started`` model_responded``tool_requested`` tool_started``tool_completed`` step_completed``run_completed`
These events help answer practical debugging questions.

Did the model request a tool? Did the runner start it? Did it complete? Did the agent reach a final response or exhaust its step limit?

For the current example, inspecting event names is enough. In a larger application, you can also examine the structured trace and result metadata.

Before persisting traces, decide what information should be retained, who can access it, and how sensitive data should be handled.

This small example separates several responsibilities that are often mixed together in agent implementations:

**Tools define capabilities.** A typed function exposes a specific operation with a discoverable schema.

**The registry manages available tools.** Tools can be registered and selected explicitly.

**The adapter defines model interaction.** Our deterministic adapter demonstrates the protocol without requiring a provider.

**The runner controls execution.** It validates tool calls, coordinates results, and limits the number of steps.

**Permissions and redaction address different security concerns.** Permissions govern access and approval, while redaction reduces exposure of recognized sensitive values.

This is not a production-ready autonomous agent, nor does it demonstrate real language-model reasoning. It is a small, inspectable foundation for understanding how tool-calling systems fit together.

If you want to go further, start with the [ArcLM repository](https://github.com/Ahmad-Al-Dibo/ArcLM) and inspect the canonical [custom tool agent example](https://github.com/Ahmad-Al-Dibo/ArcLM/blob/cleanup-2.0/examples/10_agents_tools/01_custom_tool_agent.py).

The next experiment is to replace the fake adapter with a real model adapter while keeping the same tool boundary, step limit, permission policy, and trace inspection.

That separation is the useful part: you can change the model backend without having to redesign the tool's Python implementation.
