Chatwoot AI Agents Ruby SDK

repository·main·Indexed 41 days ago

https://github.com/chatwoot/ai-agents

A provider-agnostic Ruby SDK for building multi-agent AI workflows. It features an agent-as-tool architecture, hub-and-spoke routing, and seamless agent-to-agent handoffs. The SDK supports shared context management, custom tool implementation via Agents::Tool, and integration with providers like OpenAI, Azure, Anthropic, and Gemini. It includes a thread-safe execution model using Agents::Runner and supports OpenTelemetry instrumentation for tracing.

Tokens
27K
Snippets
72
Records
94
Agent score
51%

What's inside Chatwoot AI Agents

  1. Core concepts of the AI Agents library

    main

    The AI Agents library provides a framework for building multi-agent AI workflows using several interconnected abstractions:

    • Agents: Immutable, thread-safe AI assistants defined by specific roles and capabilities.
    • AgentRunner: A thread-safe execution manager responsible for managing multi-agent conversations.
    • Context: A mechanism for serializable state management that persists across different agent interactions.
    • Handoffs: A tool-based mechanism used to facilitate seamless transitions between different agents.
    • Tools: Stateless extensions that allow agents to integrate with external systems.
    • Callbacks: A system for receiving real-time notifications regarding agent reasoning (thinking), tool execution, and handoffs.
    • AgentTool: A specialized mechanism for agent-to-agent collaboration that does not require a full conversation handoff.
  2. Monitor agent execution with Real-time Callbacks

    main

    The AI Agents SDK provides real-time callbacks to monitor agent execution stages. These are useful for building live UIs, streaming status updates, or collecting metrics. Callbacks are registered on an Agents::Runner instance using chainable methods.

    runner = Agents::Runner.with_agents(triage, support)
      .on_run_start { |agent, input, ctx| puts "Starting: #{agent}" }
      .on_run_complete { |agent, result, ctx| puts "Completed: #{agent}" }
      .on_agent_complete { |agent, result, error, ctx| puts "Agent done: #{agent}" }
      .on_agent_thinking { |agent, input| puts "#{agent} thinking..." }
      .on_tool_start { |tool, args| puts "Using #{tool}" }
      .on_tool_complete { |tool, result| puts "#{tool} completed" }
      .on_agent_handoff { |from, to, reason| puts "#{from} → #{to}" }
  3. Understand the Agent-as-Tool pattern

    main

    The Agent-as-Tool pattern is used for multi-agent collaboration where specialized agents work behind the scenes to assist an orchestrator without the user being aware of the multiple agents involved.

    Handoffs vs Agent-as-Tool

    FeatureHandoffsAgent-as-Tool
    User ExperienceUser-visible conversation transfer ("Let me transfer you to billing")Invisible to user ("Let me check that for you")
    ContextFull context is sharedLimited context (state only)
    ControlThe new agent takes over the conversationControl returns to the caller (orchestrator)

    Use this pattern when you need specialized processing, behind-the-scenes coordination, complex multi-step workflows, or a modular architecture with a clean separation of concerns.

  4. How multi-agent handoffs work

    main

    Handoffs allow agents to transfer control to other specialized agents based on the conversation context. This process is transparent to the user. You define these relationships using agent.register_handoffs(target_agent1, target_agent2, ...).

    Common patterns include:

    • Hub-and-Spoke: A central triage agent routes to specialists, and specialists route back to triage.
    • Direct Routing: Agents route directly to one another.
    # Hub-and-spoke pattern
    triage = Agents::Agent.new(name: "Triage")
    billing = Agents::Agent.new(name: "Billing")
    support = Agents::Agent.new(name: "Support")
    
    triage.register_handoffs(billing, support)
    billing.register_handoffs(triage)
    support.register_handoffs(triage)
    
    runner = Agents::Runner.with_agents(triage, billing, support)
  5. Design stateless tools for agent execution

    main

    To ensure reliability and compatibility with persistence mechanisms, tools should be designed to be stateless. Instead of using instance variables to store state, tools should rely on the tool_context to access and modify data.

    Best Practices

    • Accessing Configuration: Retrieve configuration (like database connections) from tool_context.context rather than storing it in the tool instance.
    • Storing Tool State: If a tool needs to track progress (e.g., a list of processed files), store that information within a specific key in the tool_context.context (e.g., tool_context.context[:tool_name]).
    # Stateless Tool Example
    class DatabaseTool < Agents::Tool
      name "query_database"
      description "Query the application database"
      param :query, type: "string", desc: "SQL query to execute"
      
      def perform(tool_context, query:)
        # Get database connection from context, not instance variables
        db_config = tool_context.context[:database_config]
        connection = establish_connection(db_config)
        
        connection.execute(query)
      end
      
      private
      
      def establish_connection(config)
        ActiveRecord::Base.establish_connection(config)
      end
    end
    
    # Tool State Persistence Example
    class FileProcessorTool < Agents::Tool
      name "process_file"
      description "Process uploaded files"
      param :file_path, type: "string", desc: "Path to file"
      
      def perform(tool_context, file_path:)
        # Initialize tool state in context if needed
        tool_context.context[:file_processor] ||= {
          processed_files: [],
          processing_status: {}
        }
        
        result = process_file(file_path)
        
        # Update tool state in context
        tool_context.context[:file_processor][:processed_files] << file_path
        tool_context.context[:file_processor][:processing_status][file_path] = result[:status]
        
        result
      end
    end
  6. What are Tools in Ruby Agents

    main
    Tools are components that allow agents to interact with the outside world, extending their capabilities beyond the capabilities of the language model. They can range from simple logic (like a calculator) to complex external API integrations. To ensure stability in concurrent environments, tools in this library are designed to be thread-safe and stateless.
  7. Manage Conversation Flow and Context in AgentRunner

    main

    The Agents::Runner manages the lifecycle of a multi-agent conversation:

    • Entry Points: The first agent passed to Agents::Runner.with_agents(...) is the default starting agent for all new conversations.
    • Context Preservation: To continue a conversation after a handoff, you must pass the context from the previous result into the next .run call. The AgentRunner uses this context to automatically detect which agent is currently active.
    • Thread Safety: The AgentRunner is thread-safe and can be reused across multiple concurrent conversations/threads.
    # Start conversation
    result1 = runner.run("My name is John and I have a billing question")
    
    # Continue conversation by passing the context
    result2 = runner.run("What payment methods do you accept?", context: result1.context)
    # The runner automatically selects the correct agent based on result1.context
  8. How the ISP Customer Support multi-agent architecture works

    main

    The ISP Customer Support system implements a hub-and-spoke pattern to manage customer requests.

    Core Components

    1. Triage Agent (The Hub): Acts as the entry point. It greets customers, analyzes their requests, and routes them to the appropriate specialist.
    2. Specialist Agents (The Spokes):
      • Sales Agent: Handles new customer acquisition, plan upgrades, changes, and billing questions.
      • Support Agent: Handles technical troubleshooting, account information lookups, and escalations.

    Routing Logic

    Instead of agents bouncing requests back and forth, the system uses task-focused design. For example, the Support Agent is designed to handle both account lookups and technical troubleshooting to prevent unnecessary handoffs between agents.

  9. How Context architecture works

    main

    Context is a serializable state management system that preserves information across agent interactions, tool executions, and handoffs. It uses a hash-based structure to enable conversation continuity and cross-session persistence. The architecture consists of three layers:

    1. User Context (Serializable): The primary hash that persists across sessions. It typically contains user_id, conversation_history, current_agent, and a state hash for persistent data.
    2. RunContext (Execution Wrapper): A wrapper around the User Context that provides thread safety via deep copying and ensures execution isolation.
    3. ToolContext (Tool-Specific View): A wrapper around the RunContext passed to tools during execution. It provides tools with controlled read/write access to the shared context hash and a dedicated space for persistent tool state.

    By using ToolContext, tools can remain stateless and thread-safe because they do not need to store execution-specific state in their instance variables.

    # Example User Context structure
    context = {
      user_id: 123,
      conversation_history: [...],
      current_agent: "Billing",
      state: { customer_tier: "premium" }
    }
  10. How the Two-Tier Execution Model works

    main

    The library separates long-lived configuration from short-lived execution using two distinct components:

    1. AgentRunner (Long-lived): A thread-safe manager created once and reused across multiple conversations. It maintains an immutable agent registry, defines the entry point for conversations, and determines conversation continuity.
    2. Runner (Per-request): A stateless execution engine created for every conversation turn. It handles LLM communication, tool execution, and manages the RunContext during that specific execution. It is designed to be garbage-collected immediately after use.

    This separation ensures that the system is scalable and can handle concurrent conversations without state contamination.

  11. How AgentTool enables agent-to-agent collaboration

    main

    The AgentTool class allows you to wrap an Agent as a callable tool. This enables a pattern of agent-to-agent collaboration where a main orchestrator agent can call specialized agents to perform background tasks without transferring the user-facing conversation control.

    Key Differences from Handoffs

    AspectAgentToolHandoff
    PurposeBehind-the-scenes collaborationUser-facing conversation transfer
    ContextIsolated (state only)Full conversation history
    ControlReturns to callerTransfers to target
    ConstraintsLimited turns, no handoffsFull agent capabilities
    Use CaseInternal processingConversation routing

    Safety Constraints

    To prevent infinite loops and maintain control, AgentTool enforces several constraints on the wrapped agent:

    • No Handoffs: The wrapped agent receives an empty registry, meaning it cannot trigger handoffs.
    • Limited Turns: A maximum of 3 turns is allowed to prevent infinite loops.
    • Context Isolation: The wrapped agent only receives the state object, not the full conversation_history.
    • Return Control: The execution flow always returns to the calling agent after the tool finishes.
    # Example of an orchestrator using specialized agents as tools
    research_agent = Agent.new(
      name: "Research Agent",
      instructions: "Research topics and extract key information"
    )
    
    analysis_agent = Agent.new(
      name: "Analysis Agent",
      instructions: "Analyze data and provide insights"
    )
    
    orchestrator = Agent.new(
      name: "Orchestrator",
      instructions: "Coordinate research and analysis",
      tools: [
        research_agent.as_tool(
          name: "research_topic",
          description: "Research a specific topic"
        ),
        analysis_agent.as_tool(
          name: "analyze_data",
          description: "Analyze research findings"
        )
      ]
    )
  12. Manage and persist conversation context

    main

    The Agents::Runner returns a result object containing a context hash. This context is fully serializable and tracks the conversation_history and the current_agent (stored as a string name). To resume a conversation later, pass the serialized context back into the run method.

    # Start conversation
    result = runner.run("I need billing help")
    context = result.context
    
    # Serialize for storage
    json_context = JSON.dump(context)
    
    # Later: Restore and continue
    restored_context = JSON.parse(json_context, symbolize_names: true)
    result = runner.run("Actually, I need technical support too", context: restored_context)