Agentic AI

Agentic AI

Chapter 1: The Harness Paradigm (Claude Code vs. Hermes Agent)

Ken Huang's avatar
Ken Huang
Apr 18, 2026
∙ Paid

1. Pattern Summary

The harness paradigm is the foundational insight of production AI engineering: the model provides intelligence, but the harness provides control. A raw language model is a text generator — powerful but undirected, capable but unsafe. The harness is the infrastructure layer that wraps the model and transforms it into a controllable, auditable, production-ready agent. It constrains the action space through typed tools, manages conversation state across turns, enforces safety through a permission system, handles failures gracefully, and tracks resource consumption. Without a harness, deploying an LLM into production is like wiring a jet engine directly to a steering wheel — the power is there, but the control system is missing.

2. Claude Code Implementation

Claude Code implements the harness as a TypeScript class called QueryEngine. This class is the single owner of all mutable state in a conversation session, and every agent interaction flows through it.

The QueryEngine as harness core

// src/QueryEngine.ts
// The QueryEngine is the harness core — it owns all mutable state for a session.
export class QueryEngine {
  private config: QueryEngineConfig
  private mutableMessages: Message[]       // Full conversation history, append-only
  private abortController: AbortController // User can cancel mid-execution at any time
  private permissionDenials: SDKPermissionDenial[] // Audit trail of every blocked action
  private totalUsage: NonNullableUsage     // Cumulative token + cost tracking

Each field maps to a core harness responsibility. mutableMessages is the agent's memory — every user message, assistant response, and tool result is appended here. abortController is the emergency stop — the user can always regain control. permissionDenials is the audit trail — every blocked action is recorded for review. totalUsage is the budget tracker — the harness knows exactly what the session has cost.

submitMessage() as async generator

The QueryEngine exposes its functionality through a single async generator method:

// src/QueryEngine.ts
// submitMessage is an async generator — it yields results incrementally.
// This enables real-time streaming: the user sees text as it's generated,
// and tool executions are reported as they complete, not after the full turn.
async *submitMessage(
  prompt: string | ContentBlockParam[],
  options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>

The async generator pattern is a deliberate design choice. Instead of blocking until the entire conversation turn is complete, submitMessage yields events as they happen — a token arrives, a tool starts, a tool finishes. This makes the system feel responsive and allows the UI layer to update in real time. It also means the harness can be interrupted mid-stream without losing the work already done.

The Tool interface as fundamental abstraction

Every action the model can take is expressed as a Tool. The interface is comprehensive by design — it forces every tool to declare not just what it does, but how it behaves:

// src/Tool.ts
// The Tool interface is the contract between the harness and every action.
// Tools must declare their identity, execution logic, input schema,
// concurrency safety, read/write/destructive behavior, and permission rules.
export type Tool<Input extends AnyObject = AnyObject, Output = unknown> = {
  readonly name: string                          // How the model addresses this tool
  call(args, context, canUseTool, ...): Promise<ToolResult<Output>>  // Execution
  readonly inputSchema: Input                    // Zod schema — validated before call()
  isConcurrencySafe(input): boolean              // Can this run in parallel?
  isReadOnly(input): boolean                     // Safe to auto-approve?
  isDestructive?(input): boolean                 // Requires explicit user consent?
  checkPermissions(input, context): Promise<PermissionResult>  // Tool-specific safety
  maxResultSizeChars: number                     // Prevents context window explosions
}

The inputSchema is a Zod schema — it provides both compile-time type safety and runtime validation. When the model produces a tool_use block, the harness validates the input against this schema before ever calling the tool. Malformed input never reaches tool code. The behavioral declarations (isReadOnly, isDestructive, isConcurrencySafe) let the harness make intelligent decisions about permissions and execution order without needing to understand what each tool actually does.

3. Hermes Agent Implementation

Hermes implements the harness as a Python class called AIAgent in run_agent.py. The design philosophy is similar — one class owns the session state — but the execution model is synchronous and the tool system uses a registry pattern rather than a typed interface.

AIAgent class structure

# run_agent.py
# AIAgent is the harness core in Hermes — it owns model, budget, callbacks, and platform context.
class AIAgent:
    def __init__(
        self,
        model: str = "anthropic/claude-opus-4.6",
        max_iterations: int = 90,        # Hard cap on LLM turns
        enabled_toolsets: List[str] = None,
        disabled_toolsets: List[str] = None,
        platform: str = None,            # "cli", "telegram", "discord", etc.
        iteration_budget: "IterationBudget" = None,  # Shared across parent + subagents
        tool_progress_callback: callable = None,     # Real-time tool status updates
        clarify_callback: callable = None,           # Human-in-the-loop questions
        session_id: str = None,
        # ... plus provider, routing, memory, and checkpoint params
    ):
        self.model = model
        self.max_iterations = max_iterations
        # IterationBudget is thread-safe and shared with subagents.
        # Parent creates it; children inherit it. This caps total work
        # across the entire agent tree, not just the root agent.
        self.iteration_budget = iteration_budget or IterationBudget(max_iterations)
        self.platform = platform

IterationBudget: the Hermes safety valve

# run_agent.py
# IterationBudget is a thread-safe counter that caps runaway agent loops.
# It's shared across parent and all subagents — total iterations are bounded
# at the tree level, not per-agent. consume() returns False when the budget
# is exhausted, which breaks the agent loop cleanly.
class IterationBudget:
    def __init__(self, max_total: int):
        self.max_total = max_total
        self._used = 0
        self._lock = threading.Lock()   # Thread-safe for parallel subagents

    def consume(self) -> bool:
        """Try to consume one iteration. Returns True if allowed."""
        with self._lock:
            if self._used >= self.max_total:
                return False
            self._used += 1
            return True

    def refund(self) -> None:
        """Give back one iteration (e.g. for programmatic tool calls)."""
        with self._lock:
            if self._used > 0:
                self._used -= 1

run_conversation() as synchronous loop

# run_agent.py (simplified from AGENTS.md)
# run_conversation() is the harness loop — entirely synchronous.
# It calls the LLM, executes tools, and repeats until the model stops
# requesting tools or the iteration budget is exhausted.
def run_conversation(self, user_message: str, ...) -> dict:
    while api_call_count < self.max_iterations and self.iteration_budget.remaining > 0:
        response = client.chat.completions.create(
            model=model, messages=messages, tools=tool_schemas
        )
        if response.tool_calls:
            # Execute each tool call and append results to message history
            for tool_call in response.tool_calls:
                result = handle_function_call(tool_call.name, tool_call.args, task_id)
                messages.append(tool_result_message(result))
            api_call_count += 1
        else:
            # No more tool calls — model is done
            return {"final_response": response.content, "messages": messages}

Tool registry pattern

Where Claude Code uses a typed Tool interface, Hermes uses a central registry. Tools register themselves at import time:

# tools/registry.py (pattern from AGENTS.md)
# Tools self-register at import time. The registry collects schemas for the
# LLM's tool list and routes function_call events to the right handler.
# No interface to implement — just call registry.register() with a schema,
# a handler lambda, and an optional availability check function.
registry.register(
    name="port_scan",
    toolset="security",
    schema={
        "name": "port_scan",
        "description": "Scan open ports on a target host",
        "parameters": {"type": "object", "properties": {"host": {"type": "string"}}}
    },
    handler=lambda args, **kw: port_scan(host=args["host"], task_id=kw.get("task_id")),
    check_fn=lambda: bool(os.getenv("NMAP_AVAILABLE")),  # Availability guard
)

The registry pattern trades compile-time safety for runtime flexibility. Tools can be added, removed, or conditionally enabled without changing any interface — just import the file and the tool appears. The check_fn provides the availability guard that Claude Code's checkPermissions handles at the interface level.

4. Side-by-Side Comparison

Comparison table

5. When to Use Which

User's avatar

Continue reading this post for free, courtesy of Ken Huang.

Or purchase a paid subscription.
© 2026 ken · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture