Date: 2026-03 Focus: reliability, concurrency control, resource management
GoClaw moved the agent runtime from a blocking request/response model to an event-driven actor-style model. The goal was to make streaming, cancellation, tool execution, and WebSocket output safe under concurrent load.
Older paths used Agent.Process() as the main execution primitive. That call
blocked until the full result was available.
The current runtime uses:
ProcessStream(ctx, content, history) (<-chan AgentEvent, error)
Consumers read events from the returned channel:
EventTokenChunk for streaming text.EventToolStart and EventToolResult for tool lifecycle updates. The agent
loop (runToolLoop, tool_loop.go) sends them on the stream for every
provider.EventCanvas for rich UI artifacts. Consumers handle it, but canvas output
currently reaches them through WithCanvasHandler and WithCanvasCollector.EventError when the request failed. It is the last event: the stream closes
after it, and DrainToResult returns it as the error.EventBufferReset when a retry should discard partial streamed text.EventDone for the final ProcessResult.Non-streaming callers use agent.DrainToResult() instead of calling
Process() directly.
Tool execution is routed through the shared worker pool instead of spawning unbounded goroutines.
pool := agent.GetWorkerPool()
pool.Submit(agent.ToolTask{
Ctx: ctx,
ToolName: name,
Args: args,
ResultCh: resultCh,
})
Benefits:
A tool call that waits on work which itself needs the pool (a subagent's run,
an agent run inside memory_consolidate) waits inside
whileBlocked, which adds a stand-in worker for as long as it waits. A call
holding its worker while the work it waits for sits queued behind it would
otherwise stall until its own timeout.
All WebSocket writes go through WritePump. Producers enqueue messages, and the
pump's single goroutine owns the actual socket writes.
This prevents concurrent writes from streaming chunks, final responses, tool events, canvas updates, and error handlers.
Event producers use context-aware sends:
select {
case ch <- event:
case <-ctx.Done():
}
If the client disconnects or the request context is cancelled, producers stop instead of blocking forever on an unread channel.
When WritePump reports a write failure (the browser went away), the WebSocket
handler stops writing but keeps consuming the stream to the end, so the final
reply is still saved to the session and shown on reconnect.
Cancellation also has to reach the work itself:
awaitToolResult. Once the request is
cancelled, a call still running gets 5 seconds (toolStopGrace) and is then
reported as not stopped. In a SOP step that attempt is treated as possibly
still running, and the step is not retried beside it.shell_exec, git, a SOP rollback command) are
built with toolCommand: they run in their own process group, cancelling
kills the whole group, and the wait for their output is bounded. A plain
exec.CommandContext kills only bash; what bash started keeps running and
keeps the call waiting.A session behaves like an actor with a mailbox: it handles one message at a time. A turn reads the history, runs the agent, and writes the history back with its message and reply. The session store returns a copy and writes the whole session, so two turns of one session at once (two people in one Teams group chat, a message sent twice) would each answer without the other, and the later write would drop the earlier turn.
Every entry point that runs a turn holds session.LockTurn(ctx, key) from
reading the history to writing it back: the native channels' common handler,
the WebSocket, web chat, REST, app API and webhook handlers, a subagent's run
and follow-up on its own session, and a subagent's result announced to its
parent, which waits until the parent's turn is over. Later messages wait their
turn and see the earlier ones. Discord also keeps a queue per session in front
of this, which preserves its message order.
What changes a session from outside a turn holds the same lock: the automatic
daily and idle reset, and the sessions API's delete, clear, create and rename.
Without it, a turn running at the same moment writes the history it read back
over the change, and a reset's media are already deleted by then. The idle
reset takes the lock only when it is free (session.TryLockTurn): a session
with a turn running or waiting is not idle.
The gateway still persists the inbound user message before the LLM call so a refresh or disconnect does not lose it; the agent receives the pre-append history plus the current message separately.
A panic ends the request it happened in, not the process. Both agent loops'
goroutines recover (recoverStreamPanic) and end their stream with
EventError; so do each WebSocket message's handler, the native channels'
handlers, the worker pool and SOP, cron and subagent runs. An unrecovered panic
in any goroutine stops the whole gateway: net/http recovers only a handler's own
goroutine.
internal/agent/tool_loop.gointernal/agent/tool_round.gointernal/agent/stream_panic.gointernal/agent/worker_pool.gointernal/agent/command.gointernal/session/turn_lock.gointernal/gateway/handler_websocket.gointernal/gateway/writepump.go