[KongchangAI]
· 4 min read· 2,411 words

No GPU Needed: Building Reproducible Local Agent Tools with ArcLM

No GPU Needed: Building Reproducible Local Agent Tools with ArcLM

ArcLM uses layered design and a deterministic fake model to show the full pipeline and security boundaries of Agent tool calling.

Using ArcLM's small calculator Agent, this article walks through the complete implementation of an Agent tool-calling pipeline. It separates tool definitions, the tool registry, the model adapter, the runner, and security policies so business logic stays as ordinary Python and models can be swapped without changing tools. It shows @tool decorators with type annotations and permission/side_effect metadata, ToolRegistry registration, a deterministic fake model running without GPU or external API, and AgentRunner(max_steps=2) to prevent infinite loops. It stresses that Schema is not security, declaring side effects does not enforce approval, and redaction cannot replace secret management.

AI Agents are truly useful not because they can generate beautiful text, but because they can call functions, validate arguments, perform actions, and return structured results. But once you let an Agent touch tools, engineering problems come thick and fast: what arguments can a tool accept? How do you prevent the Agent from looping forever? How do you inspect which tools it called? How do you manage permissions when a tool needs to modify files? And how do you avoid leaking sensitive information in diagnostic logs?

This article uses a small, reproducible calculator Agent to demonstrate ArcLM's tool-calling pipeline. No GPU and no external LLM API are required throughout — we run the entire pipeline with a deterministic fake model adapter. It's worth emphasizing that the fake model is just a test adapter used to verify the tool-calling mechanism, not to showcase an LLM's reasoning capabilities.

ArcLM local Agent tool example source

Why Tool Calling Deserves Its Own Design

Many Agent implementations mix capability definitions, execution control, and security policies together, making it hard later to swap models, hard to audit, and hard to limit risk. ArcLM's approach is to separate these responsibilities: tools define capabilities, the registry manages available tools, the adapter handles model interaction, the runner controls execution boundaries, and permissions and redaction each handle their own security concerns.

The direct benefit of this layering is that business logic can remain as ordinary Python code, independent of the Agent runtime. When you later want to swap the fake model for a real one, you only need to replace the adapter — no need to rewrite the tools themselves.

Defining a Typed Tool

Let's start with the core piece — a simple integer addition function:

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

The implementation is only a few lines, but the metadata is crucial. The @tool decorator exposes the function as an ArcLM tool, type annotations describe the parameters and return type, the docstring serves as the description, and category is used for grouping.

Permission and side-effect declarations describe the operation's behavior: ToolPermission.PURE means no external resources need to be accessed, and SideEffectLevel.NONE means no side effects. The function itself remains ordinary Python; ArcLM just wraps a tool abstraction around it.

SideEffectLevel describes whether the impact a tool has on the outside world after execution can be undone. Common levels include NONE (pure computation, no change to any external state), REVERSIBLE (can be undone, e.g., writing to a temporary file that can be rolled back), and irreversible or externally visible operations. It is independent of permission: permission answers "what resources does this tool need to access," while side effect answers "what consequences will remain after access." A tool might only need to read a local file (low permission) yet send its contents over the network (high impact), or it might have high permission but fully reversible side effects. Declaring these two separately lets the runner and approval policy independently decide whether authorization is needed and whether human confirmation is required.

Registering Tools and Inspecting the Schema

ArcLM manages tools by name through the ToolRegistry:

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 provides a unified way to retrieve tools and inspect metadata. input_schema describes the expected parameters, which the model adapter can use to decide how to construct a tool call. This is far more explicit than throwing arbitrary Python functions at a model.

But note: a Schema can describe what a function accepts, but it doesn't prove the tool is safe. Argument validation and side-effect access control must still be handled separately. ArcLM's tool abstraction validates arguments before calling the underlying function.

JSON Schema is a specification for describing the structure of JSON data, using keywords like type, properties, required, enum, and minimum to constrain the data's shape. In tool-calling scenarios, the model adapter converts each tool's input_schema into a function signature the model API can understand; the model generates an argument object conforming to that structure, and the runner uses the same Schema to validate whether the arguments the model actually returned are legal. The point is to decouple "model generation" from "argument legality": the model is only responsible for filling in values according to the structure, while legality is guaranteed by deterministic validation logic, preventing invalid input from reaching business functions. Note that Schema validation only checks structure and type — it cannot express policy constraints like "is this path allowed to be written to," which must be handled by the permission and approval layer.

Running the Agent Loop with a Deterministic Fake Model

A real model would decide on its own when to call add_numbers and what arguments to pass. This introduces external dependencies and possibly nondeterminism. For reproducibility, we implement a minimal fake model: the first time it returns a tool-call request, the second time it reads the structured result and gives a final reply.

Here's the complete example (save it as calculator_agent.py):

import json

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



The model adapter is an abstraction layer between the Agent framework and a specific LLM provider, responsible for converting the framework's internal conversation and tool definitions into the request format the provider API expects, and then converting the returned result back into the framework's `ModelResponse` structure. An adapter typically needs to implement a method like `generate_response(messages, tools, instructions, session)`, so the runner doesn't need to care whether the underlying implementation is OpenAI, a local model, or a fake test implementation. The `DeterministicFakeModel` in this article is exactly such an adapter: it ignores model reasoning and returns a preset tool call or final reply based on the number of invocations. This replaceable design lets the same tools, permissions, and trace mechanisms be fully tested with no network and no GPU, and makes it easy to reuse all business code when switching to a real model.
# 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))

After running python calculator_agent.py, you'll see a successful calculator run and a redaction demo. It's recommended to run it on your own ArcLM version and record the actual output.

What Happens Inside the Agent Loop

The execution order is roughly: the runner sends the conversation and available tool descriptions to the fake model; the model returns a structured ToolCall; the runner checks whether the tool exists and is allowed; ArcLM validates arguments and calls the function; the tool result is written to the session; the fake model reads the result and returns a final reply.

Note that the second reply is derived from the tool's return value, not a hardcoded number 7 — this is exactly what makes this example valuable: it lets you clearly see the interaction between the Agent, tools, and the model adapter.

Why Limit the Agent Loop

The line AgentRunner(max_steps=2) is crucial. An Agent that keeps requesting tools without ever giving a final answer will, without constraint, consume resources without bound. max_steps limits the number of model response steps; two steps are enough for this example: one tool request plus one final reply.

But the limit doesn't guarantee success. The model might request a non-existent tool, return an invalid response, or never give an answer. ArcLM represents execution results with structured result objects, letting callers check success and failure rather than relying solely on printed text.

Permission and Side-Effect Control

The calculator is a good starting point because it doesn't modify external state. A file-writing tool is entirely different — it might overwrite data, create files in unexpected locations, or expose information to other processes.

ArcLM separates permission declarations from side-effect classification. For example, a tool that writes to a local workspace can declare itself like this:

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 just a classification example, not a file-writing implementation. A production tool would also need to validate the target location, restrict allowed paths, handle errors, and define overwrite behavior.

The distinction between the two fields matters: permission declares what kind of access the tool needs, while side_effect describes the operation's consequences. It must be emphasized: merely declaring a side effect does not enforce approval. ArcLM's approval policy uses the tool's permission classification and the configured ToolPolicy. By default, permissions like WRITE_LOCAL, READ_NETWORK, EXTERNAL_ACTION, and SENSITIVE require approval, and the default approval handler rejects operations that require approval unless a suitable handler is configured.

In a real application, you should provide a ToolApprovalHandler via AgentRunner(approval_handler=...) and define how approvals are requested, granted, denied, and audited. Never treat a descriptive tool Schema as a substitute for access control.

Redaction: Handling Sensitive Information

Tool output and execution logs may contain sensitive values like credentials and tokens. ArcLM provides redact_sensitive_text:

from arclm.security import redact_sensitive_text

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

The expected output is api_key=[REDACTED]. When the Agent's security policy enables trace redaction, ArcLM also redacts tool result payloads.

But redaction has limitations. It can only detect supported patterns and cannot guarantee identification of all secrets or personal information. Also distinguish tool-result redaction from full session cleanup — the session records tool-call arguments, so don't actually pass credentials as tool arguments just because result redaction is enabled. Sensitive workflows should follow these practices: avoid putting secrets in prompts and tool arguments; store credentials in a dedicated secret-management mechanism; minimize what goes into diagnostic output; and test redaction effectiveness with real samples from your application.

Redaction and secret management are complementary but not equivalent. Redaction, applied after text has already been produced, uses pattern matching to replace suspected sensitive fragments with placeholders — it's the last line of defense on the output side, and misses are unavoidable. Secret management avoids plaintext from the source, for example by obtaining credentials through environment variables, a secret-management service, or short-lived tokens, so the secret never enters prompts, tool arguments, or session records. The sensible approach is to layer both: credentials are injected via a secret mechanism and discarded immediately after use, and logs and traces are then redacted. Also note that sessions persistently record tool-call arguments, so even with result redaction enabled, you should not pass keys as tool arguments — redaction only covers recognized patterns and cannot guarantee removal of all sensitive content from the session.

Inspecting the Execution Trace

The final answer tells you what the Agent returned, while the trace helps you explain how it got there. The example collects event names via result.trace.events; the runner records events such as run_started, model_responded, tool_requested, tool_started, tool_completed, step_completed, and run_completed.

These events answer real debugging questions: did the model request a tool? Did the runner start it? Did it complete? Did the Agent give a final reply, or exhaust its step limit?

In larger applications, you can further inspect structured traces and result metadata. But before persisting traces, think it through: what information should be retained, who can access it, and how sensitive data is handled.

What This Example Really Demonstrates

This small component separates responsibilities that are often mixed together: tools define capabilities, and typed functions expose a discoverable Schema; the registry manages available tools, supporting explicit registration and selection; the adapter defines model interaction, and a deterministic adapter can demonstrate the protocol without a Provider; the runner controls execution, validating tool calls, coordinating results, and limiting step counts; permissions and redaction handle different security concerns — permissions manage access and approval, while redaction reduces exposure of recognized sensitive values.

It's not a production-grade autonomous Agent, nor does it showcase real language-model reasoning. It's a small, inspectable foundation that helps you understand how a tool-calling system fits together. The next experiment is to swap the fake adapter for a real model adapter while keeping the same tool boundaries, step limits, permission policies, and trace inspection mechanisms. That replaceability is the most valuable part of this design.

One-line Summary

ArcLM uses layered design and a deterministic fake model to demonstrate the complete pipeline and security boundaries of Agent tool calling.

Paragraph Summary

The article uses ArcLM's small calculator Agent to demonstrate a complete implementation of an Agent tool-calling pipeline. The core idea is to layer tool definitions, the tool registry, the model adapter, the runner, and security policies: business logic stays as ordinary Python code, and models can be swapped without affecting the tools themselves. The example exposes a discoverable Schema via the @tool decorator combined with type annotations and permission/side_effect metadata; uses ToolRegistry for unified registration and retrieval; runs the Agent loop with a deterministic fake model adapter without GPU or external API; and uses AgentRunner(max_steps=2) to limit steps and prevent infinite loops. The article emphasizes that a Schema is not security, that declaring side effects does not enforce approval, and that redaction only detects supported patterns and cannot replace secret management, and explains how to diagnose execution through trace events.

#ArcLM#AI Agent tool calling#local Agent tools#deterministic fake model#ToolRegistry#AgentRunner max_steps#sensitive data redaction
Share:

Related articles