ai-agents 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "json"
4
5
  require_relative "../../lib/agents"
5
6
  require_relative "agents_factory"
6
7
 
@@ -13,8 +14,15 @@ class ISPSupportDemo
13
14
  end
14
15
 
15
16
  # Create agents
16
- @agents = ISPSupport::AgentsFactory.create_agents
17
- @triage_agent = @agents[:triage]
17
+ agents = ISPSupport::AgentsFactory.create_agents
18
+
19
+ # Create thread-safe runner with all agents (triage first = default entry point)
20
+ @runner = Agents::Runner.with_agents(
21
+ agents[:triage],
22
+ agents[:sales],
23
+ agents[:support]
24
+ )
25
+
18
26
  @context = {}
19
27
 
20
28
  puts "🏢 Welcome to ISP Customer Support!"
@@ -31,10 +39,8 @@ class ISPSupportDemo
31
39
  break if command_result == :exit
32
40
  next if command_result == :handled || user_input.empty?
33
41
 
34
- # Determine which agent to use - either from context or triage agent
35
- current_agent = @context[:current_agent] || @triage_agent
36
-
37
- result = Agents::Runner.run(current_agent, user_input, context: @context)
42
+ # Use the runner - it automatically determines the right agent from context
43
+ result = @runner.run(user_input, context: @context)
38
44
 
39
45
  # Update our context with the returned context from Runner
40
46
  @context = result.context if result.respond_to?(:context) && result.context
@@ -50,6 +56,7 @@ class ISPSupportDemo
50
56
  def handle_command(input)
51
57
  case input.downcase
52
58
  when "exit", "quit"
59
+ dump_context_and_quit
53
60
  puts "👋 Goodbye!"
54
61
  :exit
55
62
  when "/help"
@@ -73,6 +80,21 @@ class ISPSupportDemo
73
80
  end
74
81
  end
75
82
 
83
+ def dump_context_and_quit
84
+ project_root = File.expand_path("../..", __dir__)
85
+ tmp_directory = File.join(project_root, "tmp")
86
+
87
+ # Ensure tmp directory exists
88
+ Dir.mkdir(tmp_directory) unless Dir.exist?(tmp_directory)
89
+
90
+ timestamp = Time.now.to_i
91
+ context_filename = File.join(tmp_directory, "context-#{timestamp}.json")
92
+
93
+ File.write(context_filename, JSON.pretty_generate(@context))
94
+
95
+ puts "💾 Context saved to tmp/context-#{timestamp}.json"
96
+ end
97
+
76
98
  def show_help
77
99
  puts "📋 Available Commands:"
78
100
  puts " /help - Show this help message"
@@ -6,7 +6,7 @@ module ISPSupport
6
6
  # Tool for creating checkout links for new service subscriptions.
7
7
  class CreateCheckoutTool < Agents::Tool
8
8
  description "Create a secure checkout link for a service plan"
9
- param :plan_name, String, "Name of the plan to purchase"
9
+ param :plan_name, type: "string", desc: "Name of the plan to purchase"
10
10
 
11
11
  def perform(_tool_context, plan_name:)
12
12
  session_id = SecureRandom.hex(8)
@@ -4,12 +4,26 @@ module ISPSupport
4
4
  # Tool for creating sales leads in the CRM system.
5
5
  class CreateLeadTool < Agents::Tool
6
6
  description "Create a new sales lead with customer information"
7
- param :name, String, "Customer's full name"
8
- param :email, String, "Customer's email address"
9
- param :desired_plan, String, "Plan the customer is interested in"
7
+ param :name, type: "string", desc: "Customer's full name"
8
+ param :email, type: "string", desc: "Customer's email address"
9
+ param :desired_plan, type: "string", desc: "Plan the customer is interested in"
10
10
 
11
- def perform(_tool_context, name:, email:, desired_plan:)
12
- "Lead created for #{name} (#{email}) interested in #{desired_plan} plan. Sales team will contact within 24 hours."
11
+ def perform(tool_context, name:, email:, desired_plan:)
12
+ # Store lead information in state for follow-up
13
+ tool_context.state[:lead_name] = name
14
+ tool_context.state[:lead_email] = email
15
+ tool_context.state[:desired_plan] = desired_plan
16
+ tool_context.state[:lead_created_at] = Time.now.iso8601
17
+
18
+ # Check if we have existing customer info from CRM lookup
19
+ if tool_context.state[:customer_id]
20
+ existing_customer = tool_context.state[:customer_name]
21
+ "Lead created for existing customer #{existing_customer} (#{email}) " \
22
+ "interested in upgrading to #{desired_plan} plan. Sales team will contact within 24 hours."
23
+ else
24
+ "Lead created for #{name} (#{email}) interested in #{desired_plan} plan. " \
25
+ "Sales team will contact within 24 hours."
26
+ end
13
27
  end
14
28
  end
15
29
  end
@@ -6,9 +6,9 @@ module ISPSupport
6
6
  # Tool for looking up customer information from the CRM system.
7
7
  class CrmLookupTool < Agents::Tool
8
8
  description "Look up customer account information by account ID"
9
- param :account_id, String, "Customer account ID (e.g., CUST001)"
9
+ param :account_id, type: "string", desc: "Customer account ID (e.g., CUST001)"
10
10
 
11
- def perform(_tool_context, account_id:)
11
+ def perform(tool_context, account_id:)
12
12
  data_file = File.join(__dir__, "../data/customers.json")
13
13
  return "Customer database unavailable" unless File.exist?(data_file)
14
14
 
@@ -18,6 +18,18 @@ module ISPSupport
18
18
 
19
19
  return "Customer not found" unless customer
20
20
 
21
+ # Store customer information in shared state for other tools/agents
22
+ tool_context.state[:customer_id] = account_id.upcase
23
+ tool_context.state[:customer_name] = customer["name"]
24
+ tool_context.state[:customer_email] = customer["email"]
25
+ tool_context.state[:customer_phone] = customer["phone"]
26
+ tool_context.state[:customer_address] = customer["address"]
27
+ tool_context.state[:current_plan] = customer["plan"]["name"]
28
+ tool_context.state[:account_status] = customer["account_status"]
29
+ tool_context.state[:plan_price] = customer["plan"]["price"]
30
+ tool_context.state[:next_bill_date] = customer["billing"]["next_bill_date"]
31
+ tool_context.state[:account_balance] = customer["billing"]["balance"]
32
+
21
33
  # Return the entire customer data as JSON for the agent to process
22
34
  customer.to_json
23
35
  rescue StandardError
@@ -4,7 +4,7 @@ module ISPSupport
4
4
  # Tool for searching the knowledge base documentation.
5
5
  class SearchDocsTool < Agents::Tool
6
6
  description "Search knowledge base for troubleshooting steps and solutions"
7
- param :query, String, "Search terms or description of the issue"
7
+ param :query, type: "string", desc: "Search terms or description of the issue"
8
8
 
9
9
  def perform(_tool_context, query:)
10
10
  case query.downcase
data/lib/agents/agent.rb CHANGED
@@ -13,11 +13,16 @@
13
13
  # tools: [calculator_tool, weather_tool]
14
14
  # )
15
15
  #
16
- # @example Creating an agent with dynamic instructions
16
+ # @example Creating an agent with dynamic state-aware instructions
17
17
  # agent = Agents::Agent.new(
18
18
  # name: "Support Agent",
19
19
  # instructions: ->(context) {
20
- # "You are supporting user #{context.context[:user_name]}"
20
+ # state = context.context[:state] || {}
21
+ # base = "You are a support agent."
22
+ # if state[:customer_name]
23
+ # base += " Customer: #{state[:customer_name]} (#{state[:customer_id]})"
24
+ # end
25
+ # base
21
26
  # }
22
27
  # )
23
28
  #
@@ -147,18 +152,25 @@ module Agents
147
152
  # instructions: "You are a helpful support agent"
148
153
  # )
149
154
  #
150
- # @example Dynamic instructions based on context
155
+ # @example Dynamic instructions with state awareness
151
156
  # agent = Agent.new(
152
- # name: "Support",
157
+ # name: "Sales Agent",
153
158
  # instructions: ->(context) {
154
- # user = context.context[:user]
155
- # "You are helping #{user.name}. They are a #{user.tier} customer with account #{user.id}"
159
+ # state = context.context[:state] || {}
160
+ # base = "You are a sales agent."
161
+ # if state[:customer_name] && state[:current_plan]
162
+ # base += " Customer: #{state[:customer_name]} on #{state[:current_plan]} plan."
163
+ # end
164
+ # base
156
165
  # }
157
166
  # )
158
167
  #
159
168
  # @param context [Agents::RunContext] The current execution context containing runtime data
160
169
  # @return [String, nil] The system prompt string or nil if no instructions are set
161
170
  def get_system_prompt(context)
171
+ # TODO: Add string interpolation support for instructions
172
+ # Allow instructions like "You are helping %{customer_name}" that automatically
173
+ # get state values injected from context[:state] using Ruby's % formatting
162
174
  case instructions
163
175
  when String
164
176
  instructions
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ # Thread-safe agent execution manager that provides a clean API for multi-agent conversations.
5
+ # This class is designed to be created once and reused across multiple threads safely.
6
+ #
7
+ # The key insight here is separating agent registry/configuration (this class) from
8
+ # execution state (Runner instances). This allows the same AgentRunner to be used
9
+ # concurrently without thread safety issues.
10
+ #
11
+ # ## Usage Pattern
12
+ # # Create once (typically at application startup)
13
+ # runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
14
+ #
15
+ # # Use safely from multiple threads
16
+ # result = runner.run("I need billing help") # New conversation
17
+ # result = runner.run("More help", context: context) # Continue conversation
18
+ #
19
+ # ## Thread Safety Design
20
+ # - All instance variables are frozen after initialization (immutable state)
21
+ # - Agent registry is built once and never modified
22
+ # - Each run() call creates independent execution context
23
+ # - No shared mutable state between concurrent executions
24
+ #
25
+ class AgentRunner
26
+ # Initialize with a list of agents. The first agent becomes the default entry point.
27
+ #
28
+ # @param agents [Array<Agents::Agent>] List of agents, first one is the default entry point
29
+ def initialize(agents)
30
+ raise ArgumentError, "At least one agent must be provided" if agents.empty?
31
+
32
+ @agents = agents.dup.freeze
33
+ @default_agent = agents.first
34
+
35
+ # Build simple registry from provided agents - developer controls what's available
36
+ @registry = build_registry(agents).freeze
37
+ end
38
+
39
+ # Execute a conversation turn with automatic agent selection.
40
+ # For new conversations, uses the default agent (first in the list).
41
+ # For continuing conversations, determines the appropriate agent from conversation history.
42
+ #
43
+ # @param input [String] User's message
44
+ # @param context [Hash] Conversation context (will be restored if continuing conversation)
45
+ # @param max_turns [Integer] Maximum turns before stopping (default: 10)
46
+ # @return [RunResult] Execution result with output, messages, and updated context
47
+ def run(input, context: {}, max_turns: Runner::DEFAULT_MAX_TURNS)
48
+ # Determine which agent should handle this conversation
49
+ # Uses conversation history to maintain continuity across handoffs
50
+ current_agent = determine_conversation_agent(context)
51
+
52
+ # Execute using stateless Runner - each execution is independent and thread-safe
53
+ Runner.new.run(
54
+ current_agent,
55
+ input,
56
+ context: context,
57
+ registry: @registry,
58
+ max_turns: max_turns
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ # Build agent registry from provided agents only.
65
+ # Developer explicitly controls which agents are available for handoffs.
66
+ #
67
+ # @param agents [Array<Agents::Agent>] Agents to register
68
+ # @return [Hash<String, Agents::Agent>] Registry mapping agent names to agent instances
69
+ def build_registry(agents)
70
+ registry = {}
71
+ agents.each { |agent| registry[agent.name] = agent }
72
+ registry
73
+ end
74
+
75
+ # Determine which agent should handle the current conversation.
76
+ # For new conversations (empty context), uses the default agent.
77
+ # For continuing conversations, analyzes history to find the last agent that spoke.
78
+ #
79
+ # This implements Google ADK-style session continuation logic where the system
80
+ # automatically maintains conversation continuity without requiring manual agent tracking.
81
+ #
82
+ # @param context [Hash] Conversation context with potential history
83
+ # @return [Agents::Agent] Agent that should handle this conversation turn
84
+ def determine_conversation_agent(context)
85
+ history = context[:conversation_history] || []
86
+
87
+ # For new conversations, use the default (first) agent
88
+ return @default_agent if history.empty?
89
+
90
+ # Find the last assistant message with agent attribution
91
+ # We traverse in reverse to find the most recent agent that spoke
92
+ last_agent_name = history.reverse.find do |msg|
93
+ msg[:role] == :assistant && msg[:agent_name]
94
+ end&.dig(:agent_name)
95
+
96
+ # Try to resolve from registry, fall back to default if agent not found
97
+ # This handles cases where agent names in history don't match current registry
98
+ if last_agent_name && @registry[last_agent_name]
99
+ @registry[last_agent_name]
100
+ else
101
+ @default_agent
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_context"
4
+
5
+ module Agents
6
+ # Extended chat class that inherits from RubyLLM::Chat but adds proper handoff handling.
7
+ # This solves the infinite handoff loop problem by treating handoffs as turn-ending
8
+ # operations rather than allowing auto-continuation.
9
+ class Chat < RubyLLM::Chat
10
+ # Response object that indicates a handoff occurred
11
+ class HandoffResponse
12
+ attr_reader :target_agent, :response, :handoff_message
13
+
14
+ def initialize(target_agent:, response:, handoff_message:)
15
+ @target_agent = target_agent
16
+ @response = response
17
+ @handoff_message = handoff_message
18
+ end
19
+
20
+ def tool_call?
21
+ true
22
+ end
23
+
24
+ def content
25
+ @handoff_message
26
+ end
27
+ end
28
+
29
+ def initialize(model: nil, handoff_tools: [], context_wrapper: nil, **options)
30
+ super(model: model, **options)
31
+ @handoff_tools = handoff_tools
32
+ @context_wrapper = context_wrapper
33
+
34
+ # Register handoff tools with RubyLLM for schema generation
35
+ @handoff_tools.each { |tool| with_tool(tool) }
36
+ end
37
+
38
+ # Override the problematic auto-execution method from RubyLLM::Chat
39
+ def complete(&block)
40
+ @on[:new_message]&.call
41
+ response = @provider.complete(
42
+ messages,
43
+ tools: @tools,
44
+ temperature: @temperature,
45
+ model: @model.id,
46
+ connection: @connection,
47
+ &block
48
+ )
49
+ @on[:end_message]&.call(response)
50
+
51
+ add_message(response)
52
+
53
+ if response.tool_call?
54
+ handle_tools_with_handoff_detection(response, &block)
55
+ else
56
+ response
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def handle_tools_with_handoff_detection(response, &block)
63
+ handoff_calls, regular_calls = classify_tool_calls(response.tool_calls)
64
+
65
+ if handoff_calls.any?
66
+ # Execute first handoff only
67
+ handoff_result = execute_handoff_tool(handoff_calls.first)
68
+
69
+ # Add tool result to conversation
70
+ add_tool_result(handoff_calls.first.id, handoff_result[:message])
71
+
72
+ # Return handoff response to signal agent switch (ends turn)
73
+ HandoffResponse.new(
74
+ target_agent: handoff_result[:target_agent],
75
+ response: response,
76
+ handoff_message: handoff_result[:message]
77
+ )
78
+ else
79
+ # Use RubyLLM's original tool execution for regular tools
80
+ execute_regular_tools_and_continue(regular_calls, &block)
81
+ end
82
+ end
83
+
84
+ def classify_tool_calls(tool_calls)
85
+ handoff_tool_names = @handoff_tools.map(&:name).map(&:to_s)
86
+
87
+ handoff_calls = []
88
+ regular_calls = []
89
+
90
+ tool_calls.each_value do |tool_call|
91
+ if handoff_tool_names.include?(tool_call.name)
92
+ handoff_calls << tool_call
93
+ else
94
+ regular_calls << tool_call
95
+ end
96
+ end
97
+
98
+ [handoff_calls, regular_calls]
99
+ end
100
+
101
+ def execute_handoff_tool(tool_call)
102
+ tool = @handoff_tools.find { |t| t.name.to_s == tool_call.name }
103
+ raise "Handoff tool not found: #{tool_call.name}" unless tool
104
+
105
+ # Execute the handoff tool directly with context
106
+ tool_context = ToolContext.new(run_context: @context_wrapper)
107
+ result = tool.execute(tool_context, **{}) # Handoff tools take no additional params
108
+
109
+ {
110
+ target_agent: tool.target_agent,
111
+ message: result.to_s
112
+ }
113
+ end
114
+
115
+ def execute_regular_tools_and_continue(tool_calls, &block)
116
+ # Execute each regular tool call
117
+ tool_calls.each do |tool_call|
118
+ @on[:new_message]&.call
119
+ result = execute_tool(tool_call)
120
+ message = add_tool_result(tool_call.id, result)
121
+ @on[:end_message]&.call(message)
122
+ end
123
+
124
+ # Continue conversation after tool execution
125
+ complete(&block)
126
+ end
127
+
128
+ # Reuse RubyLLM's existing tool execution logic
129
+ def execute_tool(tool_call)
130
+ tool = tools[tool_call.name.to_sym]
131
+ args = tool_call.arguments
132
+ tool.call(args)
133
+ end
134
+
135
+ def add_tool_result(tool_use_id, result)
136
+ add_message(
137
+ role: :tool,
138
+ content: result.is_a?(Hash) && result[:error] ? result[:error] : result.to_s,
139
+ tool_call_id: tool_use_id
140
+ )
141
+ end
142
+ end
143
+ end
@@ -95,18 +95,10 @@ module Agents
95
95
  @tool_description
96
96
  end
97
97
 
98
- # Handoff tools implement first-call-wins semantics to prevent infinite loops
99
- # Multiple handoff calls in a single response are ignored (like OpenAI SDK)
100
- def perform(tool_context)
101
- # First-call-wins: only set handoff if not already set
102
- if tool_context.context[:pending_handoff]
103
- return "Transfer request noted (already processing a handoff)."
104
- end
105
-
106
- # Set the handoff target
107
- tool_context.context[:pending_handoff] = @target_agent
108
-
109
- # Return a message that will be shown to the user
98
+ # Handoff tools now work with the extended Chat class for proper handoff handling
99
+ # No longer need context signaling - the Chat class detects handoffs directly
100
+ def perform(_tool_context)
101
+ # Simply return the transfer message - Chat class will handle the handoff
110
102
  "I'll transfer you to #{@target_agent.name} who can better assist you with this."
111
103
  end
112
104
 
data/lib/agents/runner.rb CHANGED
@@ -54,21 +54,33 @@ module Agents
54
54
 
55
55
  class MaxTurnsExceeded < StandardError; end
56
56
 
57
- # Convenience class method for running agents
58
- def self.run(agent, input, context: {}, max_turns: DEFAULT_MAX_TURNS)
59
- new.run(agent, input, context: context, max_turns: max_turns)
57
+ # Create a thread-safe agent runner for multi-agent conversations.
58
+ # The first agent becomes the default entry point for new conversations.
59
+ # All agents must be explicitly provided - no automatic discovery.
60
+ #
61
+ # @param agents [Array<Agents::Agent>] All agents that should be available for handoffs
62
+ # @return [AgentRunner] Thread-safe runner that can be reused across multiple conversations
63
+ #
64
+ # @example
65
+ # runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
66
+ # result = runner.run("I need help") # Uses triage_agent for new conversation
67
+ # result = runner.run("More help", context: stored_context) # Continues with appropriate agent
68
+ def self.with_agents(*agents)
69
+ AgentRunner.new(agents)
60
70
  end
61
71
 
62
- # Execute an agent with the given input and context
72
+ # Execute an agent with the given input and context.
73
+ # This is now called internally by AgentRunner and should not be used directly.
63
74
  #
64
- # @param starting_agent [Agents::Agent] The initial agent to run
75
+ # @param starting_agent [Agents::Agent] The agent to run
65
76
  # @param input [String] The user's input message
66
77
  # @param context [Hash] Shared context data accessible to all tools
78
+ # @param registry [Hash] Registry of agents for handoff resolution
67
79
  # @param max_turns [Integer] Maximum conversation turns before stopping
68
80
  # @return [RunResult] The result containing output, messages, and usage
69
- def run(starting_agent, input, context: {}, max_turns: DEFAULT_MAX_TURNS)
70
- # Determine current agent from context or use starting agent
71
- current_agent = context[:current_agent] || starting_agent
81
+ def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX_TURNS)
82
+ # The starting_agent is already determined by AgentRunner based on conversation history
83
+ current_agent = starting_agent
72
84
 
73
85
  # Create context wrapper with deep copy for thread safety
74
86
  context_copy = deep_copy_context(context)
@@ -83,44 +95,55 @@ module Agents
83
95
  current_turn += 1
84
96
  raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
85
97
 
86
- # Get response from LLM (RubyLLM handles tool execution)
87
- response = if current_turn == 1
88
- chat.ask(input)
89
- else
90
- chat.complete
91
- end
98
+ # Get response from LLM (Extended Chat handles tool execution with handoff detection)
99
+ result = if current_turn == 1
100
+ chat.ask(input)
101
+ else
102
+ chat.complete
103
+ end
104
+ response = result
92
105
 
93
- # Update usage
94
- context_wrapper.usage.add(response.usage) if response.respond_to?(:usage) && response.usage
106
+ # Check for handoff response from our extended chat
107
+ if response.is_a?(Agents::Chat::HandoffResponse)
108
+ next_agent = response.target_agent
95
109
 
96
- # Check for handoff via context (set by HandoffTool)
97
- if context_wrapper.context[:pending_handoff]
98
- next_agent = context_wrapper.context[:pending_handoff]
99
- puts "[Agents] Handoff from #{current_agent.name} to #{next_agent.name}"
110
+ # Validate that the target agent is in our registry
111
+ # This prevents handoffs to agents that weren't explicitly provided
112
+ unless registry[next_agent.name]
113
+ puts "[Agents] Warning: Handoff to unregistered agent '#{next_agent.name}', continuing with current agent"
114
+ next if response.tool_call?
115
+
116
+ next
117
+ end
100
118
 
101
119
  # Save current conversation state before switching
102
120
  save_conversation_state(chat, context_wrapper, current_agent)
103
121
 
104
- # Switch to new agent
122
+ # Switch to new agent - store agent name for persistence
105
123
  current_agent = next_agent
106
- context_wrapper.context[:current_agent] = next_agent
107
- context_wrapper.context.delete(:pending_handoff)
124
+ context_wrapper.context[:current_agent] = next_agent.name
108
125
 
109
126
  # Create new chat for new agent with restored history
110
127
  chat = create_chat(current_agent, context_wrapper)
111
128
  restore_conversation_history(chat, context_wrapper)
129
+
130
+ # Force the new agent to respond to the conversation context
131
+ # This ensures the user gets a response from the new agent
132
+ input = nil
112
133
  next
113
134
  end
114
135
 
115
- # If no tools were called, we have our final response
136
+ # If tools were called, continue the loop to let them execute
116
137
  next if response.tool_call?
117
138
 
139
+ # If no tools were called, we have our final response
140
+
118
141
  # Save final state before returning
119
142
  save_conversation_state(chat, context_wrapper, current_agent)
120
143
 
121
144
  return RunResult.new(
122
145
  output: response.content,
123
- messages: extract_messages(chat),
146
+ messages: extract_messages(chat, current_agent),
124
147
  usage: context_wrapper.usage,
125
148
  context: context_wrapper.context
126
149
  )
@@ -131,7 +154,7 @@ module Agents
131
154
 
132
155
  RunResult.new(
133
156
  output: "Conversation ended: #{e.message}",
134
- messages: chat ? extract_messages(chat) : [],
157
+ messages: chat ? extract_messages(chat, current_agent) : [],
135
158
  usage: context_wrapper.usage,
136
159
  error: e,
137
160
  context: context_wrapper.context
@@ -142,7 +165,7 @@ module Agents
142
165
 
143
166
  RunResult.new(
144
167
  output: nil,
145
- messages: chat ? extract_messages(chat) : [],
168
+ messages: chat ? extract_messages(chat, current_agent) : [],
146
169
  usage: context_wrapper.usage,
147
170
  error: e,
148
171
  context: context_wrapper.context
@@ -185,11 +208,11 @@ module Agents
185
208
 
186
209
  def save_conversation_state(chat, context_wrapper, current_agent)
187
210
  # Extract messages from chat
188
- messages = extract_messages(chat)
211
+ messages = extract_messages(chat, current_agent)
189
212
 
190
213
  # Update context with latest state
191
214
  context_wrapper.context[:conversation_history] = messages
192
- context_wrapper.context[:current_agent] = current_agent
215
+ context_wrapper.context[:current_agent] = current_agent.name
193
216
  context_wrapper.context[:turn_count] = (context_wrapper.context[:turn_count] || 0) + 1
194
217
  context_wrapper.context[:last_updated] = Time.now
195
218
 
@@ -203,19 +226,26 @@ module Agents
203
226
  # Get system prompt (may be dynamic)
204
227
  system_prompt = agent.get_system_prompt(context_wrapper)
205
228
 
206
- # Wrap tools with context for thread-safe execution
207
- wrapped_tools = agent.all_tools.map do |tool|
208
- ToolWrapper.new(tool, context_wrapper)
209
- end
229
+ # Separate handoff tools from regular tools
230
+ handoff_tools = agent.handoff_agents.map { |target_agent| HandoffTool.new(target_agent) }
231
+ regular_tools = agent.tools
232
+
233
+ # Only wrap regular tools - handoff tools will be handled directly by Chat
234
+ wrapped_regular_tools = regular_tools.map { |tool| ToolWrapper.new(tool, context_wrapper) }
235
+
236
+ # Create extended chat with handoff awareness and context
237
+ chat = Agents::Chat.new(
238
+ model: agent.model,
239
+ handoff_tools: handoff_tools, # Direct tools, no wrapper
240
+ context_wrapper: context_wrapper # Pass context directly
241
+ )
210
242
 
211
- # Create chat with proper RubyLLM API
212
- chat = RubyLLM.chat(model: agent.model)
213
243
  chat.with_instructions(system_prompt) if system_prompt
214
- chat.with_tools(*wrapped_tools) if wrapped_tools.any?
244
+ chat.with_tools(*wrapped_regular_tools) if wrapped_regular_tools.any?
215
245
  chat
216
246
  end
217
247
 
218
- def extract_messages(chat)
248
+ def extract_messages(chat, current_agent)
219
249
  return [] unless chat.respond_to?(:messages)
220
250
 
221
251
  chat.messages.filter_map do |msg|
@@ -223,10 +253,16 @@ module Agents
223
253
  next unless %i[user assistant].include?(msg.role)
224
254
  next unless msg.content && !msg.content.strip.empty?
225
255
 
226
- {
256
+ message = {
227
257
  role: msg.role,
228
258
  content: msg.content
229
259
  }
260
+
261
+ # Add agent attribution for assistant messages to enable conversation continuity
262
+ # This allows AgentRunner to determine which agent should continue the conversation
263
+ message[:agent_name] = current_agent.name if msg.role == :assistant && current_agent
264
+
265
+ message
230
266
  end
231
267
  end
232
268
  end