The Big Picture
An agent harness is the software that wraps an LLM and turns it from a stateless text-completion function into an autonomous system that can act. The LLM is the brain; the harness is everything else โ the hands, the eyes, the memory, the loop that keeps it going.
Here's the simplest possible picture of Hermes:
flowchart TB
USER[("๐ค User")]
ENTRY["CLI or Gateway"]
CORE["Agent Core\nconversation loop"]
LLM["๐ง LLM API"]
RESP["Response back"]
USER --> ENTRY
ENTRY --> CORE
CORE --> LLM
LLM --> RESP
style CORE fill:#dbeafe,stroke:#2563eb,stroke-width:3px
style LLM fill:#fef9c3,stroke:#ca8a04,stroke-width:2px
Figure 1: A simple overview. The core calls the LLM (repeatedly for tool calls), then returns the final answer. The detailed loop is in Figure 2.
The loop is the heart. It sends messages to the LLM, executes any tools the LLM requests, feeds results back, and repeats until the LLM produces text. That's the entire agent. Let's walk through each piece.
The Agent Loop
The AIAgent class (run_agent.py) runs a single loop that powers everything:
flowchart TB
START(["User sends message"])
BUILD["Build system prompt
identity + tools + memory + context"]
CALL["Call LLM
messages + tool schemas"]
TOOL{"Has tool_calls?"}
VALIDATE["Validate tool names & JSON
reject hallucinations"]
DISPATCH["Execute tool
append result to messages"]
DONE["Final response
plain text, no tools"]
START --> BUILD
BUILD --> CALL
CALL --> TOOL
TOOL -->|"yes"| VALIDATE
VALIDATE --> DISPATCH
DISPATCH -->|"loop back"| CALL
TOOL -->|"no"| DONE
style CALL fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style TOOL fill:#fef3cd,stroke:#d97706,stroke-width:2px
style DONE fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 2: The conversation loop. LLM is called, tools are dispatched if requested, results are fed back, repeat until text-only response.
From the actual source:
while (api_call_count < agent.max_iterations
and agent.iteration_budget.remaining > 0):
# Call the LLM
assistant_message = agent._call_model(messages, ...)
# Tool calls โ execute, append results, loop
if assistant_message.tool_calls:
agent._execute_tool_calls(assistant_message, messages, ...)
continue
# Plain text โ final answer
if assistant_message.content:
final_response = assistant_message.content
break
Key Insight
The LLM decides which tools to call and when to stop. The harness just makes the calls, validates results, and loops. The "intelligence" is the model's tool selection. The harness is the capability surface + persistence.
The Tool Registry
Instead of hardcoding tool dispatch with if/else chains, every tool self-registers at import time into a central registry:
flowchart TB
DIR["tools/ directory"]
REG(["ToolRegistry
singleton"])
MT["model_tools.py
query + dispatch"]
LLM2["LLM receives
tool schemas in API call"]
DIR -->|"import time"| REG
REG -->|"get_all_tool_names()"| MT
REG -->|"get_entry(name)"| MT
REG -->|"dispatch(name, args)"| MT
MT -->|"inject schemas"| LLM2
style REG fill:#dbeafe,stroke:#2563eb,stroke-width:3px
style MT fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 3: Tool registry. Each tools/*.py calls registry.register() at import time. model_tools.py queries the registry to build tool schemas and dispatches calls.
Each tool is a single file that calls registry.register():
# tools/terminal_tool.py โ one file per tool
from tools.registry import registry
def check_requirements() -> bool:
return True # terminal always available
registry.register(
name="terminal",
toolset="terminal",
schema={
"name": "terminal",
"description": "Execute shell commands",
"parameters": { ... }
},
handler=lambda args, **kw: terminal_tool(
command=args.get("command", ""), **kw
),
check_fn=check_requirements,
)
Why This Pattern Matters
Adding a tool = one file, zero core changes. Tools can be conditionally available via check_fn. MCP server tools register the same way. The tool list sent to the LLM is dynamically assembled per-session based on enabled toolsets.
System Prompt Assembly
Before every conversation, Hermes builds a system prompt from multiple sources. This prompt stays byte-stable for the whole conversation โ changing it mid-flow kills prompt caching.
flowchart TB
A["Identity + Memory\nSOUL.md ยท MEMORY.md ยท USER.md"]
B["Capabilities\nTool schemas ยท Skills index"]
C["Session Context\nContext files ยท Platform hints"]
COMBINE(["Prompt Builder"])
OUTPUT["One byte-stable system prompt"]
A --> B --> C --> COMBINE --> OUTPUT
style COMBINE fill:#dbeafe,stroke:#2563eb,stroke-width:3px
style OUTPUT fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 4: Six sources feed into one prompt. The prompt is assembled once per session and never changes โ this keeps the LLM's cache prefix valid.
Session Persistence
Every message โ user, assistant, tool result โ goes into SQLite with FTS5 full-text search. This enables session resume and cross-session recall.
flowchart TB
LOOP2["Agent writes message data"]
DB["SQLite database"]
FTS["FTS5 full-text index"]
SEARCHER["Cross-session search"]
COMPRESSOR["Context compression"]
LOOP2 --> DB
DB --> FTS
FTS --> SEARCHER
DB --> COMPRESSOR
style DB fill:#faf5ff,stroke:#9333ea,stroke-width:3px
style FTS fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 5: SQLite + FTS5. Every message stored. Cross-session search via FTS5. Context compression splits sessions via parent_session_id chains.
The Gateway
The gateway is a long-running daemon that connects to 20+ messaging platforms. Each platform is one adapter file. The same agent core serves them all.
flowchart TB
GR["GatewayRunner"]
AD["Platform Adapters"]
CORE2["AIAgent Core"]
GR --> AD
AD --> CORE2
style CORE2 fill:#dbeafe,stroke:#2563eb,stroke-width:3px
style GR fill:#fef9c3,stroke:#ca8a04,stroke-width:2px
Figure 6: One agent core behind a gateway. Platform adapters handle I/O; the agent doesn't know it's on Discord vs Telegram.
Memory & Skills
Two persistence systems make Hermes self-improving across sessions. Memory is declarative (facts about you). Skills are procedural (reusable workflows).
flowchart TB
S1["Session 1
solves complex task"]
CREATE["skill_manage
action='create'"]
SKILL["SKILL.md
stored in skills/"]
S2["Session 2
similar task appears"]
LOAD["skill_view()
loads procedure"]
APPLY["Follows proven steps
avoids earlier mistakes"]
S1 --> CREATE
CREATE --> SKILL
SKILL --> S2
S2 --> LOAD
LOAD --> APPLY
style SKILL fill:#fef9c3,stroke:#ca8a04,stroke-width:2px
style S1 fill:#f0fdf4,stroke:#16a34a
style S2 fill:#dbeafe,stroke:#2563eb
Figure 7: The self-improvement loop. What the agent learns in one session is available in the next โ no code changes needed.
The Five Invariants
If you build something like this, these rules must never be broken:
1. Prompt Caching Is Sacred
The system prompt + tool schemas must be byte-stable for the life of a conversation. Change it and every turn pays full token cost.
2. Strict Role Alternation
user โ assistant โ tool โ assistant โ tool โ assistant(text). Never two same-role messages in a row. Tool results are the tool role.
3. Narrow Core
Every core tool ships on every API call. New capability belongs at the edges: skill โ plugin โ MCP server โ new core tool (last resort).
4. Config โ Secrets
Behavioral settings in config.yaml. Credentials in .env. Never invent a new env var for non-secret settings.
5. Tools Return JSON Strings
Every handler returns json.dumps(...). Uniform protocol means results can be truncated, sanitized, and appended to messages consistently.
What's Next
- Lesson 02 โ The conversation loop in detail: streaming, retries, error recovery, compression
- Lesson 03 โ The tool registry: building, registering, and gating your own tools
- Lesson 04 โ System prompt assembly and context files
- Lesson 05 โ SQLite session store with FTS5 search
- Lesson 06 โ Gateway pattern and writing a platform adapter
- Lesson 07 โ Memory and skills (the learning loop)
- Lesson 08 โ Building a minimal agent from scratch
What pattern makes adding a tool as simple as writing one file with zero core changes?
A central if/else chain in model_tools.py
Each tool file self-registers into a singleton registry at import time
A JSON config file listing all tools
All tools come from MCP servers
โ
Correct. Each tools/*.py calls registry.register() at module level. model_tools.py queries the registry for schemas and dispatches through it. MCP servers register the same way.