03. Tools and Function Calling — The Agent's "Hands"¶
A note on terminology: this chapter uses "Function Calling" (the name of the mechanism in the API) and "Tool Call" (a specific tool invocation). They are the same thing: the mechanism by which an LLM returns a structured request to call a Go function. In agent code we usually say "tool", because it is a more general concept (a tool may be not only a Go function but also an API call, a shell command, etc.).
Why does this matter?¶
Tools turn an LLM from a talker into a worker. Without tools an agent can only reply with text; it cannot interact with the real world.
Function Calling is the mechanism that gives the model access to real actions: calling Go functions, running commands, reading data.
A real-world case¶
Situation: you built a chatbot for DevOps. The user writes: "Check the status of server web-01"
Problem: the bot cannot actually check the server. It only promises: "I will check the status of server web-01..." — but that's just text.
Solution: Function Calling lets the model call real Go functions. The model generates structured JSON with the function name and arguments; your code executes the function and returns the result back to the model.
Junior employee model. An agent's list of tools is the "job description of a new employee". What you didn't give them, they cannot do; what you gave them, they can — and very quickly. A minimal set of roles, explicit
ReadOnly/Parallelflags and a risk category for each tool is the same idea as for a human: "access only to what the job requires". See Preface → The mental model and Ch. 17: Security and Governance.
The theory in plain words¶
How does function calling work?¶
- You describe the function in JSON Schema format
- The LLM sees the description and decides: "I need to call this function"
- The LLM generates JSON with the function name and arguments
- Your code parses the JSON and executes the real function
- The result is returned to the LLM for further processing
Function Calling — How the Mechanism Works¶
Function Calling is the mechanism by which the LLM returns not text but structured JSON with a function name and arguments.
The full cycle: from definition to execution¶
Let's walk through the full cycle using a ping tool as an example:
For the examples in this chapter we use neutral types layered over the native tool-calling API (OpenAI-compatible). This is exactly what goes into the HTTP request and comes back in the response — without binding to a specific SDK:
// Tool description — goes into the "tools" field of the request.
type ToolDef struct {
Name string
Description string
Parameters json.RawMessage // JSON Schema of the arguments
}
// A message in the history. Role: "system" | "user" | "assistant" | "tool".
type Message struct {
Role string
Content string
ToolCallID string // for role="tool": links the result to the call
ToolCalls []ToolCall // for role="assistant": generated calls
}
// A single tool call generated by the model.
type ToolCall struct {
ID string
Name string
Arguments string // JSON string
}
Step 1: Defining the tool (tool schema)¶
tools := []ToolDef{
{
Name: "ping",
Description: "Ping a host to check connectivity",
Parameters: json.RawMessage(`{
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "Hostname or IP address to ping"
}
},
"required": ["host"]
}`),
},
}
What happens: we describe the tool in JSON Schema format. This description is sent to the model along with the request.
Step 2: The request to the model¶
messages := []Message{
{Role: "system", Content: "You are a network admin. Use tools to check connectivity."},
{Role: "user", Content: "Check if google.com is reachable"},
}
client, err := NewClient(...) // provider from the config (ch. 04)
req := Request{
Model: "glm-5.3",
Messages: messages,
Tools: tools, // The model sees the tool descriptions!
Temperature: 0.2, // quality: less variance in tool selection
}
resp, err := client.Complete(ctx, req) // the error is handled by the loop (ch. 04)
msg := resp.Choices[0].Message
client.Complete is a method of the neutral client; the full canonical types are in chapter 04.
What happens: the model sees:
- System prompt (the role and instructions)
- User input (the user's request)
- Tools schema (the descriptions of the available tools)
Step 3: The model's response (tool call)¶
The model does not return text like "I'll run a ping". It returns a structured tool call:
// msg.ToolCalls contains:
[]ToolCall{
{
ID: "call_abc123",
Name: "ping",
Arguments: `{"host": "google.com"}`,
},
}
What happens: the model generated a tool_call for the ping tool plus JSON with the arguments. There is no magic here: the model saw Description: "Ping a host to check connectivity" and matched it against the user's request.
How does the model choose between several tools?
Let's extend the example with a few more tools:
tools := []ToolDef{
{
Name: "ping",
Description: "Ping a host to check network connectivity. Use this when user asks about network reachability or connectivity.",
},
{
Name: "check_http",
Description: "Check HTTP status code of a website. Use this when user asks about website availability or HTTP errors.",
},
{
Name: "traceroute",
Description: "Trace the network path to a host. Use this when user asks about network routing or path analysis.",
},
}
userInput := "Check if google.com is reachable"
The selection process:
-
The model sees all three tools and their
Description:ping: "check network connectivity... Use this when user asks about network reachability"check_http: "Check HTTP status... Use this when user asks about website availability"traceroute: "Trace network path... Use this when user asks about routing"
-
The model matches the request "Check if google.com is reachable" against the descriptions:
-
ping— the description contains "connectivity" and "reachability" → it picks this one -
check_http— about HTTP status, not about network reachability -
traceroute— about routing, not about reachability checks
-
-
The model returns a tool call for
ping:
An example with a different request:
userInput := "Check whether the google.com site is responding"
// The model sees the same 3 tools
// It matches:
// - ping: about network reachability → not quite it
// - check_http: "Use this when user asks about website availability" → PICKS THIS ONE
// - traceroute: about routing → doesn't fit
// The model returns:
// {"name": "check_http", "arguments": "{\"url\": \"https://google.com\"}"}
The point: the model picks a tool based on the semantic match between the user's request and the Description. The more precise and specific the Description, the better the model picks the right tool.
What the LLM does vs. what the runtime does¶
LLM (the model):
- Sees the tool descriptions in
tools[] - Generates JSON with the tool name and arguments
- Returns a structured response in the
tool_callsfield
Runtime (your code):
- Parses
tool_callsfrom the model's response - Validates the tool name (does it exist?)
- Validates the argument JSON (is it well-formed?)
- Executes the real Go function
- Appends the result to the history (
messages[]) - Sends the updated history back to the model
Important: the LLM does not execute code. It only generates JSON with a request to execute. Your runtime executes the real code.
Step 4: Validation (runtime)¶
// Check that the tool exists
if msg.ToolCalls[0].Name != "ping" {
return fmt.Errorf("unknown tool: %s", msg.ToolCalls[0].Name)
}
// Validate the argument JSON
var args struct {
Host string `json:"host"`
}
if err := json.Unmarshal([]byte(msg.ToolCalls[0].Arguments), &args); err != nil {
return fmt.Errorf("invalid JSON: %v", err)
}
// Check required fields
if args.Host == "" {
return fmt.Errorf("host is required")
}
What happens: the runtime validates the call before executing it. This is critical for security.
Step 5: Executing the tool¶
func executePing(host string) string {
cmd := exec.Command("ping", "-c", "1", host)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("Error: %s", err)
}
return string(output)
}
result := executePing(args.Host) // "PING google.com: 64 bytes from ..."
What happens: the runtime executes the real function (in this case the system ping command).
Step 6: Returning the result to the model¶
// Append the result to the history as a message with role "tool"
messages = append(messages, Message{
Role: "tool",
Content: result, // "PING google.com: 64 bytes from ..."
ToolCallID: msg.ToolCalls[0].ID, // Link it to the call
})
// Send the updated history to the model again
resp2, err := client.Complete(ctx, Request{
Model: "glm-5.3",
Messages: messages, // Now includes the tool result!
Tools: tools,
}) // the error is handled by the loop (ch. 04)
What happens: the model sees the result of the tool execution and can:
- Compose the final answer for the user
- Call another tool, if needed
- Ask a clarifying question
Step 7: The final answer¶
finalMsg := resp2.Choices[0].Message
if len(finalMsg.ToolCalls) == 0 {
// This is the final text answer
fmt.Println(finalMsg.Content) // "google.com is reachable, response time 10ms"
}
What happens: the model saw the ping result and composed a clear answer for the user.
The End-to-end Protocol: a Full Request and Two Runs¶
Now let's walk through the full protocol from the point of view of an agent developer: where everything is stored, how the request is assembled, and how the runtime processes the responses.
Where is what stored in the agent's code?¶
graph TB
A["System Prompt (in code/config)"] --> D[Request: model + messages + tools]
B["Tools Registry (in runtime code)"] --> D
C["User Input (from the user)"] --> D
D --> E[LLM API]
E --> F{Response}
F -->|tool_calls| G[Runtime validates]
G --> H[Runtime executes]
H --> I[Appends the tool result]
I --> D
F -->|content| J[Final answer]
The storage layout:
-
System Prompt — stored in the agent's code (a constant or config):
- Instructions (Role, Goal, Constraints)
- Few-shot examples (if used)
- SOP (the operating procedure)
-
Tools Schema — stored in the runtime registry (not in the prompt!):
- Tool definitions (JSON Schema)
- Tool handler functions
- Validation and execution
-
User Input — comes from the user:
- The current request
- The conversation history (stored in
messages[])
-
Tool Results — generated by the runtime:
- After the tool executes
- Appended to
messages[]withRole = "tool"
The full protocol: JSON requests and responses (2 runs)¶
Run 1: a request with several tools
{
"model": "glm-5.3",
"messages": [
{
"role": "system",
"content": "You are a DevOps engineer. Use tools to check services.\n\nUsage examples:\nUser: \"Check the status of nginx\"\nAssistant: returns a tool_call check_status(\"nginx\")\n\nUser: \"Restart the server\"\nAssistant: returns a tool_call restart_service(\"web-01\")"
},
{
"role": "user",
"content": "Check the status of nginx"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "check_status",
"description": "Check if a service is running. Use this when user asks about service status.",
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Service name"
}
},
"required": ["service"]
}
}
},
{
"type": "function",
"function": {
"name": "restart_service",
"description": "Restart a systemd service. Use this when user explicitly asks to restart a service.",
"parameters": {
"type": "object",
"properties": {
"service_name": {
"type": "string",
"description": "Name of the service to restart"
}
},
"required": ["service_name"]
}
}
}
],
"tool_choice": "auto"
}
Where everything lives:
- System Prompt (instructions + few-shot examples) →
messages[0].content - User Input →
messages[1].content - Tools Schema (2 tools with full JSON Schema) → the separate
tools[]field
Response #1 (tool call):
{
"id": "chatcmpl-abc123",
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "check_status",
"arguments": "{\"service\": \"nginx\"}"
}
}
]
}
}]
}
The runtime processes the tool call:
- Validates:
tool_calls[0].function.nameexists in the registry - Parses:
json.Unmarshal(tool_calls[0].function.arguments)→{"service": "nginx"} - Executes:
check_status("nginx")→ result:"Service nginx is ONLINE" - Appends the tool result to
messages[]
Run 2: Request with the tool result
{
"model": "glm-5.3",
"messages": [
{
"role": "system",
"content": "You are a DevOps engineer. Use tools to check services.\n\nUsage examples:\nUser: \"Check the status of nginx\"\nAssistant: returns tool_call check_status(\"nginx\")\n\nUser: \"Restart the server\"\nAssistant: returns tool_call restart_service(\"web-01\")"
},
{
"role": "user",
"content": "Check the status of nginx"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "check_status",
"arguments": "{\"service\": \"nginx\"}"
}
}
]
},
{
"role": "tool",
"content": "Service nginx is ONLINE",
"tool_call_id": "call_xyz789"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "check_status",
"description": "Check if a service is running. Use this when user asks about service status.",
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Service name"
}
},
"required": ["service"]
}
}
},
{
"type": "function",
"function": {
"name": "restart_service",
"description": "Restart a systemd service. Use this when user explicitly asks to restart a service.",
"parameters": {
"type": "object",
"properties": {
"service_name": {
"type": "string",
"description": "Service name to restart"
}
},
"required": ["service_name"]
}
}
}
]
}
Where everything lives:
- System Prompt →
messages[0].content(same as before) - User Input →
messages[1].content(same as before) - Tool Call →
messages[2](added by the runtime after the first response) - Tool Result →
messages[3].content(added by the runtime after execution) - Tools Schema → the
tools[]field (same as before)
Response #2 (the final answer):
{
"id": "chatcmpl-def456",
"choices": [{
"message": {
"role": "assistant",
"content": "nginx is running fine, the service is ONLINE",
"tool_calls": null
}
}]
}
Evolution of the messages[] array (JSON)¶
Before the first request:
[
{"role": "system", "content": "You are a DevOps engineer..."},
{"role": "user", "content": "Check the status of nginx"}
]
After the first request (the runtime adds the tool call):
[
{"role": "system", "content": "You are a DevOps engineer..."},
{"role": "user", "content": "Check the status of nginx"},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "check_status",
"arguments": "{\"service\": \"nginx\"}"
}
}]
}
]
After the tool executes (the runtime adds the tool result):
[
{"role": "system", "content": "You are a DevOps engineer..."},
{"role": "user", "content": "Check the status of nginx"},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "check_status",
"arguments": "{\"service\": \"nginx\"}"
}
}]
},
{
"role": "tool",
"content": "Service nginx is ONLINE",
"tool_call_id": "call_xyz789"
}
]
After the second request (the model sees the tool result and formulates an answer):
- The model sees the full context (system + user + tool call + tool result)
- It generates the final answer:
"nginx is running fine, the service is ONLINE"
Note: For a Go implementation, see the examples in Lab 02: Tools and Lab 04: Autonomy.
Note: labs 02 and 04 are revised to the neutral client; the canonical material remains in this chapter.
Key takeaways for the developer¶
-
The System Prompt and the Tools Schema are different things:
- The system prompt is the text in
Messages[0].Content(it may contain few-shot examples) - The tools schema is a separate
Toolsfield in the request (JSON Schema)
- The system prompt is the text in
-
Few-shot examples live inside the system prompt:
- This is text showing the model the response format or how to pick tools
- It is distinct from the tools schema (which describes the structure of the tools)
-
The runtime drives the loop:
- Validates the
tool_calls - Executes the tools
- Appends the results to
messages - Sends the next request
- Validates the
-
Tools are not "inside the prompt":
- In the API they are passed as a separate
Toolsfield - The model sees them alongside the prompt, but they are different parts of the request
- In the API they are passed as a separate
For how to write instructions and examples, see Chapter 02: Prompting
Practice: Lab 02: Tools, Lab 04: Autonomy
What actually happens?¶
In short:
-
The model sees the descriptions of ALL tools — it doesn't "know" about the tools out of the box; it sees their
Descriptionin the JSON Schema. The model picks a tool by matching the user's request against the descriptions. -
The selection mechanism is semantics-based — the model looks for a match between:
- The user's request ("Check availability")
- The tool description ("Use this when user asks about network reachability")
- The context of previous results (if any)
-
The model returns structured JSON — not text like "I'll call ping", but a concrete tool call with the tool name and arguments
-
The runtime does all the work — parsing, validation, execution, returning the result
-
The model sees the result — it receives the result as a new message in the history and continues working
Example of choosing between similar tools:
tools := []ToolDef{
{
Name: "check_service_status",
Description: "Check if a systemd service is running. Use this for Linux services like nginx, mysql, etc.",
},
{
Name: "check_http_status",
Description: "Check HTTP response code of a web service. Use this for checking if a website or API is responding.",
},
}
// Request 1: "Check the status of nginx"
// The model picks: check_service_status (nginx is a systemd service)
// Request 2: "Check whether the site example.com responds"
// The model picks: check_http_status (a website is an HTTP service)
Note: If tool descriptions are too similar or unclear, the model may pick the wrong tool. That's why Description must be specific and distinguishable. The full tool definition with the argument schema is in "Step 1" above; the full walkthrough of the JSON protocol is in the "The end-to-end protocol" section.
The main point: Description is the most important field! The LLM relies on it when deciding which tool to call.
The Cost of Tools in Context¶
The tools block is not a one-time setting: it is sent with every request of the loop and paid for in tokens every time. As a rule of thumb, 4 compact tools already cost ~742 tokens — and that's just the schema, before the system prompt and history.
Practical consequences:
Descriptionis the most important field, and also the most expensive line. Every word in it is paid for on every run. Write densely: what the tool does, when to pick it, what it returns. Strike out anything that doesn't help the model choose.- Check the budget before adding each tool. A new tool must justify its tokens: if the model calls it once per hundred tasks, it probably doesn't belong in the permanent set (see the section "Many tools ≠ you need a catalog of them" in Chapter 06: RAG).
- The number and size of descriptions is design question #1. More than 20 tools with long descriptions is a signal to rethink the design: perhaps you don't need tool wrappers but skills and
execwith progressive disclosure (see Chapter 09: Agent Architecture and Chapter 06: RAG).
How the real SDK does it. Descriptions are not written by hand: a tool is declared via a typed constructor, and the JSON Schema is generated from the Go input struct — the schema and the code simply cannot drift apart. The execution result is an object with two versions of the content: the full one for the UI and audit, and a filtered one for the model.
Tool Examples Across Domains¶
Tools from different domains are built the same way — only the Description and the argument schema change. A summary of typical examples:
| Domain | Tool | Description gist | Note |
|---|---|---|---|
| DevOps | check_service_status |
Check if a systemd service is running | read-only |
| DevOps | restart_service |
Restart a systemd service | downtime — requires confirmation |
| DevOps | read_logs |
Read the last N lines of service logs | read-only |
| Support | search_kb |
Search knowledge base for solutions | read-only |
| Support | draft_reply |
Draft a reply to the ticket | draft, not sending |
| Data | sql_select |
Execute a SELECT query on the database | SELECT-only enforced in code, not in words |
| Data | describe_table |
Get table schema and column information | read-only |
| Security | query_siem |
Query security events | read-only |
| Security | isolate_host |
Isolate a host from the network | destructive — requires confirmation |
Two examples where the wording of Description decides everything:
// DevOps: a destructive operation — the description explains the consequences
{
Name: "restart_service",
Description: "Restart a systemd service. This interrupts the service and may cause downtime for users; the tool returns the new service status.",
Parameters: `{"service_name": "string"}`,
}
// Security: a dangerous tool — the description says what it does and why it requires confirmation
{
Name: "isolate_host",
Description: "Cut a host off from the network to contain an active security incident. This severs all connections on the host; execution requires confirmation through the agent's Confirm mechanism.",
Parameters: `{"host": "string"}`,
}
Note that confirmation for isolate_host is enforced by the runtime code (the Confirm callback and the mandatory confirmed_by argument), not by a plea in the Description. For a detailed treatment see Chapter 05: Safety and HITL; the full collection of tools by domain is in Chapter 15: Case Studies.
Handling Tool Errors¶
If a tool returns an error, the agent must see it and handle it.
Example:
// The agent calls ping("nonexistent-host")
result := ping("nonexistent-host")
// result = "Error: Name or service not known"
// Append the error to the history
messages = append(messages, Message{
Role: "tool",
Content: result, // The model will see the error!
ToolCallID: call.ID,
})
// The model receives the error and can:
// 1. Try a different host
// 2. Report the problem to the user
// 3. Escalate the issue
Remember: an error is a result too! Don't hide errors from the model.
Validating Tool Calls¶
Before executing a tool, you need to validate its arguments.
Validation example:
func executeTool(name string, args json.RawMessage) (string, error) {
switch name {
case "restart_service":
var params struct {
ServiceName string `json:"service_name"`
}
if err := json.Unmarshal(args, ¶ms); err != nil {
return "", fmt.Errorf("invalid args: %v", err)
}
// Validation
if params.ServiceName == "" {
return "", fmt.Errorf("service_name is required")
}
// Security check: allowlist, not denylist.
// We explicitly list what may be restarted; everything else is rejected.
var allowedServices = map[string]bool{
"nginx": true,
"redis": true,
"worker": true,
}
if !allowedServices[params.ServiceName] {
return "", fmt.Errorf("service %q is not in the restart allowlist; restart is permitted only for explicitly allowed services", params.ServiceName)
}
return restartService(params.ServiceName), nil
}
return "", fmt.Errorf("unknown tool: %s", name)
}
Repairing Tool-Call Arguments (When the JSON/schema is Invalid)¶
In production this happens regularly: the model sometimes returns:
- syntactically invalid JSON (a missing
}, broken quotes, etc.), - valid JSON that fails your JSON Schema (wrong fields/types).
Fix the first category deterministically — with a resilient parser such as jsonrepair — before giving up on the model. Typical breakages (trailing commas, single quotes, unclosed brackets, wrapping in a ```json fence) are fixed by the parser without an LLM call:
args := call.Arguments
if !json.Valid([]byte(args)) {
repaired, err := jsonrepair.Repair(args) // deterministic repair
if err != nil {
// couldn't repair — return a clear error message as the result for the model
return "Error: invalid arguments JSON, re-send as valid JSON matching the schema", nil
}
args = repaired
}
// then the usual validation: json.Unmarshal + schema check
If the JSON is syntactically sound but fails the schema, you can return the error to the model once, with the schema text, and ask it to retry the call. The key rule: repair does not change the intent, it fixes the format.
How the real SDK does it. Sometimes the model "leaks": it emits a tool call as plain text in
contentinstead of thetool_callsfield. Mature SDKs include recovery parsers that recognize such leaked calls in the text and reconstruct them into full-fledged tool calls. This is a fallback path for model glitches, not the normal mode of operation — don't design around it.
Tool Descriptions in .md Files¶
In production agents, tool descriptions are stored in separate .md files, not in Go code:
internal/agent/tool/
├── read/
│ ├── read.go // implementation
│ └── read.md // description for the LLM
├── edit/
│ ├── edit.go
│ └── edit.md
└── exec/
├── exec.go
└── exec.md
The description is loaded via go:embed:
import _ "embed"
//go:embed read.md
var description string
type ReadTool struct{}
func (t *ReadTool) Name() string { return "read" }
func (t *ReadTool) Description() string { return description }
Advantages:
- Markdown is easier to edit — you don't need to touch Go code to improve the description
- Descriptions get reviewed separately from code — a product manager can improve a description without knowing Go
- Single Source of Truth —
Tool.Description()is the only source of the description
Anti-pattern: duplicate description¶
A common mistake: the tool description is duplicated in the system prompt and in the API tool definitions. The model receives contradictory instructions:
// In system.md (system prompt):
"ALWAYS use ask tool to ask questions. Never write questions as plain text."
// In ask.Description() (tool definition):
"Use ONLY when you need the user to pick from options. For free-text, just ask in your message."
The model receives mutually exclusive instructions. For raw providers (Qwen, DeepSeek) the description may end up in three places: system prompt + tool definition + <tools> injection.
Rule: the system prompt contains behavior rules and workflow, but not tool descriptions. Descriptions go only through the Tool API.
Fallbacks Instead of Routing¶
When you do NOT need different models¶
This is the core of the section, so let's start with it:
- Prototype / MVP: one model is simpler. Optimize later.
- Few calls: if the agent makes 1-2 calls a day, the savings won't justify the complexity.
- Critical consistency: some tasks require the same model to "remember" its reasoning between iterations. Switching models can break the chain of reasoning.
For the main agent loop, pick one model (GLM-5.3 or Fable 5.1) and don't overcomplicate things.
Why stage-based routing is over-engineering¶
The idea of "a cheap model for tool selection, a powerful one for analyzing results" sounds reasonable, but in practice it breaks the agent:
- It kills the prompt cache. Each model has its own prompt cache. Switching models within a single run resets the cacheable prefix (system prompt + tools + history) — and that prefix is exactly what gives you the main savings in long loops. The savings on the "cheap iteration" turn into paying for the full prefix again.
- It makes testing harder. Agent behavior starts to depend on which model handled which iteration. Reproducing a bug becomes much harder.
- A stage cannot be predicted. You don't know in advance whether the model will call a tool or answer with text — the "router" is just guessing anyway.
The right shape: primary + explicit fallbacks¶
Instead of stage-based routing — one primary model and an explicit list of fallbacks. On a transient error (timeout, 429/5xx) or context overflow, the request is retried against the fallback model:
type ModelConfig struct {
Primary string // the main model of the loop
Fallbacks []string // fallbacks, in order
}
cfg := ModelConfig{
Primary: "glm-5.3",
Fallbacks: []string{"fable-5.1"}, // a different model, same tool-calling protocol
}
Rules:
- each fallback triggers at most once per run — otherwise the agent will get stuck looping on repeated errors;
- fallbacks are about availability (transient errors, context overflow), not about "saving on a simple iteration";
- all models in the chain must support the same tools protocol, so
messages[]can be carried over without conversion.
Where the cheap model belongs¶
A cheap model (GLM-5.3 Flash) belongs not in the main loop but at auxiliary nodes outside it — places with no long cacheable prefix where the task really is simple:
- classification of incoming requests,
- structured output for field extraction.
Condense is the exception: summarization runs on the same model with the same cacheable prefix, so the head is read from a warm cache (ch. 20, ch. 13).
The main loop — GLM-5.3 or Fable 5.1; auxiliary nodes — GLM-5.3 Flash.
For more on cost and optimization, see Chapter 20: Cost & Latency Engineering.
Common Mistakes¶
Mistake 1: The model doesn't generate a tool_call¶
Symptom: The agent receives a text response from the model instead of tool_calls. The model answers with text instead of calling a tool.
Cause:
- The model wasn't trained on function calling
- Poor tool description (unclear
Description) Temperaturetoo high — worse instruction following during tool selection
Fix:
// GOOD: Use a model with tools support
// Check via Lab 00: Capability Check
// GOOD: Improve the Description
Description: "Check the status of a server by hostname. Use this when user asks about server status or availability."
// GOOD: Low Temperature — quality, less variance in tool selection
Temperature: 0.2,
Mistake 2: Broken JSON in arguments¶
Symptom: json.Unmarshal returns an error. The JSON in the arguments is invalid.
Cause: The model generates invalid JSON (missing brackets, wrong format).
Fix:
// GOOD: Validate the JSON before parsing
if !json.Valid([]byte(call.Arguments)) {
return "Error: Invalid JSON", nil
}
// GOOD: Low Temperature — fewer errors in JSON generation
Temperature: 0.2,
Mistake 3: Tool hallucinations¶
Symptom: The agent calls a tool that doesn't exist. The model generates a tool_call with a name that isn't in the list.
Cause: The model doesn't see a clear list of available tools, or the list is too large.
Fix:
// GOOD: Validate the tool name before executing
allowedFunctions := map[string]bool{
"get_server_status": true,
"ping": true,
}
if !allowedFunctions[call.Name] {
return "Error: Unknown function", nil
}
Note: If you have a large tool space (hundreds or thousands), reconsider the design first: skills + exec instead of a catalog of wrappers (see Chapter 06: RAG). Tool retrieval — dynamically selecting relevant tools before planning — is a last resort for when tools are genuinely diverse and numerous.
Mistake 4: Poor tool description¶
Symptom: The model picks the wrong tool, or doesn't pick one at all.
Cause: The Description is too generic or lacks keywords from the user's request.
Fix:
// BAD:
Description: "Ping a host"
// GOOD:
Description: "Ping a host to check network connectivity. Use this when user asks about network reachability or connectivity."
Mistake 5: Duplicated tool description¶
Symptom: The model behaves unpredictably — sometimes it uses the tool, sometimes it writes the question as text. Different providers show different behavior.
Cause: The tool description is duplicated in the system prompt and in the tool definition. The descriptions contradict each other.
Fix:
// BAD: description in two places
systemPrompt := "Use read tool to read files. Always read before editing."
readTool := Tool{Description: "Read a file from the filesystem. Returns file content."}
// GOOD: description only in the tool definition
systemPrompt := "Before editing, always read the file first." // a rule, not a description
readTool := Tool{Description: "Read a file. Returns content with line numbers."}
Exercises¶
Exercise 1: Create a tool¶
Create a check_disk_usage tool that checks disk usage:
tools := []ToolDef{
{
Name: "check_disk_usage",
Description: "...", // Your code here
Parameters: json.RawMessage(`{
// Your code here
}`),
},
}
Expected result:
Descriptioncontains the keywords: "disk", "usage", "space"- The JSON Schema is correct
- Required fields are listed in
required
Exercise 2: Argument validation¶
Implement an argument validation function for a tool:
func validateToolCall(call ToolCall) error {
// Check the tool name
// Check the JSON validity
// Check the required fields
}
Expected result:
- The function returns an error if the tool name is unknown
- The function returns an error if the JSON is invalid
- The function returns an error if required fields are missing
Acceptance Criteria / Checklist¶
Passed:
-
Descriptionis concrete and clear (contains keywords) - The JSON Schema is correct
- Required fields are listed in
required - Argument validation is implemented
- Errors are handled and returned to the agent
- Critical tools require confirmation
- The model successfully generates a tool_call
Failed:
- The model doesn't generate a tool_call (poor description or unsuitable model)
- JSON in arguments is broken (no validation)
- The model calls a non-existent tool (no name validation)
-
Descriptionis too generic (the model can't pick the right tool) - The tool description is duplicated in the system prompt and the tool definition
Relation to Other Chapters¶
- LLM physics: Why the model chooses a tool call instead of text — see Chapter 01: LLM Fundamentals
- Prompting: How to describe tools so the model uses them correctly — see Chapter 02: Prompt Engineering
- The loop: How tool results are returned to the model — see Chapter 04: Autonomy
- MCP: The standard protocol for connecting tools to agents — Model Context Protocol. Details in Chapter 18: Tool Protocols
Full Example: a Compact Working Loop¶
The full walkthrough of the JSON protocol (requests, responses, the evolution of messages[]) was already given above in the "End-to-end protocol" section — here is just a compact working loop on the neutral types from the beginning of the chapter:
// Agent loop with a single tool. client is a neutral client (ch. 04):
// Complete is a call to your LLM provider
// (OpenAI-compatible tool-calling endpoint).
func runAgent(ctx context.Context, client Client) error {
tools := []ToolDef{{
Name: "check_status",
Description: "Check the status of a server by hostname. Use this when user asks about server status.",
Parameters: json.RawMessage(`{"type":"object","properties":{"hostname":{"type":"string","description":"Server hostname"}},"required":["hostname"]}`),
}}
messages := []Message{
{Role: "system", Content: "You are a DevOps engineer. Use tools to check services."},
{Role: "user", Content: "Check the status of server web-01"},
}
for i := 0; i < 10; i++ {
resp, err := client.Complete(ctx, Request{
Model: "glm-5.3",
Messages: messages,
Tools: tools,
Temperature: 0.2, // quality: less variance in tool selection
})
if err != nil {
return fmt.Errorf("LLM call failed: %w", err)
}
msg := resp.Choices[0].Message
messages = append(messages, msg)
if len(msg.ToolCalls) == 0 { // final answer
fmt.Println("Agent:", msg.Content)
return nil
}
for _, call := range msg.ToolCalls { // runtime: validate and execute
result := executeTool(call) // an error is a result too, for the model
messages = append(messages, Message{
Role: "tool",
Content: result,
ToolCallID: call.ID,
})
}
}
return fmt.Errorf("iteration limit reached")
}
func executeTool(call ToolCall) string {
if call.Name != "check_status" {
return fmt.Sprintf("Error: unknown tool %s", call.Name)
}
var args struct {
Hostname string `json:"hostname"`
}
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
return fmt.Sprintf("Error: invalid arguments: %v", err)
}
return fmt.Sprintf("Server %s is ONLINE", args.Hostname) // in real life — an API or a command
}
Running it: go run main.go — the key and endpoint of your LLM provider are set via environment variables.
What's Next?¶
Once you've covered tools, move on to:
- 04. Autonomy and Loops — how the agent works in a loop