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:
- โ Explain the agent loop: reason โ call tool โ observe โ repeat
- โ Implement native Ollama tool calling in Python
- โ Choose between a custom loop, LangGraph, and CrewAI on real criteria
- โ Recognize workflows that should NOT be agents โ and build them deterministically instead
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:
- Cost scales with iterations, not requests. Each iteration re-sends the whole transcript โ context grows quadratically-feeling fast (context-and-tokens).
- Reliability compounds downward. If each step succeeds with p=0.95, a 10-step task succeeds ~60% of the time. Small local models make this worse; design for few steps.
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).
- โ Fully transparent โ every token and decision is yours to log and debug
- โ No dependency churn; trivially testable
- โ Forces good design (few tools, few steps)
- โ You build state management yourself if flows branch
- Best when: โค3 tools, linear task shapes, single agent
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
- โ Explicit control flow โ the loop is visible, unlike monolithic "AgentExecutor" black boxes
- โ Checkpointing/persistence, human-in-the-loop interrupts, streaming built in
- โ Inherits all LangChain abstraction-tax caveats; significant API surface
- Best when: multi-step flows with real branching, retries, or approval gates; team already invested in LangChain
Option 3: CrewAI (and role-based multi-agent generally)
Define agents with roles ("researcher", "reviewer") and delegate tasks between them.
- โ Quick demos of divide-and-conquer narratives
- โ On local hardware, multi-agent means multi-token: costs multiply, error surfaces compound, and role prompts mostly add noise for 8B-class models
- Best when: genuinely separable subtasks with distinct tool sets AND a strong model. Rarely justified on a single-GPU homelab box.
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:
- 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.
- Single retrieval question. That's RAG (rag-pipeline), not an agent. Retrieval-augmented answering with zero tool decisions is cheaper and far more predictable.
- Structured extraction. Schema-constrained output, one call, done (prompt-engineering).
- 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
rmis how homelab incidents happen. - 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
- Harden the LLM calls inside your loop: python-llm-integration
- Ground agent reasoning in your docs via retrieval: rag-pipeline
- Give agents more capability per step with a vector memory: qdrant-setup
- Observe agent runs in production (tokens/sec, GPU): netdata-basics
Sources & Related
External references consulted:
- https://github.com/ollama/ollama/blob/main/docs/api.md (tool calling)
- https://langchain-ai.github.io/langgraph/
- https://docs.crewai.com/
- https://www.anthropic.com/engineering/build-effective-agents (when-not-to-build guidance)
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation by KB build session.