Agent Frameworks - Agent Loops, Tool Calling, and When Not To

Status: Active
Last Updated: 2026-08-26
Category: AI/ML - Phase 4: Agents
Prerequisites: python-llm-integration, prompt-engineering, agentic-workflows
Time: 3 hours
Tags: agents, tool-calling, langgraph, crewai, ollama, orchestration

Summary

What an agent actually is (a model in a loop with tools), how tool calling works against Ollama, a survey of framework options from a hand-rolled loop through LangGraph to CrewAI, and โ€” most importantly โ€” the cases where no agent is the right answer.

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


Context / Why This Matters

Everything built so far โ€” chat, extraction, RAG (rag-pipeline) โ€” is single-shot: one request, one response. An agent adds agency: the model decides which steps to take, calls tools, inspects results, and iterates until done. That power is exactly what makes agents unreliable and expensive, especially on local 8B-class models. This article builds the minimal correct mental model first, frameworks second, restraint third. For the broader non-AI workflow context on this server, see agentic-workflows.


Implementation / Core Content

The Agent Loop

Strip away marketing and every agent is:

loop:
    1. send conversation (system + history + tool results) to model
    2. model replies with text OR tool-call request(s)
    3. if tool calls: execute them, append results as messages, continue loop
    4. if plain text (final answer): stop
guardrails: max_iterations, timeout, allowlist of tools, human approval gates

Two properties follow immediately:

Tool Calling With Ollama

Modern Ollama models (llama3.1+, qwen2.5+, mistral-nemo etc.) support native tool calling: the model emits structured tool requests instead of imitating JSON in prose.

import json, ollama

def get_disk_free(path: str) -> str:
    import shutil
    total, used, free = shutil.disk_usage(path)
    return json.dumps({"path": path, "free_gb": round(free / 1e9, 1),
                       "total_gb": round(total / 1e9, 1)})

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_disk_free",
        "description": "Get free and total disk space in GB for a filesystem path.",
        "parameters": {
            "type": "object",
            "properties": {"path": {"type": "string", "description": "absolute path"}},
            "required": ["path"],
        },
    },
}]

messages = [{"role": "user", "content": "How much space is left on /srv? Should I worry?"}]
available = {"get_disk_free": get_disk_free}

for _ in range(5):                                   # hard iteration cap!
    resp = ollama.chat(model="llama3.1:8b", messages=messages,
                       tools=TOOLS, options={"temperature": 0})
    msg = resp["message"]
    messages.append(msg)
    if not msg.get("tool_calls"):
        print(msg["content"])                        # final answer
        break
    for call in msg["tool_calls"]:
        fn = available[call["function"]["name"]]
        result = fn(**call["function"]["arguments"])
        messages.append({"role": "tool", "content": result,
                         "name": call["function"]["name"]})
else:
    print("Aborted: iteration limit reached")        # never loop unbounded

Tool definitions are effectively prompts: descriptions decide whether the model calls the right tool. Write them like API docs for a junior dev โ€” say what it returns, units included โ€” and validate arguments server-side regardless of what the model passes.

Non-tool-calling fallback for older/smaller models: ask for {"tool": ..., "args": {...}} JSON (prompt-engineering), parse defensively, and treat parse failure as "ask user". Works, but strictly worse โ€” prefer tool-capable models (model-selection-guide).

Framework Survey

Option 1: Custom Loop (Recommended Starting Point)

The code above plus logging and a retry wrapper. You already own everything else: timeouts/retries (python-llm-integration), eval harness (prompt-engineering).

Option 2: LangGraph

Graph-of-steps extension of LangChain: nodes are functions (LLM call, tool executor, validator), edges define transitions including conditional branching and cycles, with checkpointing for resumable runs (langchain-integration).

# shape only โ€” see LangGraph docs for full setup
graph.add_node("agent", call_model_with_tools)
graph.add_node("tools", execute_tools)
graph.add_conditional_edges("agent", route, {"need_tool": "tools", "done": END})
graph.add_edge("tools", "agent")          # the agent loop, explicit

Option 3: CrewAI (and role-based multi-agent generally)

Define agents with roles ("researcher", "reviewer") and delegate tasks between them.

Decision table:

Need Pick
One task, โ‰ค3 tools Custom loop
Deterministic pipeline with occasional LLM steps Plain code calling the LLM โ€” no loop at all
Branching/resumable workflow, human approvals LangGraph
Multi-agent collaboration theater Usually none; see below

When NOT To Use an Agent

Most things people build agents for are better as ordinary code:

  1. Known, fixed sequence of operations ("fetch logs โ†’ summarize โ†’ email"). Write a script with LLM calls inside (python-llm-integration). No loop needed; the control flow is already known.
  2. Single retrieval question. That's RAG (rag-pipeline), not an agent. Retrieval-augmented answering with zero tool decisions is cheaper and far more predictable.
  3. Structured extraction. Schema-constrained output, one call, done (prompt-engineering).
  4. Anything touching destructive actions without a human gate: deleting files, restarting services, spending money. If you do build it, require explicit approval between propose and execute โ€” an agent loop around rm is how homelab incidents happen.
  5. High-volume automation. Per-item agent loops at scale burn hours of GPU time for marginal gains over templates plus one summarization pass.

Litmus test: "Can I write the flowchart ahead of time?" If yes, write code that follows the flowchart and use the LLM only inside its boxes. Agents are for when the flowchart itself must be discovered at runtime โ€” accept their cost consciously.


Practical Examples

Example 1: Multi-Tool Homelab Assistant Loop

Extend the core loop with two tools and a refusal rule:

TOOLS += [disk_cleanup_dry_run_tool, systemd_status_tool]

SYSTEM = """You are a homelab ops assistant with tools:
get_disk_free, systemd_status, cleanup_dry_run.
Rules:
- Call cleanup_dry_run NEVER without showing its output to the user first.
- Max 2 tool calls per question; then answer.
- If tools don't cover the question, say so."""

Expected behavior trace: user asks about space โ†’ get_disk_free(/srv) โ†’ answer with numbers. If the model tries a third call, the loop cap ends it gracefully with partial findings.

Example 2: Approval Gate Before Mutating Actions

pending = None
if call["function"]["name"] == "cleanup_dry_run":
    pending = call                      # don't execute yet
    print("PROPOSED:", call["function"]["arguments"])
    if input("Approve? [y/N] ").lower() != "y":
        messages.append({"role": "tool", "content": "USER DENIED ACTION",
                         "name": call["function"]["name"]})
        continue

Denials go back into the transcript so the model adapts instead of retrying blindly.

Example 3: Deciding Against an Agent (Real Case)

Task: "every morning, check backup success and post a summary to chat."

Agent-shaped approach (bad): let the model choose journalctl/grep/post tools each day โ€” nondeterministic, slow, occasionally wrong.

Deterministic approach (right): a cron'd script that greps exit codes, builds a fixed-format summary, makes ONE LLM call to phrase it politely, posts it. Zero loop, zero drift, testable. Reserve agents for questions like "why did backups fail this week?" where investigation order can't be predicted.


Troubleshooting & Common Pitfalls

Problem Cause Fix
Agent loops forever repeating a failed tool No iteration cap / model ignores errors Hard max_iterations; feed errors back explicitly; temperature 0
Model hallucinates tool names Tools poorly described or too many Fewer tools, precise descriptions; use native tool calling not prose-JSON
Wrong arguments passed to tools Schema ambiguity; trusting model output Strict Pydantic validation server-side; reject and report errors back
Each turn gets slower and pricier Transcript grows every iteration Summarize/truncate tool outputs before appending (context-and-tokens)
Tool call JSON malformed on small models Model lacks native tool support Switch to a tool-capable model; add parse-failure retry once, then bail
Multi-agent crew produces rambling loops Role-play overhead exceeds capability of local models Collapse to single agent, or to deterministic code
Agent succeeded once, fails unpredictably Success by luck, not design Build eval cases (prompt-engineering); require N/N pass rate before trusting
Destructive action executed wrongly No human gate Approval step before any mutating tool executes

Next Steps / Ops Actions

Sources & Related

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse