Tool Calling with Arcee: A Small, Inspectable Workflow Before Agents
Build with Arcee Views 0

Tool Calling with Arcee: A Small, Inspectable Workflow Before Agents

Most agent demos hide failures behind complex loops and framework layers, making debugging difficult. This article demonstrates how to build a small, highly inspectable tool-calling workflow using an Arcee model. By defining tools as plain data, enforcing a strict single-tool decision prompt, and tracking a visible execution history, developers can maintain complete control, prevent infinite loops, and easily diagnose argument or model errors before scaling up to full agents.

Most “agent” demos start with a dozen tools, a complex planner, and a loop that can run for minutes. When something goes wrong—and something always goes wrong—the failure is buried under layers of framework code. You cannot see which tool was chosen, what arguments were generated, or why the model decided to call it again.

I no longer start there.

This post builds the smallest useful tool-calling workflow I know how to write with an Arcee model. One or two tools, an explicit decision step, visible arguments, and a hard limit on the number of turns. The result is easy to inspect, easy to debug, and easy to turn into a more capable agent later—once the basic control flow is solid.

Trinity Mini is the model I use for these examples. It has been reliable enough at following tool schemas when the prompt is strict and the temperature is low. The same pattern works with Nano for the simplest cases and with Large Thinking when the planning needs more depth.

What You Will Build

A laptop screen displaying a complex AI agent debugging interface with nested logs.

A single Python function that:

  1. Accepts a user request and a list of available tools (name, description, JSON schema for arguments).

  2. Asks the model to choose at most one tool and supply valid arguments.

  3. Validates the tool call.

  4. Executes the tool (or simulates it).

  5. Feeds the result back and either returns a final answer or stops after a fixed number of steps.

No autonomous loop that can wander forever. No hidden state. Everything the model sees and decides is printed.

Defining Tools as Plain Data

I keep tool definitions boring and serializable.

Python

from pydantic import BaseModel, Field
from typing import Callable, Any

class Tool(BaseModel):
    name: str
    description: str
    parameters: dict  # JSON Schema
    function: Callable[..., Any]  # the actual Python function

def get_weather(city: str) -> str:
    # Replace with a real API call; this is a stub for the example
    return f"Weather in {city}: 18°C, clear"

weather_tool = Tool(
    name="get_weather",
    description="Return the current weather for a given city",
    parameters={
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name"}
        },
        "required": ["city"],
    },
    function=get_weather,
)

You can add a second tool the same way. Keep the list short while you are learning the control flow.

The Decision Prompt

The model is not asked to “be an agent.” It is asked to make one explicit choice.

Python

def build_tool_decision_prompt(user_message: str, tools: list[Tool]) -> list[dict]:
    tool_list = "\n".join(
        f"- {t.name}: {t.description}\n  Parameters: {t.parameters}"
        for t in tools
    )
    system = (
        "You are a careful tool-using assistant. "
        "You have access to the following tools:\n"
        f"{tool_list}\n\n"
        "If a tool is needed, respond with a JSON object of the form:\n"
        '{"tool": "tool_name", "arguments": {...}}\n'
        "If no tool is needed, respond with:\n"
        '{"tool": null, "answer": "your final answer"}\n'
        "Never call more than one tool at a time. "
        "Do not invent tools or arguments. "
        "Output only the JSON object, nothing else."
    )
    return [
        {"role": "system", "content": system},
        {"role": "user", "content": user_message},
    ]

The schema is simple on purpose. Complex parallel tool calling and multi-step planning come later, after this path is reliable.

The Inspectable Loop

Python

import json
from typing import Optional

def run_tool_workflow(
    user_message: str,
    tools: list[Tool],
    generate_fn,
    max_steps: int = 3,
) -> dict:
    messages = build_tool_decision_prompt(user_message, tools)
    tool_map = {t.name: t for t in tools}
    history = []

    for step in range(max_steps):
        raw = generate_fn(messages)  # your existing local or API call
        text = raw.strip()

        # Defensive cleanup
        if text.startswith("```"):
            text = text.strip("`").removeprefix("json").strip()

        try:
            decision = json.loads(text)
        except json.JSONDecodeError as e:
            return {
                "status": "error",
                "error": f"Invalid JSON from model: {e}",
                "raw": text,
                "history": history,
            }

        history.append({"step": step, "decision": decision})

        tool_name = decision.get("tool")
        if tool_name is None:
            return {
                "status": "final",
                "answer": decision.get("answer", ""),
                "history": history,
            }

        if tool_name not in tool_map:
            return {
                "status": "error",
                "error": f"Unknown tool: {tool_name}",
                "history": history,
            }

        tool = tool_map[tool_name]
        args = decision.get("arguments", {})

        # Execute
        try:
            result = tool.function(**args)
        except Exception as e:
            result = f"Tool error: {e}"

        history[-1]["result"] = result

        # Feed the observation back
        messages.append({"role": "assistant", "content": text})
        messages.append({
            "role": "user",
            "content": f"Tool result: {result}\n\nDecide the next action (another tool call or final answer)."
        })

    return {
        "status": "max_steps_reached",
        "history": history,
    }

Every decision, every argument list, and every tool result is stored in history. When the workflow finishes (or hits the step limit) you can print the entire trace. That trace is the feature.

Terminal output showing structured workflow steps, decision history, and tool results.

Design Choices That Keep It Debuggable

One tool at a time. Parallel calls and multi-tool planning add failure modes that are hard to diagnose while you are still learning. Add them only after single-tool calling is solid.

Hard step limit. Three is enough for most simple workflows. If the model has not finished by then, something is wrong with the prompt or the tool design.

JSON-only responses. The same validation discipline from the structured-output post applies. If the model drifts, the parse fails loudly instead of producing a half-working agent.

Explicit final-answer path. The model must actively choose to stop. This prevents the common failure where the model keeps calling tools forever because it was never told how to finish.

Visible history. I print the full trace in development. In production I log it. Either way the information is not thrown away.

What I Observe With Trinity Mini

On clean, single-tool requests (weather, simple calculator, document lookup) Mini produces valid tool calls at a high rate when temperature is 0.0–0.2 and the schema is shown clearly. Argument errors still happen—missing required fields, wrong types, hallucinated parameter names—but the validation step catches them before execution.

When the user request is ambiguous, Mini sometimes chooses a tool that is only marginally relevant. The visible history makes those choices obvious so you can tighten the tool descriptions or the decision prompt.

I do not treat these observations as permanent. A new model revision can shift the behavior, which is why the revision pin and a small smoke suite of tool-calling examples stay in the repository.

Common Failure Modes and How the Trace Reveals Them

Symptom

What the history usually shows

Model invents a tool name

Decision JSON contains a name not in the tool list

Wrong argument types

Arguments fail when passed to the real Python function

Infinite tool loop

Step limit is hit; each step calls a tool instead of returning an answer

Tool called when none is needed

Decision contains a tool name on a pure information request

Final answer ignores tool result

The observation was appended, but the next decision never referenced it

All of these are trivial to spot once the history is printed. They are painful to diagnose inside a large agent framework.

A Practical Note From Use

The first tool-calling system I put into a real internal tool started as a full agent with five tools and an open-ended loop. It worked in the demo and failed in subtle ways once real users asked real questions. I stripped it down to the pattern above—one tool, visible decisions, hard step limit—and the failure rate dropped sharply. Only after the simple path was reliable did I carefully add a second tool and a second optional step. The discipline of starting small has been more valuable than any framework feature.

Next Steps

Once this workflow is stable you can:

  • Add a second tool and watch how the decision quality changes

  • Introduce the structured-output validation loop around the decision JSON

  • Persist the history for later analysis

  • Wrap the same function behind a FastAPI endpoint

  • Only then consider a more open-ended agent loop

Tool calling is not magic. It is a constrained decision step plus an execution step plus a clear stopping condition. Keep those pieces visible and the Arcee model becomes a reliable component instead of an opaque planner.

Test it. Understand it. Then ship it.

— Theo Calder

Independent AI developer, San Francisco

Comments

No comments yet — be the first to share a thought.

Leave a comment

Last Updated: 2026-11-05 11:21