Agent Runtime
// Platform input -> session hydration -> agent loop -> tool dispatch -> persistence.
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.
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.pyandgateway/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.tsandruntime/types.ts- runtime session handles.
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.
@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: ...
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 };
}
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.
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
)
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());
}
}
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.
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)
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 });
}
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.
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,
)
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,
}),
};
}
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.
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
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);
}
}
What the Codebases Have in Common
| Runtime Part | Hermes Agent | OpenClaw |
|---|---|---|
| Input boundary | SessionSource, BasePlatformAdapter.handle_message, allowlist mixin | AcpRuntimeHandle, AcpRuntimeTurnInput, in-memory ACP session store |
| Hydration | conversation_history, context compression, memory tools | Session.buildContext(), compaction summaries, active branch replay |
| Agent loop | agent.conversation_loop.run_conversation with max_iterations | runLoop() with tool-use, steering, follow-up queues, and stop hooks |
| Tool dispatch | tools.registry.dispatch() plus sequential/concurrent executor modules | executeToolCalls(), deferred resolution, hookable before/after tool policies |
| Persistence | SQLite append_message plus JSON log, duplicate-write markers | Append-only JSONL session tree entries |