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.

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.