Class: Agents::Runner

Inherits:
Object
  • Object
show all
Defined in:
lib/agents/runner.rb

Overview

The execution engine that orchestrates conversations between users and agents. Runner manages the conversation flow, handles tool execution through RubyLLM, coordinates handoffs between agents, and ensures thread-safe operation.

The Runner follows a turn-based execution model where each turn consists of:

  1. Sending a message to the LLM with current context

  2. Receiving a response that may include tool calls

  3. Executing tools and getting results (handled by RubyLLM)

  4. Checking for agent handoffs

  5. Continuing until no more tools are called

## Thread Safety The Runner ensures thread safety by:

  • Creating new context wrappers for each execution

  • Using tool wrappers that pass context through parameters

  • Never storing execution state in shared variables

## Integration with RubyLLM We leverage RubyLLM for LLM communication and tool execution while maintaining our own context management and handoff logic.

Examples:

Simple conversation

agent = Agents::Agent.new(
  name: "Assistant",
  instructions: "You are a helpful assistant",
  tools: [weather_tool]
)

result = Agents::Runner.run(agent, "What's the weather?")
puts result.output
# => "Let me check the weather for you..."

Conversation with context

result = Agents::Runner.run(
  support_agent,
  "I need help with my order",
  context: { user_id: 123, order_id: 456 }
)

Multi-agent handoff

triage = Agents::Agent.new(
  name: "Triage",
  instructions: "Route users to the right specialist",
  handoff_agents: [billing_agent, tech_agent]
)

result = Agents::Runner.run(triage, "I can't pay my bill")
# Triage agent will handoff to billing_agent

Defined Under Namespace

Classes: MaxTurnsExceeded

Constant Summary collapse

DEFAULT_MAX_TURNS =
10

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.with_agents(*agents) ⇒ AgentRunner

Create a thread-safe agent runner for multi-agent conversations. The first agent becomes the default entry point for new conversations. All agents must be explicitly provided - no automatic discovery.

Examples:

runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
result = runner.run("I need help")  # Uses triage_agent for new conversation
result = runner.run("More help", context: stored_context)  # Continues with appropriate agent

Parameters:

  • agents (Array<Agents::Agent>)

    All agents that should be available for handoffs

Returns:

  • (AgentRunner)

    Thread-safe runner that can be reused across multiple conversations



70
71
72
# File 'lib/agents/runner.rb', line 70

def self.with_agents(*agents)
  AgentRunner.new(agents)
end

Instance Method Details

#run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX_TURNS, callbacks: {}) ⇒ RunResult

Execute an agent with the given input and context. This is now called internally by AgentRunner and should not be used directly.

Parameters:

  • starting_agent (Agents::Agent)

    The agent to run

  • input (String)

    The user’s input message

  • context (Hash) (defaults to: {})

    Shared context data accessible to all tools

  • registry (Hash) (defaults to: {})

    Registry of agents for handoff resolution

  • max_turns (Integer) (defaults to: DEFAULT_MAX_TURNS)

    Maximum conversation turns before stopping

  • callbacks (Hash) (defaults to: {})

    Optional callbacks for real-time event notifications

Returns:

  • (RunResult)

    The result containing output, messages, and usage



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/agents/runner.rb', line 84

def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX_TURNS, callbacks: {})
  # The starting_agent is already determined by AgentRunner based on conversation history
  current_agent = starting_agent

  # Create context wrapper with deep copy for thread safety
  context_copy = deep_copy_context(context)
  context_wrapper = RunContext.new(context_copy, callbacks: callbacks)
  current_turn = 0

  # Create chat and restore conversation history
  chat = create_chat(current_agent, context_wrapper)
  restore_conversation_history(chat, context_wrapper)

  loop do
    current_turn += 1
    raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns

    # Get response from LLM (Extended Chat handles tool execution with handoff detection)
    result = if current_turn == 1
               # Emit agent thinking event for initial message
               context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input)
               chat.ask(input)
             else
               # Emit agent thinking event for continuation
               context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, "(continuing conversation)")
               chat.complete
             end
    response = result

    # Check for handoff via RubyLLM's halt mechanism
    if response.is_a?(RubyLLM::Tool::Halt) && context_wrapper.context[:pending_handoff]
      handoff_info = context_wrapper.context.delete(:pending_handoff)
      next_agent = handoff_info[:target_agent]

      # Validate that the target agent is in our registry
      # This prevents handoffs to agents that weren't explicitly provided
      unless registry[next_agent.name]
        puts "[Agents] Warning: Handoff to unregistered agent '#{next_agent.name}', continuing with current agent"
        # Return the halt content as the final response
        save_conversation_state(chat, context_wrapper, current_agent)
        return RunResult.new(
          output: response.content,
          messages: MessageExtractor.extract_messages(chat, current_agent),
          usage: context_wrapper.usage,
          context: context_wrapper.context
        )
      end

      # Save current conversation state before switching
      save_conversation_state(chat, context_wrapper, current_agent)

      # Emit agent handoff event
      context_wrapper.callback_manager.emit_agent_handoff(current_agent.name, next_agent.name, "handoff")

      # Switch to new agent - store agent name for persistence
      current_agent = next_agent
      context_wrapper.context[:current_agent] = next_agent.name

      # Create new chat for new agent with restored history
      chat = create_chat(current_agent, context_wrapper)
      restore_conversation_history(chat, context_wrapper)

      # Force the new agent to respond to the conversation context
      # This ensures the user gets a response from the new agent
      input = nil
      next
    end

    # Handle non-handoff halts - return the halt content as final response
    if response.is_a?(RubyLLM::Tool::Halt)
      save_conversation_state(chat, context_wrapper, current_agent)
      return RunResult.new(
        output: response.content,
        messages: MessageExtractor.extract_messages(chat, current_agent),
        usage: context_wrapper.usage,
        context: context_wrapper.context
      )
    end

    # If tools were called, continue the loop to let them execute
    next if response.tool_call?

    # If no tools were called, we have our final response

    # Save final state before returning
    save_conversation_state(chat, context_wrapper, current_agent)

    return RunResult.new(
      output: response.content,
      messages: MessageExtractor.extract_messages(chat, current_agent),
      usage: context_wrapper.usage,
      context: context_wrapper.context
    )
  end
rescue MaxTurnsExceeded => e
  # Save state even on error
  save_conversation_state(chat, context_wrapper, current_agent) if chat

  RunResult.new(
    output: "Conversation ended: #{e.message}",
    messages: chat ? MessageExtractor.extract_messages(chat, current_agent) : [],
    usage: context_wrapper.usage,
    error: e,
    context: context_wrapper.context
  )
rescue StandardError => e
  # Save state even on error
  save_conversation_state(chat, context_wrapper, current_agent) if chat

  RunResult.new(
    output: nil,
    messages: chat ? MessageExtractor.extract_messages(chat, current_agent) : [],
    usage: context_wrapper.usage,
    error: e,
    context: context_wrapper.context
  )
end