Session Structure and Run Loop Workflow in Reasonix: Managing Conversation State and Tool Execution

Reasonix implements a thread-safe Session struct to maintain immutable conversation logs while a deterministic Run loop alternates between LLM inference and tool execution until producing a final assistant response.

The esengine/DeepSeek-Reasonix framework orchestrates multi-turn AI conversations through a carefully designed state management system. Understanding the Session structure and Run loop workflow is essential for developers building complex agentic applications that require persistent context and dynamic tool calling. This article examines the core implementation details found in the Reasonix source code, demonstrating how the framework handles concurrency, tool dispatch, and conversation compaction.

Understanding the Session Structure in Reasonix

The Session type, defined in internal/agent/session.go, serves as the immutable conversation log that persists across multiple turns of the Run loop.

Core Fields and Concurrency Control

The Session struct uses a sync.RWMutex named mu to guard concurrent access patterns. While the Run loop acts as the sole writer, UI components may read snapshots concurrently without blocking the active conversation.

The struct maintains four critical fields:

  • Messages: A slice of provider.Message storing chronological history including system prompts, user inputs, assistant responses, and tool interactions
  • rewriteVersion: An integer incremented each time the log is compacted (e.g., after a long run-loop)
  • normalizedDirty: A boolean flag set when LoadSession repairs corrupted history such as empty tool-call names, dangling calls, or truncated arguments

Message Management Methods

The Session exposes several concurrency-safe operations for manipulating conversation state:

  • NewSession(system string): Creates a fresh session optionally pre-populated with a system prompt
  • Add(m provider.Message): Appends a new message while holding the write lock
  • Replace(msgs []provider.Message): Swaps the entire log during compaction operations that rewrite the middle of the history
  • Snapshot() []provider.Message: Returns a deep copy that other goroutines can read safely
  • HasContent() bool: Determines if the session contains anything beyond a system prompt, used to decide if the session should be persisted
  • RewriteVersion() / IncrementRewrite(): Expose and bump the compaction version counter

The Run Loop Workflow for Processing Tool Calls

The Run loop, primarily implemented in internal/agent/agent.go and orchestrated through internal/agent/task.go, drives the turn-by-turn conversation flow that processes tool calls.

Entry Point: RunSubAgentWithSession

Sub-agents enter the loop via RunSubAgentWithSession in internal/agent/task.go (lines 29-50). This function wires together the Session, tool Registry, Provider, and event Sink:

func RunSubAgentWithSession(
    ctx context.Context,
    prov provider.Provider,
    reg  *tool.Registry,
    sess *Session,
    prompt string,
    opts  Options,
    sink  event.Sink,
) (string, error) {
    if sess == nil { return "", fmt.Errorf("sub‑agent session is nil") }
    sub := New(prov, reg, sess, opts, sink)   // creates the agent instance
    if err := sub.Run(ctx, prompt); err != nil {
        return "", fmt.Errorf("sub‑agent: %w", err)
    }
    // Walk backwards to find the last non-empty assistant message
    for i := len(sess.Messages) - 1; i >= 0; i-- {
        m := sess.Messages[i]
        if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
            return m.Content, nil
        }
    }
    return "", fmt.Errorf("sub‑agent finished without producing a final answer")
}

Turn-by-Turn Execution Flow

Inside Agent.Run, the loop follows a strict execution pattern:

  1. Provider Invocation: Sends the current snapshot via prov.Chat(ctx, sess.Snapshot(), opts)
  2. Response Parsing: Inspects the returned provider.Message for tool calls
  3. Tool Detection: If Role == RoleAssistant and ToolCalls are present, pauses normal text handling
  4. Final Answer Detection: If no tool calls exist, appends the message and terminates the loop
  5. Tool Execution: For each call, looks up the tool name in tool.Registry using registry.Get(name) and executes via tool.Execute(ctx, args)
  6. Result Recording: Creates a RoleTool message with the execution result and appends it to the Session

Event Nesting and Sub-Agent Isolation

Tool-dispatch and tool-result events forward to the parent event.Sink through a subSink. This architecture allows front-ends to render nested visual hierarchies under the original task call while keeping the sub-agent's textual reasoning isolated. Only the final assistant answer returns to the parent model, ensuring clean separation between parent and sub-agent contexts.

Compaction and Loop Termination

After each turn, the agent evaluates compaction triggers when the message count exceeds the ContextWindow limit. Compaction rewrites the middle of the message slice, increments rewriteVersion, and calls Session.Replace(newMsgs) to atomically swap the log.

The loop terminates when either:

  • An assistant message arrives without ToolCalls (success case)
  • The configurable maxSteps budget exhausts, raising a MaxStepsExceeded error

Practical Implementation Example

The following example demonstrates creating a Session, running an agent with tool capabilities, and extracting the final response:

package main

import (
    "context"
    "fmt"

    "reasonix/internal/agent"
    "reasonix/internal/provider"
    "reasonix/internal/tool"
)

func mockProvider() provider.Provider   { /* ... */ return nil }
func mockRegistry() *tool.Registry      { /* ... */ return nil }

func main() {
    // Initialize session with system prompt
    sess := agent.NewSession("You are a coding assistant.")
    
    // Configure dependencies
    prov := mockProvider()
    reg := mockRegistry()
    opts := agent.Options{MaxSteps: 10, ContextWindow: 4096}
    
    // Create agent instance
    ag := agent.New(prov, reg, sess, opts, agent.EventSink())
    
    // Execute run loop
    prompt := "List the Go source files in the current directory."
    if err := ag.Run(context.Background(), prompt); err != nil {
        panic(err)
    }
    
    // Extract final answer from session history
    msgs := sess.Snapshot()
    for i := len(msgs) - 1; i >= 0; i-- {
        if msgs[i].Role == provider.RoleAssistant && msgs[i].Content != "" {
            fmt.Println("Final answer:", msgs[i].Content)
            break
        }
    }
}

This produces a message sequence: system → user → assistant (tool call) → tool result → assistant (final answer).

Summary

  • The Session structure in Reasonix provides thread-safe, immutable conversation storage using sync.RWMutex and versioning fields like rewriteVersion defined in internal/agent/session.go
  • RunSubAgentWithSession in internal/agent/task.go serves as the primary entry point for sub-agent execution, coordinating the Provider, Registry, and Session
  • The Run loop alternates between LLM inference and tool execution, checking provider.Message for ToolCalls on each iteration and dispatching via tool.Registry
  • Tool results are recorded as RoleTool messages via the Execute method, maintaining a complete chronological transcript
  • Conversation compaction rewrites history mid-log when context limits approach, incrementing the rewrite version through Session.Replace()
  • The loop terminates upon receiving a final assistant message without tool calls or when the maxSteps budget is exceeded

Frequently Asked Questions

What is the Session structure in Reasonix?

The Session structure is a concurrency-safe conversation log defined in internal/agent/session.go that stores chronological provider.Message slices. It features a sync.RWMutex named mu for thread safety, a rewriteVersion counter for compaction tracking, and methods like Snapshot() and Replace() for safe concurrent access and history rewriting during long-running conversations.

How does the Run loop handle tool calls?

The Run loop, implemented in Agent.Run, detects tool calls by checking if the assistant message contains ToolCalls. For each call, it looks up the tool name in tool.Registry, executes the tool with parsed JSON arguments via tool.Execute(ctx, args), and appends the result as a RoleTool message to the Session before continuing the conversation cycle.

What happens when the conversation exceeds the context window?

When message counts approach the ContextWindow limit configured in Options, the Run loop triggers compaction. This process rewrites the middle portion of the message history, increments rewriteVersion, and calls Session.Replace() to swap the log atomically, preserving recent context while removing older messages to maintain token limits.

How does Reasonix ensure thread safety during concurrent access?

Reasonix protects Session state using a sync.RWMutex field named mu. The Run loop acquires write locks during message appends via Add() and replacements via Replace(), while UI components acquire read locks via Snapshot() to obtain consistent copies of the conversation history without blocking the active agent processing.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →