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.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +146 -137
- data/README.md +42 -20
- data/examples/isp-support/agents_factory.rb +66 -10
- data/examples/isp-support/interactive.rb +28 -6
- data/examples/isp-support/tools/create_checkout_tool.rb +1 -1
- data/examples/isp-support/tools/create_lead_tool.rb +19 -5
- data/examples/isp-support/tools/crm_lookup_tool.rb +14 -2
- data/examples/isp-support/tools/search_docs_tool.rb +1 -1
- data/lib/agents/agent.rb +18 -6
- data/lib/agents/agent_runner.rb +105 -0
- data/lib/agents/chat.rb +143 -0
- data/lib/agents/handoff.rb +4 -12
- data/lib/agents/runner.rb +74 -38
- data/lib/agents/tool.rb +0 -81
- data/lib/agents/tool_context.rb +36 -0
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +14 -0
- metadata +4 -2
@@ -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
|
-
|
17
|
-
|
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
|
-
#
|
35
|
-
|
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,
|
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,
|
8
|
-
param :email,
|
9
|
-
param :desired_plan,
|
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(
|
12
|
-
|
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,
|
9
|
+
param :account_id, type: "string", desc: "Customer account ID (e.g., CUST001)"
|
10
10
|
|
11
|
-
def perform(
|
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,
|
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
|
-
#
|
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
|
155
|
+
# @example Dynamic instructions with state awareness
|
151
156
|
# agent = Agent.new(
|
152
|
-
# name: "
|
157
|
+
# name: "Sales Agent",
|
153
158
|
# instructions: ->(context) {
|
154
|
-
#
|
155
|
-
# "You are
|
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
|
data/lib/agents/chat.rb
ADDED
@@ -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
|
data/lib/agents/handoff.rb
CHANGED
@@ -95,18 +95,10 @@ module Agents
|
|
95
95
|
@tool_description
|
96
96
|
end
|
97
97
|
|
98
|
-
# Handoff tools
|
99
|
-
#
|
100
|
-
def perform(
|
101
|
-
#
|
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
|
-
#
|
58
|
-
|
59
|
-
|
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
|
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
|
-
#
|
71
|
-
current_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 (
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
#
|
94
|
-
|
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
|
-
|
97
|
-
|
98
|
-
next_agent
|
99
|
-
|
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
|
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
|
-
#
|
207
|
-
|
208
|
-
|
209
|
-
|
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(*
|
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
|