Tool calling is the mechanism that turns a model from a text generator into something that can actually act. This topic has four subtopics: how a tool is declared, how the call-and-response cycle actually flows, how toolsets control what the model even sees, and how to debug it when things go wrong. Work through them in order, each builds on the last.
Subtopic 7.1: Anatomy of a Tool Definition
The Hermes function-calling standard places every available tool’s schema inside a <tools> block in the model’s system context, as JSON Schema. The model reads these descriptions the same way you’d read documentation, so the description field is not a formality, it is the single biggest lever you have over whether the model picks the right tool at the right time.
<tools>
[
{
"name": "get_current_weather",
"description": "Get the current weather for a city. Use this when the user asks about weather, temperature, or conditions in a specific location.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'London' or 'New York'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit, defaults to celsius if omitted"
}
},
"required": ["city"]
}
}
]
</tools>
Schemas can be generated from a Pydantic model rather than hand-written, which is the more maintainable path for anything beyond a handful of tools:
from pydantic import BaseModel, Field
class GetWeatherParams(BaseModel):
city: str = Field(..., description="The city name, e.g. 'London' or 'New York'")
unit: str = Field("celsius", description="Temperature unit")
# .model_json_schema() produces the parameters block Hermes expects inside <tools>
schema = GetWeatherParams.model_json_schema()
Common pitfall: writing a vague description like “gets weather data.” The model has nothing but this text to decide whether a given user request warrants calling this tool over some other one. Write it the way you’d explain the tool to a new teammate: what it does, and when to reach for it.
Subtopic 7.2: The Call-and-Response Cycle
When the model decides a tool is needed, it emits a <tool_call> block instead of a plain-text answer:
<tool_call>
{"name": "get_current_weather", "arguments": {"city": "London", "unit": "celsius"}}
</tool_call>
This is a request, nothing more. The orchestration layer intercepts it, runs the real Python function, and hands the result back inside a <tool_response> block appended to the conversation:
<tool_response>
{"city": "London", "temperature": 14, "unit": "celsius", "condition": "light rain"}
</tool_response>
Only now does the model have the actual data, and it continues reasoning (optionally inside a <think> block, if the model supports extended reasoning) before producing its final answer:
# The execution side: Hermes' orchestration layer, conceptually
import json
TOOL_FUNCTIONS = {
"get_current_weather": get_current_weather, # your real implementation
}
def execute_tool_call(raw_call: str) -> str:
call = json.loads(raw_call)
name, args = call["name"], call["arguments"]
if name not in TOOL_FUNCTIONS:
result = {"error": f"Unknown tool: {name}"}
else:
try:
result = TOOL_FUNCTIONS[name](**args)
except Exception as e:
# Surface the error to the model rather than crashing:
# a well-designed agent can often recover or ask for clarification
result = {"error": str(e)}
return json.dumps(result)
A full turn can involve several of these round trips in sequence, the model calls a tool, reads the result, decides it needs another tool, calls that, and only then answers. This loop is exactly what you traced conceptually in Lesson 3’s request-flow diagram; here you’re seeing the actual wire format underneath it.
Subtopic 7.3: Toolsets, Platform Tools, and Selective Loading
Every tool in <tools> costs context budget and adds one more option the model has to correctly choose (or correctly ignore) on every turn. Hermes groups tools into toolsets, letting you load only what a given deployment actually needs, rather than exposing everything by default.
hermes tools # inspect which toolsets are currently active
# ~/.hermes/config.yaml
agent:
# explicit allow-list: only these toolsets load, nothing else
platform_toolsets:
cli: ["filesystem", "shell", "web_search"]
# explicit deny-list: these never load even if requested elsewhere
disabled_toolsets: ["code_execution", "image_generation"]
This is the same platform_toolsets / disabled_toolsets pair you saw in “Blank Slate” setup back in Lesson 2, now with the reasoning behind it made explicit: a support-bot deployment (Module 6) has no business loading a code-execution toolset, and a coding-assistant deployment has little use for image generation. Scoping toolsets to the deployment is the first and cheapest guardrail available to you, well before the more involved sandboxing techniques in Module 7.
Subtopic 7.4: Debugging Malformed or Rejected Tool Calls
Three failure modes account for nearly every tool-calling bug you’ll hit:
| Symptom | Likely cause | Fix |
|---|---|---|
Model describes a tool call in plain prose instead of <tool_call> | Inference server missing tool-call parser (Lesson 5) | Add --tool-call-parser hermes (vLLM) or --jinja (llama.cpp) |
<tool_call> present but arguments don’t match the schema | Description or parameter naming is ambiguous | Rewrite the description field to be more specific; add an enum to constrain free-text guesses |
| Tool executes but the model ignores the result | Result wasn’t wrapped correctly, or context length was exceeded (Lesson 6) | Verify <tool_response> formatting; check hermes doctor for context warnings |
hermes doctor # your first stop for any of the above
For anything hermes doctor doesn’t catch, inspect the raw conversation to see exactly what the model emitted before deciding whether the problem is the schema, the model, or the inference server:
hermes sessions list
hermes sessions show <session-id> --raw # see exact <tool_call>/<tool_response> exchanges
Exercise: Write a Pydantic model and JSON Schema for a tool called
convert_currency(amount, from_currency, to_currency). Deliberately write a bad, vague description first (“converts money”) and test it with an ambiguous request like “how much is that in euros.” Then rewrite the description to be specific about when to call it, and compare how reliably the model picks it up. This is the fastest way to internalize why subtopic 7.1’s advice about descriptions matters in practice, not just in theory.