agent — Core Agent Engine
Core Agent Engine
The orchestration and utility layer powering Hermes Agent — pluggable context management, memory provider abstraction, smart model routing, error classification, credential pooling, prompt building, and trajectory capture for RL training.
🎯 Why This Module Exists
The agent/ module was extracted from a monolithic 3,600-line run_agent.py that had grown to mix orchestration logic with utility functions, making testing, maintenance, and extension increasingly painful. Without this separation, every change to error handling would risk breaking context compression; every new memory backend would require modifying the core conversation loop; and unit testing any individual subsystem would require spinning up the entire agent stack. By extracting pure, self-contained utilities into agent/, the project achieves clean separation of concerns: run_agent.py focuses solely on the conversation orchestration loop, while agent/ provides the pluggable engines and services it consults. This modular architecture enables independent testing of compression, error classification, and credential management; allows third parties to swap implementations via abstract base classes (ContextEngine, MemoryProvider); and ensures the same utilities work across CLI, gateway, and cron contexts. When Anthropic releases a new caching strategy or a provider changes its error signatures, updates localize to a single file rather than scattering across the entire codebase.
💡 Real-world analogy
Think of agent/ as the engine room of a large ship. The bridge (run_agent.py) decides where to go and when to change course, but the engine room houses all the specialized machinery — the engines, fuel pumps, electrical generators, and damage-control systems — each operated by specialists. A captain doesn't rewire the generator to change heading; they issue orders through well-defined interfaces. Similarly, the orchestration loop doesn't know how context compression works internally; it simply asks the ContextEngine "should we compress?" and receives a clean answer. If the ship upgrades from diesel to electric, only the engine room changes — the bridge controls remain the same.
Context Engine Memory Management Smart Routing Prompt Building
Architecture
flowchart LR
subgraph ContextMgmt["Context Management"]
CE["ContextEngine"]
CC["ContextCompressor"]
CR["ContextReferences"]
end
subgraph MemorySys["Memory System"]
MM["MemoryManager"]
MP["MemoryProvider"]
end
subgraph ModelRouting["Model and Routing"]
SMR["SmartModelRouting"]
CP["CredentialPool"]
UP["UsagePricing"]
MM2["ModelMetadata"]
end
subgraph ErrorSys["Error Handling"]
EC["ErrorClassifier"]
RU["RetryUtils"]
RLT["RateLimitTracker"]
end
subgraph Core["Core Pipeline"]
PB["PromptBuilder"]
TC["TrajectoryCapture"]
DI["Display"]
end
PB -->|"system prompt"| CE
CE -->|"compress check"| CC
CC -->|"summary request"| CE
PB -->|"memory context"| MM
MM -->|"recall/sync"| MP
PB -->|"route decision"| SMR
SMR -->|"model lookup"| MM2
SMR -->|"credential"| CP
CP -->|"cost tracking"| UP
EC -->|"retry policy"| RU
RU -->|"rate state"| RLT
CE -->|"token usage"| TC
TC -->|"formatted output"| DI
CR -->|"ref injection"| PB
Data Flow
sequenceDiagram
participant U as User
participant PB as PromptBuilder
participant MM as MemoryManager
participant CE as ContextEngine
participant SMR as SmartModelRouting
participant CP as CredentialPool
participant LLM as LLM Provider
participant EC as ErrorClassifier
participant RU as RetryUtils
participant TC as TrajectoryCapture
participant DI as Display
U->>PB: User query
PB->>MM: prefetch_all() recall context
MM-->>PB: Memory context block
PB->>CE: Check context budget
CE->>CE: should_compress_preflight()
CE-->>PB: Token estimate OK
PB->>SMR: Route decision (simple vs complex)
SMR->>CP: Select credential
CP-->>SMR: API key + provider
SMR->>LLM: API call with messages
LLM-->>EC: Response or Exception
EC->>RU: Recovery action (retry/rotate/fallback)
RU-->>SMR: Retry with backoff
LLM-->>TC: Successful response + usage
TC->>DI: Format and render output
DI-->>U: Formatted response
🚶 Step-by-Step Narrated Walkthrough
Follow a single user query through the agent pipeline as it flows through the entire agent module:
- User Input Received — The user types a message.
SubdirectoryHintTrackerchecks any recent tool calls for new directory paths and lazily loadsAGENTS.md/.cursorruleshints if the agent has navigated to a new directory. - Memory Prefetch —
MemoryManager.prefetch_all()fires across all registered providers (built-in + at most one external). Each provider retrieves relevant context from its backend. Results are merged into a fenced memory context block viabuild_memory_context_block(). - System Prompt Assembly —
prompt_builder._build_system_prompt()constructs the system prompt by combining identity text, platform hints, the skills index (parsed from skill frontmatter viaskill_commands.py), injected context files, and the memory block. Prompt injection detection scansAGENTS.mdcontent for suspicious patterns. - Preflight Token Check —
model_metadata.estimate_messages_tokens_rough()uses a 4-chars/token heuristic with provider-specific adjustments to estimate whether the assembled messages fit within the model's context window. If the estimate exceeds the threshold,ContextEngine.should_compress_preflight()signals compression is needed. - Context Compression (if triggered) —
ContextCompressor.compress()prunes old tool results, protects head and tail messages, serializes middle turns, and calls the auxiliary LLM to generate a structured summary. If a previous summary exists, it uses iterative update mode to preserve accumulated facts. - Smart Routing Decision —
SmartModelRoutingevaluates whether the user message is "simple" (under 160 chars, under 28 words, no code blocks or complex keywords). If simple, it routes to a configured cheap model to save ~80% on token cost. Otherwise, the primary model is used. - Credential Selection —
CredentialPoolselects the next available API credential via round-robin or least-used strategy. Exhausted credentials (those with recent 429/402 errors) are skipped until their cooldown expires. - Prompt Caching — For Anthropic models,
apply_anthropic_cache_control()injects up to 4cache_controlbreakpoints (system prompt + last 3 non-system messages) to achieve ~75% input cost reduction on cache hits. - API Call & Response — The request is sent to the selected LLM provider. Response headers (
x-ratelimit-*) are parsed byRateLimitTrackerto update RPM/TPM bucket state.ContextEngine.update_from_response(usage)records actual token counts. - Error Classification & Recovery — If the API call throws an exception,
classify_api_error()runs the error through a priority-ordered pipeline: provider-specific patterns, HTTP status codes, message pattern matching, transport heuristics, and context overflow detection. The resultingClassifiedErrordetermines whether to retry with backoff, rotate credentials, trigger compression, fall back to another model, or abort. - Trajectory Capture — On success,
TrajectoryCapturerecords the full turn (input messages, output, tool calls, token usage, timing) for reinforcement learning training data. This creates structured datasets that can train future model versions. - Display Formatting —
display.pyformats the assistant response for terminal output with syntax highlighting, markdown rendering, and token usage statistics. The final formatted response is presented to the user, andMemoryManager.sync_all()writes the exchange to all registered memory providers in the background.
🏗️ Design Decisions
| Decision | Choice Made | Alternatives Considered | Why This Approach |
|---|---|---|---|
| Context compression strategy | Lossy LLM summarization with iterative updates; previous summary preserved and incrementally updated on each compression | (A) Retrieve-augmented generation via vector DB; (B) Sliding window dropping oldest N turns; (C) Lossless DAG construction | Lossy summarization is simplest and works well for typical agent workflows. RAG requires external infrastructure; sliding window loses critical decision context from mid-conversation; DAG is complex to implement and maintain. Iterative updates prevent cascading information loss across multiple compressions in long sessions. |
| Memory provider abstraction | Single external provider limit — built-in always active plus at most one external (Honcho, Mem0, Hindsight, etc.) | (A) Multiple simultaneous providers with union schema; (B) Per-turn switchable provider | Prevents tool schema bloat (20+ memory tools confuse the model); avoids ambiguous ownership of facts across providers; forces explicit configuration choice. The single-provider constraint is the mental-model winner for both users and the LLM. |
| Smart model routing | Conservative heuristic: route to cheap model only when message is under 160 chars, under 28 words, no code blocks, no complex keywords | (A) ML-based complexity classifier; (B) Always use primary model; (C) User-selected per-turn | Conservative criteria ensure the cheap route only fires for clearly simple messages ("thanks", "what time is it?"). ML classifier adds training overhead and latency; always-primary wastes ~80% cost on trivial turns; user selection adds friction. |
| Credential pooling | Multi-key round-robin with exhaustion tracking and cooldown TTL per credential | (A) Single credential with retry; (B) Random selection; (C) Adaptive load balancing based on rate-limit headers | Round-robin distributes load evenly; exhaustion tracking with cooldown prevents retrying dead credentials; adaptive balancing requires header parsing first (adds latency) and not all providers return consistent headers. |
| Error classification taxonomy | Priority-ordered pipeline: provider-specific patterns, HTTP status codes, message patterns, transport heuristics, then fallback | (A) Machine-learned error classifier; (B) Per-provider manual error maps; (C) Simple regex catch-all | Deterministic and debuggable; no ML training overhead; centralized logic instead of scattered per-provider if/else blocks. Provider-specific patterns are folded in as high-priority matchers within the same pipeline. |
| Jittered backoff seed | time.time_ns() ^ (counter * 0x9E3779B9) with process-level counter |
(A) Pure random.Random(); (B) Fixed exponential backoff; (C) Rate-limit-header-adaptive delay |
Pure random can produce collisions on coarse clocks; fixed backoff causes thundering herd when multiple sessions retry simultaneously. The counter-XOR-golden-ratio seed ensures diversity even when sessions start at the same instant. |
| Subdirectory hints injection | Lazy-loaded via tool results, not injected into system prompt | (A) Upfront filesystem scan at startup; (B) Batch inject all hints into system prompt | Lazy loading preserves prompt caching (no system prompt edits per turn). Upfront scanning adds startup latency. Tool-result injection is transparent to the model and avoids cache invalidation. |
🔬 Algorithm Spotlight
Context Compression with Iterative Summary Updates
The Problem: Long agent conversations can exceed the model's context window (e.g., 200K or 1M tokens). Naively dropping old messages loses critical decision context — file paths, design choices, error resolutions — that the agent needs to continue working effectively.
The Approach: When last_prompt_tokens exceeds a configurable threshold (default: 75% of max context), the compressor divides the conversation into three zones: a protected head (system prompt + opening exchange), a middle (summarization target), and a protected tail (recent messages by token budget, ~20K tokens). The middle is serialized with truncation (6K chars per message), then sent to an auxiliary LLM with a structured prompt requesting a handoff summary. On subsequent compressions, the previous summary is passed to the LLM with instructions to update it — moving items from "In Progress" to "Done", adding new facts, and preserving unresolved questions.
Why This Algorithm: Iterative updates prevent the cascading information loss that would occur if each compression started from scratch. A fresh summarization of the most recent middle chunk would forget facts from the previous compression. The iterative approach creates a durable, ever-growing handoff artifact. The summary budget scales with input size: max(2000, min(0.20 * content_tokens, 12000)), capped to avoid the summary itself consuming too much context.
graph TD
A["Conversation exceeds threshold"] --> B{"Previous summary exists?"}
B -->|"No"| C["Fresh summarization prompt"]
B -->|"Yes"| D["Iterative update prompt"]
C --> E["Prune old tool results"]
D --> E
E --> F["Protect head messages"]
F --> G["Protect tail by token budget"]
G --> H["Serialize middle turns"]
H --> I["Call auxiliary LLM"]
I --> J{"LLM call succeeded?"}
J -->|"Yes"| K["Insert summary + tail"]
J -->|"No"| L["Drop middle, enter 600s cooldown"]
K --> M["Store as _previous_summary"]
L --> M
Trace through an example
Given: A 180K-token conversation on a model with 200K context (threshold = 150K). This is the second compression; a previous summary of 3K tokens exists from an earlier compression.
Step 1: should_compress(180000) returns true since 180K ≥ 150K threshold.
Step 2: Tool pruning walks backward, replacing tool outputs older than the tail boundary with "[Old tool output cleared]". This typically recovers 10-30K tokens cheaply.
Step 3: Head protection keeps messages 0-2 (system + first exchange). Tail protection accumulates backward until ~20K tokens are covered — roughly the last 8-12 messages.
Step 4: The middle turns (~140K tokens after pruning) are serialized with 6K char truncation per message. The previous 3K-token summary is included. The iterative update prompt instructs: "Update this summary. Preserve all file paths and decisions. Move completed items to Done."
Step 5: The auxiliary LLM generates a ~6K-token updated summary (budget = min(140K * 0.20, 12K) = 12K max). The output replaces the middle: head + summary message + tail. Total context drops from 180K to ~28K tokens.
Result: The conversation continues with full recent context and a structured summary preserving decisions from all prior compressions. Without iterative updates, facts from the first compression would be completely lost.
⚠️ What Could Go Wrong
| Failure Mode | Trigger | Detection | Recovery / Mitigation |
|---|---|---|---|
| Rate limit exhaustion | All credentials in the pool hit 429 rate limits simultaneously during high-concurrency usage | RateLimitTracker parses x-ratelimit-remaining headers; CredentialPool tracks last_error_code per entry |
jittered_backoff() computes decorrelated delays; credentials enter cooldown until TTL expires or provider reset header received; worst case, user sees a retry-after delay message |
| Context overflow | Conversation grows beyond model context window; preflight check missed due to tool output expansion | classify_api_error() matches HTTP 413 or "context", "token limit" patterns; also infers overflow when server disconnects on large sessions (>80% context used) |
Sets should_compress=true on the ClassifiedError; triggers ContextCompressor.compress() on next turn; tool pruning provides immediate relief |
| Memory backend failure | External memory provider (Honcho, Mem0, Hindsight) is unreachable or returns errors during prefetch/sync | Exception caught in MemoryManager.prefetch_all() per-provider try/except; logged at debug level |
Non-fatal: failed provider is skipped; merged results from other providers (including built-in) are used. Sync failures are also non-fatal; data may be partially lost but conversation continues |
| Credential pool empty | All credentials exhausted (402 billing) with no remaining alternatives in the pool | CredentialPool.select_next() returns None when all entries are in exhausted state |
Falls through to the auxiliary client fallback chain (OpenRouter, Nous Portal, Codex, Anthropic); if entire chain is exhausted, returns error to user with clear "no credentials available" message |
| Compression LLM failure | Auxiliary LLM for summarization fails (all providers exhausted or network error) | Exception from call_llm(task="compression") caught in ContextCompressor.compress() |
Enters 600-second cooldown; drops middle turns without summary rather than crashing; accepts slightly reduced context budget until cooldown expires and compression can be retried |
| Prompt injection in context files | Malicious AGENTS.md or .cursorrules contains "ignore previous instructions" patterns |
Regex scanning in prompt_builder.py detects exfiltration and override patterns during system prompt assembly |
Suspicious content is flagged or stripped; injection attempt is logged; system prompt integrity is preserved |
⚡ Performance Profile
Hot Paths
The most performance-sensitive code paths in this module:
- Token estimation (
estimate_messages_tokens_rough): Called on every turn for preflight checks — O(n) over message content using a 4-chars/token heuristic. Must be fast since it runs before every API call; avoids tokenizer imports for speed. - Context compression: O(n) message traversal for pruning + one LLM API call. The LLM call dominates (2-10 seconds); the local pruning and serialization are negligible. Triggered only when threshold is crossed, typically every 15-30 turns.
- Error classification (
classify_api_error): O(k) where k is the number of pattern matchers (~50). Runs only on exceptions. Priority ordering means common cases (429, 402) match early and short-circuit. - Memory prefetch: Runs in parallel across providers; latency is max(provider_latencies). Built-in provider is local file I/O (~1ms); external providers add network latency (50-500ms).
- Prompt caching injection (
apply_anthropic_cache_control): O(n) deep copy of messages + O(1) breakpoint insertion. Runs on every Anthropic API call but is fast compared to the API latency itself.
Resource Usage
- Memory: Message history is the primary memory consumer, growing linearly with conversation length. Compression resets this to head + summary + tail (~28K tokens typical).
_previous_summaryis a single string retained across compressions.CredentialPoolandRateLimitTrackermaintain small fixed-size state per credential. - I/O: External I/O is dominated by LLM API calls (1-30 seconds per call). Memory providers add network I/O during prefetch/sync (background).
TrajectoryCapturewrites structured JSON to disk asynchronously.InsightsEnginequeries the local SQLite session database on demand. - CPU: Minimal — the module is I/O-bound. The only CPU-intensive local operation is regex-based secret redaction in
redact.py, which runs O(n) over output text with ~15 compiled patterns.
Key Components
| Component | File | Role |
|---|---|---|
PromptBuilder | prompt_builder.py | Assembles system prompt: identity, platform hints, skills index, context files with injection detection, memory blocks |
ContextEngine | context_engine.py | Abstract base class for pluggable context management; defines lifecycle (init, compress, update, status) |
ContextCompressor | context_compressor.py | Default context engine; lossy LLM summarization with iterative updates, tool pruning, head/tail protection |
MemoryManager | memory_manager.py | Orchestrates built-in + one external memory provider; handles prefetch, sync, and single-external-provider constraint |
MemoryProvider | memory_provider.py | Abstract base for persistent recall backends (Honcho, Hindsight, Mem0, built-in file store) |
SmartModelRouting | smart_model_routing.py | Routes simple queries to a cheap model; conservative heuristic based on length, word count, and complexity indicators |
CredentialPool | credential_pool.py | Multi-credential failover with round-robin rotation, exhaustion tracking, and cooldown TTL per credential |
ErrorClassifier | error_classifier.py | Priority-ordered taxonomy mapping exceptions to recovery actions: retry, rotate, fallback, compress, abort |
RetryUtils | retry_utils.py | Decorrelated exponential backoff with counter-based jitter to prevent thundering-herd retry spikes |
RateLimitTracker | rate_limit_tracker.py | Parses x-ratelimit-* headers; tracks RPM/TPM buckets, remaining capacity, and reset times |
TrajectoryCapture | trajectory.py | Records full turn data (messages, tool calls, usage, timing) as structured JSON for RL training datasets |
UsagePricing | usage_pricing.py | Calculates per-turn and per-session cost from token usage and model pricing tables |
AuxiliaryClient | auxiliary_client.py | Unified LLM resolution chain for side tasks (compression, title gen, vision); auto-fallback on 402 |
PromptCaching | prompt_caching.py | Injects Anthropic cache_control breakpoints (system + last 3 messages) for ~75% cost reduction |
Display | display.py | Terminal output formatting with syntax highlighting, markdown rendering, and token usage statistics |
Sub-Modules
Context Management
Pluggable context engine with compression, references, and token tracking.
context_engine.py— Abstract base classcontext_compressor.py— Lossy summarization enginecontext_references.py— External reference injectionmanual_compression_feedback.py— User-triggered compression
Memory System
Provider-based persistent recall with 8+ backend support.
memory_manager.py— Multi-provider orchestrationmemory_provider.py— Abstract provider interfaceinsights.py— Session analytics engine
Model & Routing
Smart routing, metadata, credential management, and cost tracking.
smart_model_routing.py— Cheap-model route detectionmodel_metadata.py— Context lengths, token estimationcredential_pool.py— Multi-key rotationusage_pricing.py— Per-turn cost calculationanthropic_adapter.py— Anthropic-specific adaptations
Skills Engine
Skill discovery, parsing, and system prompt injection.
skill_commands.py— Skill index and payload loadingskill_utils.py— Frontmatter parsing utilitiessubdirectory_hints.py— Lazy AGENTS.md discovery
Error Handling
Classification, retry logic, and rate limit awareness.
error_classifier.py— Priority-ordered error taxonomyretry_utils.py— Jittered exponential backoffrate_limit_tracker.py— RPM/TPM header parsing
Utilities
Cross-cutting concerns: prompts, caching, display, capture, security.
prompt_builder.py— System prompt constructionprompt_caching.py— Anthropic cache breakpointsauxiliary_client.py— Side-task LLM fallback chaincopilot_acp_client.py— Copilot ACP integrationtrajectory.py— RL training data capturetitle_generator.py— Async conversation titlingdisplay.py— Terminal output formattingredact.py— Secret masking (~15 regex patterns)
Directory Map
Source tree for agent/
agent/
├── prompt_builder.py ← System prompt assembly with injection detection
├── context_engine.py ← Abstract base for pluggable context management
├── context_compressor.py ← Lossy LLM summarization with iterative updates
├── context_references.py ← External reference injection into context
├── memory_manager.py ← Multi-provider memory orchestration
├── memory_provider.py ← Abstract base for recall backends
├── skill_commands.py ← Skill index loading and payload resolution
├── skill_utils.py ← YAML frontmatter parsing for skills
├── smart_model_routing.py ← Cheap-model route detection heuristic
├── model_metadata.py ← Context lengths, token estimation (4 chars/tok)
├── credential_pool.py ← Multi-key rotation with exhaustion tracking
├── error_classifier.py ← Priority-ordered error taxonomy (50+ patterns)
├── retry_utils.py ← Jittered exponential backoff (counter XOR seed)
├── rate_limit_tracker.py ← x-ratelimit-* header parsing, RPM/TPM buckets
├── trajectory.py ← RL training data capture (structured JSON)
├── usage_pricing.py ← Per-turn and per-session cost calculation
├── display.py ← Terminal formatting, syntax highlighting, stats
├── anthropic_adapter.py ← Anthropic-specific API adaptations
├── auxiliary_client.py ← Side-task LLM fallback chain resolution
├── copilot_acp_client.py ← GitHub Copilot ACP protocol client
├── insights.py ← Session analytics (tokens, costs, tool usage)
├── title_generator.py ← Async background title generation
├── redact.py ← Regex-based secret masking before output
├── prompt_caching.py ← Anthropic cache_control breakpoint injection
├── subdirectory_hints.py ← Lazy AGENTS.md/.cursorrules discovery
├── manual_compression_feedback.py ← User-triggered manual compression support
├── __init__.py ← Package init
└── __pycache__/ ← Bytecode cache (gitignored)