Modern tool-using agents tend to converge on the same runtime shape. A platform event is normalized into a session, prior state is assembled into model context, the model runs a loop, tool calls are dispatched, and the resulting transcript is persisted for the next turn. This page uses NousResearch/hermes-agent and openclaw/openclaw as concrete implementations of that standard pattern.

The examples are intentionally compact. They are source-shaped excerpts and reconstructions that preserve the real filenames, public class/function names, and data flow while leaving out surrounding product code, error branches, and provider-specific details.

Inbound event
01 Platform input platform/session identity
02 Session hydration prompt + message history
03 Agent loop reasoning turn + stop policy
04 Tool dispatch schema, policy, execution
05 Persistence transcript / memory
Outbound response
Hermes:Python gateway + AIAgent + tools.registry + SQLite session DB
OpenClaw:TypeScript ACP/session layer + CoreAgentHarness + agentLoop + JSONL session tree

Hermes Agent

Hermes keeps a large Python runtime around AIAgent. The gateway builds SessionSource objects, adapters inherit BasePlatformAdapter, the loop lives behind agent.conversation_loop.run_conversation, tools self-register in tools.registry, and session rows are appended through SessionDB.append_message.

OpenClaw

OpenClaw separates reusable TypeScript packages. @openclaw/agent-core owns CoreAgentHarness, agentLoop, session context building, tool execution, and JSONL-backed session storage. ACP/runtime types and session stores bind external clients to stable sessionKey handles.

Interactive slice

Send a simple prompt. Inspect the runtime path.

Inspired by the standalone Agent Runtime explainer, this is a separate runtime observability lab. It turns a plain request into an inspectable agent trace without exposing hidden chain-of-thought, secrets, or private session fragments.

Prompt: Review the portfolio-site deploy checklist

The trace shows operational evidence: intent, selected tools, file/code landmarks, and verification. It deliberately does not show private reasoning tokens.

Loop1 / 3
Prompt tokens8
Tool calls0
Stop reasonintake
01Prompt intakeNormalize request + session source
02Context buildMemory, skills, history, constraints
03Loop policyChoose tools + safety rails
04Tool dispatchExecute, observe, adapt
05Verify + persistCheck result, save useful state

01 Prompt intake

    Boundary: this view can show model-visible prompts, loop state, tool calls, token accounting, and observable rationale summaries. It does not expose hidden chain-of-thought or secrets.

    Source-shaped landmark

    
          

    Model-visible prompt stack

    
          

    Observable event stream

    Source Anchors

    Hermes Python

    • run_agent.py - AIAgent, persistence hooks, tool-call forwarding.
    • agent/conversation_loop.py - max-iteration loop and provider calls.
    • agent/tool_executor.py - sequential/concurrent tool execution.
    • tools/registry.py - self-registering tool schemas and dispatch.
    • gateway/session.py and gateway/platforms/base.py - message source and adapter boundary.

    OpenClaw TypeScript

    • packages/agent-core/src/agent-loop.ts - core LLM/tool loop.
    • packages/agent-core/src/harness/agent-harness.ts - session hydration, hooks, persistence.
    • packages/agent-core/src/harness/session/session.ts - context assembly from session branch.
    • packages/agent-core/src/harness/session/jsonl-storage.ts - append-only JSONL storage.
    • packages/acp-core/src/session.ts and runtime/types.ts - runtime session handles.
    Part 1

    Platform Input

    Hermes has direct messaging adapters. OpenClaw exposes runtime/session contracts that let platform clients deliver turns through stable handles. Both normalize an external event into a session identity before the model sees anything.

    Hermes Pythongateway/session.py, gateway/platforms/base.py
    @dataclass
    class SessionSource:
        platform: Platform
        chat_id: str
        chat_type: str = "dm"
        user_id: Optional[str] = None
        user_name: Optional[str] = None
        thread_id: Optional[str] = None
        message_id: Optional[str] = None
        profile: Optional[str] = None
    
    class BasePlatformAdapter(ABC):
        supports_async_delivery: bool = True
        splits_long_messages: bool = False
    
        def set_message_handler(self, handler: MessageHandler) -> None:
            self._message_handler = handler
    
        async def handle_message(self, event: MessageEvent) -> None:
            session_key = event.source.session_key
            if session_key in self._active_sessions:
                self._pending_messages[session_key] = event
                return
            task = asyncio.create_task(
                self._process_message_background(event, session_key)
            )
            self._session_tasks[session_key] = task
    
        @abstractmethod
        async def start(self) -> None: ...
    
        @abstractmethod
        async def send(self, chat_id: str, text: str, metadata=None) -> None: ...
    OpenClaw TypeScriptpackages/acp-core/src/runtime/types.ts, session.ts
    export type AcpRuntimeHandle = {
      sessionKey: string;
      backend: string;
      runtimeSessionName: string;
      cwd?: string;
      backendSessionId?: string;
      agentSessionId?: string;
    };
    
    export type AcpRuntimeTurnInput = {
      handle: AcpRuntimeHandle;
      text: string;
      attachments?: AcpRuntimeTurnAttachment[];
      mode: "prompt" | "steer";
      requestId: string;
      signal?: AbortSignal;
    };
    
    export function createInMemorySessionStore(): AcpSessionStore {
      const sessions = new Map<string, AcpSession>();
    
      const createSession = (params) => {
        const sessionId = params.sessionId ?? randomUUID();
        const existing = sessions.get(sessionId);
        if (existing) {
          existing.sessionKey = params.sessionKey;
          existing.cwd = params.cwd;
          return existing;
        }
        const session = { sessionId, sessionKey: params.sessionKey, cwd: params.cwd };
        sessions.set(sessionId, session);
        return session;
      };
    
      return { createSession, getSession, cancelActiveRun, deleteSession };
    }
    Part 2

    Session Hydration

    Hydration is where prior state becomes model-visible context. Hermes passes prior conversation history into run_conversation and can compress context. OpenClaw makes this explicit: Session.buildContext() walks the active session branch and injects compaction summaries before recent messages.

    Hermes Pythonrun_agent.py
    class AIAgent:
        def run_conversation(
            self,
            user_message: str,
            system_message: str = None,
            conversation_history: List[Dict[str, Any]] = None,
            task_id: str = None,
            stream_callback: Optional[callable] = None,
            persist_user_message: Optional[str] = None,
            persist_user_timestamp: Optional[float] = None,
            moa_config: Optional[dict[str, Any]] = None,
        ) -> Dict[str, Any]:
            from agent.conversation_loop import run_conversation
            return run_conversation(
                self,
                user_message,
                system_message,
                conversation_history,
                task_id,
                stream_callback,
                persist_user_message,
                persist_user_timestamp=persist_user_timestamp,
                moa_config=moa_config,
            )
    
        def _compress_context(self, messages, system_message, *, approx_tokens=None):
            from agent.conversation_compression import compress_context
            return compress_context(
                self, messages, system_message, approx_tokens=approx_tokens
            )
    OpenClaw TypeScriptpackages/agent-core/src/harness/session/session.ts
    export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
      let compaction: CompactionEntry | null = null;
      for (const entry of pathEntries) {
        if (entry.type === "compaction") compaction = entry;
      }
    
      const messages: AgentMessage[] = [];
      const appendMessage = (entry: SessionTreeEntry) => {
        if (entry.type === "message") messages.push(entry.message);
        if (entry.type === "branch_summary" && entry.summary) {
          messages.push(asAgentMessage(createBranchSummaryMessage(entry.summary)));
        }
      };
    
      if (compaction) {
        messages.push(asAgentMessage(
          createCompactionSummaryMessage(
            compaction.summary,
            compaction.tokensBefore,
            compaction.timestamp,
          ),
        ));
        const compactionIdx = pathEntries.findIndex(
          (e) => e.type === "compaction" && e.id === compaction.id,
        );
        let foundFirstKept = false;
        for (let i = 0; i < compactionIdx; i++) {
          const entry = pathEntries[i];
          if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
          if (foundFirstKept) appendMessage(entry);
        }
        for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
          appendMessage(pathEntries[i]);
        }
      } else {
        for (const entry of pathEntries) appendMessage(entry);
      }
    
      return { messages, thinkingLevel, model };
    }
    
    export class Session {
      async buildContext(): Promise<SessionContext> {
        return buildSessionContext(await this.getBranch());
      }
    }
    Part 3

    Agent Loop

    The loop is the think/act cycle. Hermes counts API calls against agent.max_iterations and forwards assistant tool calls to the executor. OpenClaw's runLoop continues while tool calls or steering messages exist, then emits agent_end.

    Hermes Pythonagent/conversation_loop.py, run_agent.py
    def run_conversation(agent, user_message, system_message=None,
                         conversation_history=None, task_id=None, *args, **kwargs):
        messages = list(conversation_history or [])
        messages.append({"role": "user", "content": user_message})
        api_call_count = 0
    
        while (
            api_call_count < agent.max_iterations
            and agent.iteration_budget.remaining > 0
        ) or agent._budget_grace_call:
            api_call_count += 1
            agent._api_call_count = api_call_count
    
            # provider request/stream handling builds the assistant message here
            messages.append(assistant_message)
    
            if getattr(assistant_message, "tool_calls", None):
                agent._execute_tool_calls(
                    assistant_message, messages, task_id, api_call_count
                )
                agent._persist_session(messages, conversation_history)
                continue
    
            agent._persist_session(messages, conversation_history)
            return {"response": assistant_message.content, "messages": messages}
    
        return agent._handle_max_iterations(messages, api_call_count)
    OpenClaw TypeScriptpackages/agent-core/src/agent-loop.ts
    async function runLoop(
      initialContext: AgentContext,
      newMessages: AgentMessage[],
      initialConfig: AgentLoopConfig,
      signal: AbortSignal | undefined,
      emit: AgentEventSink,
    ): Promise<void> {
      let currentContext = initialContext;
      let pendingMessages = (await initialConfig.getSteeringMessages?.()) || [];
    
      while (true) {
        let hasMoreToolCalls = true;
    
        while (hasMoreToolCalls || pendingMessages.length > 0) {
          for (const message of pendingMessages) {
            currentContext.messages.push(message);
            newMessages.push(message);
          }
    
          const message = await streamAssistantResponse(
            currentContext, initialConfig, signal, emit,
          );
          newMessages.push(message);
    
          const toolCalls = message.content.filter((c) => c.type === "toolCall");
          hasMoreToolCalls = false;
          if (message.stopReason === "toolUse" && toolCalls.length > 0) {
            const batch = await executeToolCalls(
              currentContext, message, initialConfig, signal, emit,
            );
            for (const result of batch.messages) currentContext.messages.push(result);
            hasMoreToolCalls = !batch.terminate;
          }
    
          if (await initialConfig.shouldStopAfterTurn?.({ message, context: currentContext })) {
            await emit({ type: "agent_end", messages: newMessages });
            return;
          }
          pendingMessages = (await initialConfig.getSteeringMessages?.()) || [];
        }
        break;
      }
      await emit({ type: "agent_end", messages: newMessages });
    }
    Part 4

    Tool Dispatch

    Both projects keep schemas and execution separate from the LLM loop. Hermes uses a singleton registry populated by top-level registry.register() calls. OpenClaw resolves tool calls from the current context, validates/blocks them through hooks, and runs sequentially or in parallel depending on policy.

    Hermes Pythontools/registry.py, tools/memory_tool.py
    class ToolRegistry:
        def __init__(self):
            self._tools: Dict[str, ToolEntry] = {}
            self._lock = threading.RLock()
    
        def register(self, name, toolset, schema, handler, check_fn=None,
                     is_async=False, description="", emoji="", override=False):
            with self._lock:
                self._tools[name] = ToolEntry(
                    name=name,
                    toolset=toolset,
                    schema=schema,
                    handler=handler,
                    check_fn=check_fn,
                    is_async=is_async,
                    description=description or schema.get("description", ""),
                    emoji=emoji,
                )
    
        def get_definitions(self, tool_names: Set[str]) -> List[dict]:
            definitions = []
            for entry in self._snapshot_entries():
                if entry.name not in tool_names:
                    continue
                if entry.check_fn and not _check_fn_cached(entry.check_fn):
                    continue
                schema = {**entry.schema, "name": entry.name}
                definitions.append({"type": "function", "function": schema})
            return definitions
    
        def dispatch(self, name: str, args: dict, **kwargs) -> str:
            entry = self.get_entry(name)
            if not entry:
                return json.dumps({"error": f"Unknown tool: {name}"})
            try:
                return entry.handler(args, **kwargs)
            except Exception as exc:
                return json.dumps({"error": f"Tool execution failed: {exc}"})
    
    registry.register(
        name="memory",
        toolset="memory",
        schema=MEMORY_SCHEMA,
        handler=lambda args, **kw: memory_tool(...),
        check_fn=check_memory_requirements,
    )
    OpenClaw TypeScriptpackages/agent-core/src/agent-loop.ts, agent-harness.ts
    async function executeToolCalls(
      currentContext: AgentContext,
      assistantMessage: AssistantMessage,
      config: AgentLoopConfig,
      signal: AbortSignal | undefined,
      emit: AgentEventSink,
    ): Promise<ExecutedToolCallBatch> {
      const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
      const resolvedToolCalls = new Map<AgentToolCall, ResolvedToolCallOutcome>();
    
      let hasSequentialToolCall = false;
      if (config.toolExecution !== "sequential") {
        for (const toolCall of toolCalls) {
          const resolution = await resolveToolCallTool(
            currentContext, assistantMessage, toolCall, config, signal, resolvedToolCalls,
          );
          if (resolution.kind === "resolved" && resolution.tool?.executionMode === "sequential") {
            hasSequentialToolCall = true;
            break;
          }
        }
      }
    
      if (config.toolExecution === "sequential" || hasSequentialToolCall) {
        return executeToolCallsSequential(
          currentContext, assistantMessage, toolCalls, resolvedToolCalls, config, signal, emit,
        );
      }
    
      return executeToolCallsParallel(
        currentContext, assistantMessage, toolCalls, resolvedToolCalls, config, signal, emit,
      );
    }
    
    function createLoopConfig(): AgentLoopConfig {
      return {
        beforeToolCall: async ({ toolCall, args }) =>
          emitHook({ type: "tool_call", toolName: toolCall.name, input: args }),
        afterToolCall: async ({ toolCall, result, isError }) =>
          emitHook({
            type: "tool_result",
            toolName: toolCall.name,
            content: result.content,
            isError,
          }),
      };
    }
    Part 5

    Persistence

    Hermes writes both a session log and SQLite rows, marking message dictionaries after flush so multiple exit paths do not duplicate rows. OpenClaw records append-only session tree entries to JSONL and rebuilds context by walking the branch.

    Hermes Pythonrun_agent.py
    def _persist_session(self, messages: List[Dict], conversation_history=None):
        self._drop_trailing_empty_response_scaffolding(messages)
        self._session_messages = messages
        self._save_session_log(messages)
        self._flush_messages_to_session_db(messages, conversation_history)
    
    def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history=None):
        if getattr(self, "_persist_disabled", False) or not self._session_db:
            return
    
        history_ids = {id(item) for item in (conversation_history or []) if isinstance(item, dict)}
    
        for msg in messages:
            if not isinstance(msg, dict) or msg.get(_DB_PERSISTED_MARKER):
                continue
            if id(msg) in history_ids:
                msg[_DB_PERSISTED_MARKER] = True
                continue
    
            self._session_db.append_message(
                session_id=self.session_id,
                role=msg.get("role", "unknown"),
                content=msg.get("content"),
                tool_name=msg.get("tool_name"),
                tool_calls=msg.get("tool_calls"),
                tool_call_id=msg.get("tool_call_id"),
            )
            msg[_DB_PERSISTED_MARKER] = True
    OpenClaw TypeScriptsession.ts, jsonl-storage.ts
    export class Session {
      private async appendTypedEntry(entry: SessionTreeEntry): Promise<string> {
        await this.storage.appendEntry(entry);
        return entry.id;
      }
    
      async appendMessage(message: AgentMessage): Promise<string> {
        return this.appendTypedEntry({
          type: "message",
          id: await this.storage.createEntryId(),
          parentId: await this.getAppendParentId(),
          timestamp: new Date().toISOString(),
          message,
        });
      }
    }
    
    export class JsonlSessionStorage extends BaseSessionStorage {
      static async create(fs, filePath, options): Promise<JsonlSessionStorage> {
        const header = {
          type: "session",
          version: 3,
          id: options.sessionId,
          timestamp: new Date().toISOString(),
          cwd: options.cwd,
          parentSession: options.parentSessionPath,
        };
        await fs.writeFile(filePath, `${JSON.stringify(header)}\n`);
        return new JsonlSessionStorage(fs, filePath, header, [], null, null);
      }
    
      override async appendEntry(entry: SessionTreeEntry): Promise<void> {
        this.validateEntryForAppend(entry);
        await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
        this.recordEntry(entry);
      }
    }
    Coordination

    What the Codebases Have in Common

    Runtime PartHermes AgentOpenClaw
    Input boundarySessionSource, BasePlatformAdapter.handle_message, allowlist mixinAcpRuntimeHandle, AcpRuntimeTurnInput, in-memory ACP session store
    Hydrationconversation_history, context compression, memory toolsSession.buildContext(), compaction summaries, active branch replay
    Agent loopagent.conversation_loop.run_conversation with max_iterationsrunLoop() with tool-use, steering, follow-up queues, and stop hooks
    Tool dispatchtools.registry.dispatch() plus sequential/concurrent executor modulesexecuteToolCalls(), deferred resolution, hookable before/after tool policies
    PersistenceSQLite append_message plus JSON log, duplicate-write markersAppend-only JSONL session tree entries
    Key difference: Hermes is a Python product runtime with many pragmatic gateway protections in one large agent surface. OpenClaw factors the same runtime into reusable TypeScript packages, so the harness/session/loop contracts are easier to isolate and test.