Dinesh/ Blog
machine learning

LLM Agents — Function Calling, Tools, Planning

LLM Agents — Function Calling, Tools, Planning: mechanisms, worked examples, assumptions and source-backed corrections with explicit experiment boundaries.

18 min readChecking device speech…
Lesson preparation & details

Level: advanced

Language model engineering · Lesson 15 of 26 ↗
Loading this browser’s progress…

By the end, you should be able to

  • Explain function calling / tool use as a protocol on top of a chat completion, in one paragraph.
  • Write a JSON schema for a tool that the LLM will call with explicit validation.
  • Implement a ReAct-style agent with provider-specific tool protocols.
  • Diagnose the four failure modes: schema mismatch, infinite loop, hallucinated tools, planning drift.
  • Pick between ReAct, Plan-and-Execute, and Reflexion patterns for a new problem.

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 9 sections

Why this session exists

An agent is not a model with permission to do anything. It is a controller around model proposals, a tool registry, observations and termination rules. Imagine an assistant writing requests on a whiteboard: a coworker still checks authorization before carrying them out. The protocol transports proposed function names and arguments; it does not itself execute or authorize them.

You will design schemas, implement a bounded tool loop, diagnose invalid calls and planning drift, and choose between reactive control, explicit planning and reflection. Prompting (116), sampling (113) and retrieval (118) are prerequisites. A retrieval tool is one capability, not a requirement that every agent have a vector database.

Protocol, schemas and authority

The five moving parts are model, tools, controller, stopping policy and state/history. In OpenAI Chat Completions, an assistant tool call has an ID, function name and JSON-string arguments; each result uses the matching tool_call_id. Preserve the assistant call message before its results. Anthropic uses tool_use blocks and corresponding tool_result blocks with tool_use_id in user messages; this is a similar concept, not an identical wire format. Multiple calls can arrive in one turn; parallel execution is safe only for independent authorized operations.

A descriptive tool name, precise parameter descriptions, required fields, enums and explicit units reduce ambiguity. They cannot guarantee success on the first try. A schema default is not application-side default insertion. Ordinary function calling is not synonymous with constrained decoding: OpenAI Python 1.109.1's function-call type explicitly warns about invalid JSON and hallucinated parameters. Supported strict structured-output modes impose a separate schema contract; neither proves factual correctness or permission.

{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "Read the local teaching weather fixture; not live weather.",
    "parameters": {
      "type": "object",
      "properties": {
        "city": {"type": "string", "maxLength": 80},
        "units": {"type": "string", "enum": ["celsius", "fahrenheit"]}
      },
      "required": ["city", "units"],
      "additionalProperties": false
    }
  }
}

Validate name and exact keys, then types/ranges, then current actor/resource permissions, then budgets, before dispatch. Low temperature can reduce sampling variation; it is not a determinism, schema or safety guarantee and some models do not expose it. ast.literal_eval parses Python literals, not general arithmetic; dynamic eval is unnecessary for fixed conversion tools.

Tool exceptions can remain normal Python exceptions internally. Catch expected failures at the controller boundary and return sanitized error data the model can act on; cancellation, permission denial and infrastructure faults may require termination rather than another model attempt. Never return raw stack traces or secrets. A finish request is only a proposal: validate its arguments and verify task completion separately from stopping.

Reactive planning, plans and reflection

ReAct interleaves reasoning, action and observations. The 2022 preprint is a particular prompting method, not a synonym for every tool API. Reacting to a search result before choosing the next page is useful because the environment is partially observed. A fixed plan that ignores changed evidence is open-loop; plan-and-execute with feedback and replanning is also closed-loop. Known expensive steps can benefit from upfront dependency planning and parallelism.

Reflexion's 2023 paper describes verbal feedback retained across trials, not weight updates and not a 2024 invention. Reflection is useful when an external test or grounded critique identifies an error and a new attempt is affordable. It can also reinforce a false belief. No general HumanEval +20-point or fixed cost multiplier is asserted here. Versioned prompts, tool contracts, evidence and test outputs are more useful “memory” than a self-congratulatory summary.

Under the simplifying assumption that all n required steps succeed independently with probability p, success is p^n. At p=.95, three steps yield .8574, ten .5987 and twenty .3585. Achieving .9 over fifty steps requires p at least .9**(1/50) ≈ .9979. This is a design model, not an observed reliability law: correlated errors, recovery, optional steps and verification alter it. Reduce unnecessary risky steps and verify outputs, but a retry that successfully recovers can improve overall success rather than necessarily making it worse.

Reactive planning, plans and reflection
Figure text equivalent — labels and relationships
  • Node “User objective and permissions” (square).
  • Node “Model proposal” (square).
  • Node “Validate schema and authority” (square).
  • Node “Budgets and call allowed?” (diamond).
  • Node “Explicit failure or escalation” (square).
  • Node “Execute bounded tool” (square).
  • Node “Sanitized observation with call ID” (square).
  • Node “Verified completion?” (diamond).
  • Node “Final answer with evidence” (square).
  • “User objective and permissions” leads to “Model proposal”. Line: normal; ends: arrow point.
  • “Model proposal” leads to “Validate schema and authority”. Line: normal; ends: arrow point.
  • “Validate schema and authority” leads to “Budgets and call allowed?”. Line: normal; ends: arrow point.
  • “Budgets and call allowed?” leads to “Explicit failure or escalation”: no. Line: normal; ends: arrow point.
  • “Budgets and call allowed?” leads to “Execute bounded tool”: yes. Line: normal; ends: arrow point.
  • “Execute bounded tool” leads to “Sanitized observation with call ID”. Line: normal; ends: arrow point.
  • “Sanitized observation with call ID” leads to “Verified completion?”. Line: normal; ends: arrow point.
  • “Verified completion?” leads to “Model proposal”: no. Line: normal; ends: arrow point.
  • “Verified completion?” leads to “Final answer with evidence”: yes. Line: normal; ends: arrow point.

Hands-on: tested controller with scripted proposals

This CPU example replaces the original paid-model loop with dependency-injected proposals. It tests the actual controller and tools, not model planning quality. Weather is an explicit fixture. Three tools remain: weather, conversion and finish. Invalid JSON, arrays, unknown names, invalid units, duplicate call IDs and false completion are negative controls. No service, model weights or network calls are needed.

import json
import math
import time
 
WEATHER = {"tokyo": 22., "london": 12., "san francisco": 17.}
UNITS = {"celsius", "fahrenheit"}
 
def dispatch(name, args):
    if not isinstance(args, dict):
        raise ValueError("object arguments required")
    fields = {"get_weather": {"city", "units"},
              "convert_temp": {"value", "from_unit", "to_unit"},
              "finish": {"answer"}}
    if name not in fields or set(args) != fields[name]:
        raise ValueError("unknown tool or incorrect keys")
    if name == "finish":
        if not isinstance(args["answer"], str) or not 1 <= len(args["answer"]) <= 500:
            raise ValueError("bounded nonempty answer")
        return args["answer"]
    if name == "get_weather":
        city, units = args["city"], args["units"]
        if not isinstance(city, str) or not 1 <= len(city) <= 80 or units not in UNITS:
            raise ValueError("city or units")
        if city.lower() not in WEATHER:
            raise ValueError("unknown fixture city")
        value = WEATHER[city.lower()]
        return {"value": value if units == "celsius" else value*9/5+32,
                "units": units, "source": "teaching fixture"}
    value, src, dst = args["value"], args["from_unit"], args["to_unit"]
    # Bound arbitrary-size JSON integers before a float-based finiteness check.
    if type(value) not in (int, float) or abs(value) > 1e6 or not math.isfinite(value):
        raise ValueError("bounded finite number")
    if src not in UNITS or dst not in UNITS:
        raise ValueError("units")
    return value if src == dst else value*9/5+32 if src == "celsius" else (value-32)*5/9
 
def run(proposals, verify, max_steps=8, max_chars=4000, seconds=1):
    history, seen = [], set()
    start, spent = time.monotonic(), 0
    for step, call in enumerate(proposals):
        if step >= max_steps or time.monotonic()-start >= seconds:
            return {"status": "budget_exhausted", "history": history}
        cid, name, raw = call
        spent += len(raw)
        if spent > max_chars:
            return {"status": "budget_exhausted", "history": history}
        if cid in seen:
            return {"status": "duplicate_call", "history": history}
        seen.add(cid)
        history.append({"role": "assistant", "id": cid, "name": name, "arguments": raw})
        try:
            args = json.loads(raw)
            result = dispatch(name, args)
        except (ValueError, TypeError) as exc:
            result = {"error": str(exc)}
        history.append({"role": "tool", "tool_call_id": cid, "result": result})
        if name == "finish" and isinstance(result, str):
            return {"status": "complete" if verify(result, history) else "unverified",
                    "answer": result, "history": history}
    return {"status": "partial", "history": history}
 
calls = [("1", "get_weather", '{"city":"Tokyo","units":"celsius"}'),
         ("2", "convert_temp", '{"value":22,"from_unit":"celsius","to_unit":"fahrenheit"}'),
         ("3", "finish", '{"answer":"71.6 F in the Tokyo fixture"}')]
verify = lambda a, h: "71.6" in a and any(x.get("result") == 71.6 for x in h)
assert run(calls, verify)["status"] == "complete"
assert run(calls, verify, max_steps=2)["status"] == "budget_exhausted"
assert run(calls[-1:], verify)["status"] == "unverified"
assert run([calls[0], calls[0]], verify)["status"] == "duplicate_call"
for name, raw in [("finish", "{"), ("finish", "[]"), ("shell", '{}'),
                  ("get_weather", '{"city":"Mars","units":"kelvin"}')]:
    result = run([("bad", name, raw)], verify)
    assert result["status"] == "partial" and "error" in result["history"][-1]["result"]
oversized = json.dumps({"value": 10**400, "from_unit": "celsius", "to_unit": "fahrenheit"})
assert "error" in run([("huge", "convert_temp", oversized)], verify)["history"][-1]["result"]
assert math.isclose(dispatch("convert_temp", {"value":71.6,"from_unit":"fahrenheit","to_unit":"celsius"}),22)
assert math.isclose(.95**20, .3584859224, rel_tol=1e-9)
print("controller contracts pass; no model planning evaluated")

The wall-clock guard is checked between operations; it cannot interrupt a hung blocking tool. Production requires tool-specific timeouts/cancellation and a reserved budget before model/tool calls. The character budget here is a toy resource counter, not token metering or dollars. Add actual token usage/pricing and per-call reservations in a real API adapter. The output verifier is deliberately narrow; it does not validate arbitrary natural-language answers.

Restoring the provider integration safely

To connect the controller to a pinned SDK, replace the scripted iterator with the provider response loop: send system/user history and schemas; validate a response exists and is not a refusal/truncation; append the assistant response; dispatch every authorized call; append one matching result per ID; then make the next call. For independent parallel calls, reserve all budgets and reconcile every result before continuing. Do not return midway through a call group and leave protocol history incomplete.

The original code could reference args before assignment after malformed JSON, call finish despite validation failure, accept invalid units as Fahrenheit, return NaN, and let a max-step limit masquerade as success. The tested replacement removes those faults. A real OpenAI/Anthropic adapter, live weather, model selection and behavior under refusals remain source-reviewed integration work, not executed here. No API key is requested and no hosted model is called.

Production controls and worked design choices

Four diagnostic categories are invalid schema/arguments, unknown tools, repeated calls or missing stop, and goal drift. They are not an empirically ranked “top four.” Log structured call ID, actor, tool version, sanitized arguments/result, duration, budget and status. Avoid dumping private context. Compare actual tool success, trajectory length, unauthorized-action rate and final outcome, not just fluent narration.

A known pipeline is easier to test for control flow; its LLM outputs remain stochastic. A constrained agent earns its cost when inputs require genuine routing variation within a small authorized action set. Open-ended exploration needs reversible actions and human review, not expanded permissions. Anthropic's workflow/agent distinction recommends the simplest sufficient system; it does not prove that a weaker model with scaffolding always beats a stronger one.

For a weather request, the controller can call the fixture once then convert with deterministic code; no second LLM decision is necessary to perform known arithmetic. For “weather on Mars,” preserve the tool's no-data observation and answer that the fixture lacks it. Do not invent a value. For a write-capable agent, require authorization outside the model, idempotency keys for retryable side effects, confirmations where policy requires them, and explicit failure on exhausted budgets. Tool output and retrieved pages are untrusted evidence, not new controller instructions.

The original function-calling launch percentages and runaway dollar anecdotes are unsupported. Their retained lesson is concrete: transport structure is not validation, and steps/time/spend must all be bounded. A system prompt asking for finish, descriptive tools and actionable errors can help behavior but are not control-plane enforcement.

Recall and stretch: complete answers

  1. Loop: propose; validate/authorize/execute; observe and either continue or return a verified final/explicit partial state.
  2. Temperature zero? A possible low-variance setting where supported, not deterministic selection or a safety boundary.
  3. Errors as data? Catch expected exceptions at the tool boundary and expose bounded useful observations; fatal/cancelled/forbidden operations should not be blindly retried.
  4. Failures: invalid arguments, unknown tools, looping/stopping faults and planning drift. Test all rather than claiming a universal frequency ranking.
  5. Plan-and-execute: use explicit dependencies for predictable complex work; revise after observations. There is no five-step cutoff.
  6. Reflexion: verbal feedback across attempts, ideally grounded in tests; it costs extra work and can preserve errors as well as lessons.
  7. Limits: steps, wall time and metered resources/spend, plus permissions and per-tool bounds. A step cap alone does not cap one huge call.
  8. Workflow default: predefined paths are preferable when sufficient because they expose control flow and budgets; model calls are still fallible.
  9. Function calling versus JSON prompting: a typed call protocol supplies names/arguments/IDs. Strict schema constraints are a distinct supported mode, and application validation remains necessary.
  10. Scaffolding stretch: tools, state, validators, retries, budgets and feedback surround the model. Evaluate model and scaffolding jointly; neither alone guarantees capability.

Explain the protocol without equating proposals with actions. Then break the fixture with malformed JSON, an unknown city and an early budget cap, and describe the explicit returned status. Orchestration (121), evaluations (122) and serving (124) build on these boundaries; side-effect safety and checkpointing are cross-cutting concerns, not invented series destinations.

Optional resources and onward navigation

The original optional pre-read links are retained as navigation, not fact-check evidence or promises about video length, quality or API stability. Use the versioned primary sources in the teaching for consequential claims. Original pacing was a suggested 90-minute session, not a completion guarantee.

Next in the original sequence: S121 · Multi-Agent Orchestration — LangGraph, CrewAI Patterns. Publication and release navigation are adjudicated by the parent.

Hub: The 6-Month Learning Plan. The original 130-session scaffold is retained as navigation, not evidence that every destination has been reviewed.

Review and execution boundary

Reviewed against complete original and pre-edit current teaching with claim-specific primary evidence and independently replayed coverage. The executable CPU program is a bounded teaching test, not a pretrained model, serving, RAG or training benchmark. External model/service recipes remain source-supported and unexecuted. No weights, datasets, GPU, paid API or new service were used. Source publication is not end-to-end application acceptance.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.