Skip to content

Execution Flow

This page traces a task from the moment a user submits a message through DAG resolution, tool dispatch, sub-agent calls, and final result delivery.

User message
↓
Chat API endpoint (/api/chat)
↓
Intent Dispatcher ← Tier 1 routes USER_MESSAGE → DeliverToChat
↓
Session Manager ← Loads agent config, memory context
↓
ExecutionEngine
↓
├─ Task planner ← Decomposes goal into tasks if project context
│ ↓
│ DAG Resolver ← Resolves task order from dependencies
│ ↓
│ Task Executor ← Runs tasks sequentially or in parallel
↓
Agent Loop ← Multi-turn conversation with LLM
│
├─ LLM API call ← Anthropic/OpenAI/etc.
│
├─ Tool use response
│ ↓
│ ToolDispatcher
│ ├─ Built-in tools (bash, file, web_search, browser, ...)
│ ├─ MCP tools (mcp__server__tool_name)
│ └─ Sub-agent (spawn_sub_agent)
│ ↓
│ SubAgentLifecycle
│ ↓
│ [Nested agent loop]
│ ↓
│ ResultAggregator
↓
Result delivery ← SSE stream to UI / channel message
↓
Memory update ← Episode stored

The chat API endpoint receives a POST:

POST /api/chat/{agent_id}
{"message": "Analyze the test failures in the CI pipeline"}

It:

  1. Validates auth (API key check)
  2. Loads the agent config from agents table
  3. Creates or resumes a session (session_id)
  4. Starts an SSE response stream

Before the session is loaded, the chat API builds an IntentEnvelope with source=USER_MESSAGE, the room_id from the conversation, and the user’s agent_hint. The Unified Intent Dispatcher’s Tier 1 rule for room_id + kind=MESSAGE matches immediately, producing a DeliverToChat action that resolves to the TurnOrchestrator path below. Dispatch is fire-and-forget on the streaming path — the dispatch hot path adds ≤2 ms p50 and never blocks the first SSE byte.

For non-chat producers (channel webhooks, scheduler ticks, hooks, proactive insights, device events) the same IntentDispatcher.dispatch() call decides which executor receives the signal. The chat case is the fast path because the envelope carries explicit hints; ambiguous envelopes go through Tier 3 (LLM classification).

The SessionManager:

  1. Loads recent episodic memory (configurable window, default last 20 episodes)
  2. Runs semantic retrieval to find relevant past context
  3. Constructs the system prompt with agent personality + memory context
  4. Injects any project/task context if the message is part of a project

The core execution is an agentic loop:

while True:
response = await llm.create(
model=agent.model,
system=system_prompt,
messages=conversation_history,
tools=available_tools,
)
if response.stop_reason == "end_turn":
break # LLM is done
if response.stop_reason == "tool_use":
for tool_call in response.tool_uses:
result = await tool_dispatcher.dispatch(tool_call)
conversation_history.append(tool_result(tool_call.id, result))
# Continue loop with updated history

Maximum turns: configurable per agent (default: 50).

The ToolDispatcher (not the Intent Dispatcher — see the disambiguation note in Step 2) routes tool calls to their handler:

tool_name
├─ "bash" → BashTool.execute()
├─ "read_file" → FileTool.read()
├─ "write_file" → FileTool.write()
├─ "web_search" → BraveSearchTool.search()
├─ "browser_navigate" → BrowserManager.navigate()
├─ "spawn_sub_agent" → SubAgentSpawner.spawn()
└─ "mcp__*__*" → MCPConnectionManager.call_tool()

Tool results flow back into the conversation as tool_result messages.

When the agent calls spawn_sub_agent:

  1. SubAgentSpawner.spawn() validates permissions
  2. Creates a SubAgent record in the database
  3. Starts an isolated session (with filtered tool set)
  4. Runs the sub-agent’s own agent loop concurrently
  5. Returns partial or final result to the parent
  6. ResultAggregator merges results using the configured strategy

Sub-agents can spawn their own sub-agents (up to depth 3).

Certain actions require human approval before proceeding:

  • Execution pauses
  • ApprovalRequest is created and streamed to the UI
  • Agent waits (up to configurable timeout)
  • On approval: continues; on rejection: tool result is “action rejected by user”

After the session ends (user stops interacting or max turns reached):

  1. The entire conversation is serialized as episodic memory entries
  2. If a vector store is configured, embeddings are computed and stored
  3. The knowledge graph is updated with any new entities discovered

Throughout execution, the SSE stream delivers:

event: delta
data: {"text": "I'll analyze the CI failures...", "session_id": "sess_abc"}
event: tool_use
data: {"tool": "bash", "input": {"command": "cat ci.log | grep FAILED"}}
event: tool_result
data: {"tool": "bash", "output": "3 tests failed: test_auth, test_payment..."}
event: delta
data: {"text": "Found 3 test failures. The root cause is..."}
event: done
data: {"session_id": "sess_abc", "token_usage": {"input": 1842, "output": 547}}

The UI renders these events in real time.

A turn is not tied to the socket that started it. Every turn (single-agent, multi-agent, team) runs in a detached task and publishes each SSE event through a per-turn journal with a monotonic id: sequence number; the response the sender receives is just the first subscriber. If that socket goes away — a page reload, a dev-server restart, a Wi‑Fi or tunnel blip — generation continues and the reply is still persisted.

A client catches up with the running turn instead of waiting for it to finish:

EndpointPurpose
GET /api/chat/conversations/{id} → turnSnapshot of the running (or just-finished) turn: turn_id, seq, status.
GET /api/chat/conversations/{id}/turn/stream?after=NReplays every journaled event with seq > N, then follows the turn live, then emits turn_done.
POST /api/chat/conversations/{id}/turn/stopEnds the turn server-side. Aborting the socket alone no longer stops generation.

The web UI opens the turn stream from after=0 when a conversation loads with a running turn (a fresh tab or a reload), and from its last applied seq when a live stream drops mid-turn (with 1 s → 30 s backoff, up to 10 attempts). Each event’s id: is the cursor. The journal keeps a finished turn addressable for two minutes, and an on-disk copy (~/.snippbot/turns/<conversation>/<turn>.jsonl, culled after 48 h) lets a restarted daemon fold whatever streamed before a crash back into the reply. SNIPPBOT_DURABLE_SINGLE_AGENT=off reverts single-agent turns to the older socket-driven behaviour.

When an agent is working on a project (not a free-form chat), the flow changes:

  1. Project goal is decomposed into tasks by the task planner
  2. Tasks are arranged in a DAG based on depends_on relationships
  3. The DAGResolver computes the execution order
  4. Tasks execute per the project’s execution_strategy:
    • sequential: one at a time
    • parallel: all independent tasks simultaneously
    • dag: topological order with concurrency where possible
  5. Each task runs its own agent loop
  6. Project status updates as tasks complete (in_progress → completed)

Tasks that fail are classified by failure type:

ClassExamplesDefault retry
transientNetwork timeout, rate limitYes — up to 3x with backoff
tool_errorBash command failedYes — up to 2x
llm_errorModel API errorYes — 1x after 10s
context_limitToken limit exceededCompress context and retry
permanentInvalid tool argsNo
human_rejectedUser rejected actionNo

Retry delays use exponential backoff: 10s, 30s, 90s.

Multiple agent sessions can run concurrently. Each session has its own:

  • Conversation history
  • Active tool calls
  • Sub-agent tree

Limits:

  • Max concurrent sessions per agent: 5 (configurable)
  • Max concurrent sub-agents globally: 8
  • Max concurrent workflow runs: 10 (configurable via WORKFLOW_MAX_CONCURRENT_RUNS)