ai-agents 0.1.2 → 0.2.0
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/.claude/commands/bump-version.md +44 -0
- data/CHANGELOG.md +35 -0
- data/CLAUDE.md +59 -15
- data/README.md +29 -106
- data/docs/Gemfile +14 -0
- data/docs/Gemfile.lock +183 -0
- data/docs/_config.yml +53 -0
- data/docs/_sass/color_schemes/ruby.scss +72 -0
- data/docs/_sass/custom/custom.scss +93 -0
- data/docs/architecture.md +353 -0
- data/docs/assets/fonts/InterVariable.woff2 +0 -0
- data/docs/concepts/agent-tool.md +166 -0
- data/docs/concepts/agents.md +43 -0
- data/docs/concepts/callbacks.md +42 -0
- data/docs/concepts/context.md +110 -0
- data/docs/concepts/handoffs.md +81 -0
- data/docs/concepts/runner.md +87 -0
- data/docs/concepts/tools.md +62 -0
- data/docs/concepts.md +22 -0
- data/docs/guides/agent-as-tool-pattern.md +242 -0
- data/docs/guides/multi-agent-systems.md +261 -0
- data/docs/guides/rails-integration.md +440 -0
- data/docs/guides/state-persistence.md +451 -0
- data/docs/guides.md +18 -0
- data/docs/index.md +97 -0
- data/examples/collaborative-copilot/README.md +169 -0
- data/examples/collaborative-copilot/agents/analysis_agent.rb +48 -0
- data/examples/collaborative-copilot/agents/answer_suggestion_agent.rb +50 -0
- data/examples/collaborative-copilot/agents/copilot_orchestrator.rb +85 -0
- data/examples/collaborative-copilot/agents/integrations_agent.rb +58 -0
- data/examples/collaborative-copilot/agents/research_agent.rb +52 -0
- data/examples/collaborative-copilot/data/contacts.json +47 -0
- data/examples/collaborative-copilot/data/conversations.json +170 -0
- data/examples/collaborative-copilot/data/knowledge_base.json +58 -0
- data/examples/collaborative-copilot/data/linear_issues.json +83 -0
- data/examples/collaborative-copilot/data/stripe_billing.json +71 -0
- data/examples/collaborative-copilot/interactive.rb +90 -0
- data/examples/collaborative-copilot/tools/create_linear_ticket_tool.rb +58 -0
- data/examples/collaborative-copilot/tools/get_article_tool.rb +41 -0
- data/examples/collaborative-copilot/tools/get_contact_tool.rb +51 -0
- data/examples/collaborative-copilot/tools/get_conversation_tool.rb +53 -0
- data/examples/collaborative-copilot/tools/get_stripe_billing_tool.rb +44 -0
- data/examples/collaborative-copilot/tools/search_contacts_tool.rb +57 -0
- data/examples/collaborative-copilot/tools/search_conversations_tool.rb +54 -0
- data/examples/collaborative-copilot/tools/search_knowledge_base_tool.rb +55 -0
- data/examples/collaborative-copilot/tools/search_linear_issues_tool.rb +60 -0
- data/examples/isp-support/interactive.rb +43 -4
- data/lib/agents/agent.rb +34 -0
- data/lib/agents/agent_runner.rb +66 -1
- data/lib/agents/agent_tool.rb +113 -0
- data/lib/agents/callback_manager.rb +54 -0
- data/lib/agents/handoff.rb +8 -34
- data/lib/agents/message_extractor.rb +82 -0
- data/lib/agents/run_context.rb +5 -2
- data/lib/agents/runner.rb +16 -27
- data/lib/agents/tool_wrapper.rb +11 -1
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +3 -0
- metadata +48 -1
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module Copilot
|
6
|
+
# Tool for searching knowledge base articles and documentation
|
7
|
+
class SearchKnowledgeBaseTool < Agents::Tool
|
8
|
+
description "Search help documentation and knowledge base for solutions"
|
9
|
+
param :query, type: "string", desc: "Search terms or keywords to find relevant articles"
|
10
|
+
param :category, type: "string",
|
11
|
+
desc: "Optional: filter by category (troubleshooting, account, development, billing)", required: false
|
12
|
+
|
13
|
+
def perform(_tool_context, query:, category: nil)
|
14
|
+
data_file = File.join(__dir__, "../data/knowledge_base.json")
|
15
|
+
return "Knowledge base unavailable" unless File.exist?(data_file)
|
16
|
+
|
17
|
+
begin
|
18
|
+
kb_data = JSON.parse(File.read(data_file))
|
19
|
+
articles = kb_data["articles"]
|
20
|
+
query_text = query.downcase
|
21
|
+
matches = []
|
22
|
+
|
23
|
+
articles.each do |article_id, article|
|
24
|
+
# Skip if category filter is specified and doesn't match
|
25
|
+
next if category && article["category"] != category.downcase
|
26
|
+
|
27
|
+
# Simple keyword matching
|
28
|
+
text_to_search = [
|
29
|
+
article["title"],
|
30
|
+
article["tags"].join(" "),
|
31
|
+
article["content"]
|
32
|
+
].join(" ").downcase
|
33
|
+
|
34
|
+
next unless text_to_search.include?(query_text)
|
35
|
+
|
36
|
+
matches << {
|
37
|
+
article_id: article_id,
|
38
|
+
title: article["title"],
|
39
|
+
category: article["category"],
|
40
|
+
tags: article["tags"],
|
41
|
+
content_preview: "#{article["content"][0...200]}..."
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
if matches.empty?
|
46
|
+
"No knowledge base articles found for: #{query}"
|
47
|
+
else
|
48
|
+
JSON.pretty_generate({ query: query, articles: matches })
|
49
|
+
end
|
50
|
+
rescue StandardError => e
|
51
|
+
"Error searching knowledge base: #{e.message}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module Copilot
|
6
|
+
# Tool for searching Linear issues for development context and bug reports
|
7
|
+
class SearchLinearIssuesTool < Agents::Tool
|
8
|
+
description "Search Linear issues for bug reports, feature requests, and development context"
|
9
|
+
param :query, type: "string", desc: "Search terms (keywords, error messages, or feature descriptions)"
|
10
|
+
param :status, type: "string", desc: "Optional: filter by status (backlog, in_progress, completed, resolved)",
|
11
|
+
required: false
|
12
|
+
param :priority, type: "string", desc: "Optional: filter by priority (low, medium, high)", required: false
|
13
|
+
|
14
|
+
def perform(_tool_context, query:, status: nil, priority: nil)
|
15
|
+
data_file = File.join(__dir__, "../data/linear_issues.json")
|
16
|
+
return "Linear issues database unavailable" unless File.exist__(data_file)
|
17
|
+
|
18
|
+
begin
|
19
|
+
linear_data = JSON.parse(File.read(data_file))
|
20
|
+
issues = linear_data["issues"]
|
21
|
+
query_text = query.downcase
|
22
|
+
matches = []
|
23
|
+
|
24
|
+
issues.each do |issue_id, issue|
|
25
|
+
# Apply filters
|
26
|
+
next if status && issue["status"] != status.downcase
|
27
|
+
next if priority && issue["priority"] != priority.downcase
|
28
|
+
|
29
|
+
# Simple keyword matching
|
30
|
+
text_to_search = [
|
31
|
+
issue["title"],
|
32
|
+
issue["description"],
|
33
|
+
issue["labels"].join(" "),
|
34
|
+
issue["resolution"]
|
35
|
+
].compact.join(" ").downcase
|
36
|
+
|
37
|
+
next unless text_to_search.include?(query_text)
|
38
|
+
|
39
|
+
matches << {
|
40
|
+
issue_id: issue_id,
|
41
|
+
title: issue["title"],
|
42
|
+
status: issue["status"],
|
43
|
+
priority: issue["priority"],
|
44
|
+
labels: issue["labels"],
|
45
|
+
description: "#{issue["description"][0...200]}...",
|
46
|
+
resolution: issue["resolution"]
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
if matches.empty?
|
51
|
+
"No Linear issues found for: #{query}"
|
52
|
+
else
|
53
|
+
JSON.pretty_generate({ query: query, issues: matches })
|
54
|
+
end
|
55
|
+
rescue StandardError => e
|
56
|
+
"Error searching Linear issues: #{e.message}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -14,16 +14,20 @@ class ISPSupportDemo
|
|
14
14
|
end
|
15
15
|
|
16
16
|
# Create agents
|
17
|
-
agents = ISPSupport::AgentsFactory.create_agents
|
17
|
+
@agents = ISPSupport::AgentsFactory.create_agents
|
18
18
|
|
19
19
|
# Create thread-safe runner with all agents (triage first = default entry point)
|
20
20
|
@runner = Agents::Runner.with_agents(
|
21
|
-
agents[:triage],
|
22
|
-
agents[:sales],
|
23
|
-
agents[:support]
|
21
|
+
@agents[:triage],
|
22
|
+
@agents[:sales],
|
23
|
+
@agents[:support]
|
24
24
|
)
|
25
25
|
|
26
|
+
# Setup real-time callbacks for UI feedback
|
27
|
+
setup_callbacks
|
28
|
+
|
26
29
|
@context = {}
|
30
|
+
@current_status = ""
|
27
31
|
|
28
32
|
puts "🏢 Welcome to ISP Customer Support!"
|
29
33
|
puts "Type '/help' for commands or 'exit' to quit."
|
@@ -39,12 +43,18 @@ class ISPSupportDemo
|
|
39
43
|
break if command_result == :exit
|
40
44
|
next if command_result == :handled || user_input.empty?
|
41
45
|
|
46
|
+
# Clear any previous status and show agent is working
|
47
|
+
clear_status_line
|
48
|
+
print "🤖 Processing..."
|
49
|
+
|
42
50
|
# Use the runner - it automatically determines the right agent from context
|
43
51
|
result = @runner.run(user_input, context: @context)
|
44
52
|
|
45
53
|
# Update our context with the returned context from Runner
|
46
54
|
@context = result.context if result.respond_to?(:context) && result.context
|
47
55
|
|
56
|
+
# Clear status and show response
|
57
|
+
clear_status_line
|
48
58
|
puts "🤖 #{result.output || "[No output]"}"
|
49
59
|
|
50
60
|
puts
|
@@ -53,6 +63,35 @@ class ISPSupportDemo
|
|
53
63
|
|
54
64
|
private
|
55
65
|
|
66
|
+
def setup_callbacks
|
67
|
+
@runner.on_agent_thinking do |agent_name, _input|
|
68
|
+
update_status("🧠 #{agent_name} is thinking...")
|
69
|
+
end
|
70
|
+
|
71
|
+
@runner.on_tool_start do |tool_name, _args|
|
72
|
+
update_status("🔧 Using #{tool_name}...")
|
73
|
+
end
|
74
|
+
|
75
|
+
@runner.on_tool_complete do |tool_name, _result|
|
76
|
+
update_status("✅ #{tool_name} completed")
|
77
|
+
end
|
78
|
+
|
79
|
+
@runner.on_agent_handoff do |from_agent, to_agent, _reason|
|
80
|
+
update_status("🔄 Handoff: #{from_agent} → #{to_agent}")
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def update_status(message)
|
85
|
+
clear_status_line
|
86
|
+
print message
|
87
|
+
$stdout.flush
|
88
|
+
end
|
89
|
+
|
90
|
+
def clear_status_line
|
91
|
+
print "\r#{" " * 80}\r" # Clear the current line
|
92
|
+
$stdout.flush
|
93
|
+
end
|
94
|
+
|
56
95
|
def handle_command(input)
|
57
96
|
case input.downcase
|
58
97
|
when "exit", "quit"
|
data/lib/agents/agent.rb
CHANGED
@@ -178,5 +178,39 @@ module Agents
|
|
178
178
|
instructions.call(context)
|
179
179
|
end
|
180
180
|
end
|
181
|
+
|
182
|
+
# Transform this agent into a tool, callable by other agents.
|
183
|
+
# This enables agent-to-agent collaboration without conversation handoffs.
|
184
|
+
#
|
185
|
+
# Agent-as-tool is different from handoffs in two key ways:
|
186
|
+
# 1. The wrapped agent receives generated input, not conversation history
|
187
|
+
# 2. The wrapped agent returns a result to the calling agent, rather than taking over
|
188
|
+
#
|
189
|
+
# @param name [String, nil] Override the tool name (defaults to snake_case agent name)
|
190
|
+
# @param description [String, nil] Override the tool description
|
191
|
+
# @param output_extractor [Proc, nil] Custom proc to extract/transform the agent's output
|
192
|
+
# @param params [Hash] Additional parameter definitions for the tool
|
193
|
+
# @return [Agents::AgentTool] A tool that wraps this agent
|
194
|
+
#
|
195
|
+
# @example Basic agent-as-tool
|
196
|
+
# research_agent = Agent.new(name: "Researcher", instructions: "Research topics")
|
197
|
+
# research_tool = research_agent.as_tool(
|
198
|
+
# name: "research_topic",
|
199
|
+
# description: "Research a topic using company knowledge base"
|
200
|
+
# )
|
201
|
+
#
|
202
|
+
# @example Custom output extraction
|
203
|
+
# analyzer_tool = analyzer_agent.as_tool(
|
204
|
+
# output_extractor: ->(result) { result.context[:extracted_data]&.to_json || result.output }
|
205
|
+
# )
|
206
|
+
#
|
207
|
+
def as_tool(name: nil, description: nil, output_extractor: nil)
|
208
|
+
AgentTool.new(
|
209
|
+
agent: self,
|
210
|
+
name: name,
|
211
|
+
description: description,
|
212
|
+
output_extractor: output_extractor
|
213
|
+
)
|
214
|
+
end
|
181
215
|
end
|
182
216
|
end
|
data/lib/agents/agent_runner.rb
CHANGED
@@ -11,6 +11,8 @@ module Agents
|
|
11
11
|
# ## Usage Pattern
|
12
12
|
# # Create once (typically at application startup)
|
13
13
|
# runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
|
14
|
+
# .on_tool_start { |tool_name, args| broadcast_event('tool_start', tool_name, args) }
|
15
|
+
# .on_tool_complete { |tool_name, result| broadcast_event('tool_complete', tool_name, result) }
|
14
16
|
#
|
15
17
|
# # Use safely from multiple threads
|
16
18
|
# result = runner.run("I need billing help") # New conversation
|
@@ -22,6 +24,10 @@ module Agents
|
|
22
24
|
# - Each run() call creates independent execution context
|
23
25
|
# - No shared mutable state between concurrent executions
|
24
26
|
#
|
27
|
+
# ## Callback Thread Safety
|
28
|
+
# Callback registration is thread-safe using internal synchronization. Multiple threads
|
29
|
+
# can safely register callbacks concurrently without data races.
|
30
|
+
#
|
25
31
|
class AgentRunner
|
26
32
|
# Initialize with a list of agents. The first agent becomes the default entry point.
|
27
33
|
#
|
@@ -30,10 +36,19 @@ module Agents
|
|
30
36
|
raise ArgumentError, "At least one agent must be provided" if agents.empty?
|
31
37
|
|
32
38
|
@agents = agents.dup.freeze
|
39
|
+
@callbacks_mutex = Mutex.new
|
33
40
|
@default_agent = agents.first
|
34
41
|
|
35
42
|
# Build simple registry from provided agents - developer controls what's available
|
36
43
|
@registry = build_registry(agents).freeze
|
44
|
+
|
45
|
+
# Initialize callback storage - use thread-safe arrays
|
46
|
+
@callbacks = {
|
47
|
+
tool_start: [],
|
48
|
+
tool_complete: [],
|
49
|
+
agent_thinking: [],
|
50
|
+
agent_handoff: []
|
51
|
+
}
|
37
52
|
end
|
38
53
|
|
39
54
|
# Execute a conversation turn with automatic agent selection.
|
@@ -50,15 +65,65 @@ module Agents
|
|
50
65
|
current_agent = determine_conversation_agent(context)
|
51
66
|
|
52
67
|
# Execute using stateless Runner - each execution is independent and thread-safe
|
68
|
+
# Pass callbacks to enable real-time event notifications
|
53
69
|
Runner.new.run(
|
54
70
|
current_agent,
|
55
71
|
input,
|
56
72
|
context: context,
|
57
73
|
registry: @registry,
|
58
|
-
max_turns: max_turns
|
74
|
+
max_turns: max_turns,
|
75
|
+
callbacks: @callbacks
|
59
76
|
)
|
60
77
|
end
|
61
78
|
|
79
|
+
# Register a callback for tool start events.
|
80
|
+
# Called when an agent is about to execute a tool.
|
81
|
+
#
|
82
|
+
# @param block [Proc] Callback block that receives (tool_name, args)
|
83
|
+
# @return [self] For method chaining
|
84
|
+
def on_tool_start(&block)
|
85
|
+
return self unless block
|
86
|
+
|
87
|
+
@callbacks_mutex.synchronize { @callbacks[:tool_start] << block }
|
88
|
+
self
|
89
|
+
end
|
90
|
+
|
91
|
+
# Register a callback for tool completion events.
|
92
|
+
# Called when an agent has finished executing a tool.
|
93
|
+
#
|
94
|
+
# @param block [Proc] Callback block that receives (tool_name, result)
|
95
|
+
# @return [self] For method chaining
|
96
|
+
def on_tool_complete(&block)
|
97
|
+
return self unless block
|
98
|
+
|
99
|
+
@callbacks_mutex.synchronize { @callbacks[:tool_complete] << block }
|
100
|
+
self
|
101
|
+
end
|
102
|
+
|
103
|
+
# Register a callback for agent thinking events.
|
104
|
+
# Called when an agent is about to make an LLM call.
|
105
|
+
#
|
106
|
+
# @param block [Proc] Callback block that receives (agent_name, input)
|
107
|
+
# @return [self] For method chaining
|
108
|
+
def on_agent_thinking(&block)
|
109
|
+
return self unless block
|
110
|
+
|
111
|
+
@callbacks_mutex.synchronize { @callbacks[:agent_thinking] << block }
|
112
|
+
self
|
113
|
+
end
|
114
|
+
|
115
|
+
# Register a callback for agent handoff events.
|
116
|
+
# Called when control is transferred from one agent to another.
|
117
|
+
#
|
118
|
+
# @param block [Proc] Callback block that receives (from_agent, to_agent, reason)
|
119
|
+
# @return [self] For method chaining
|
120
|
+
def on_agent_handoff(&block)
|
121
|
+
return self unless block
|
122
|
+
|
123
|
+
@callbacks_mutex.synchronize { @callbacks[:agent_handoff] << block }
|
124
|
+
self
|
125
|
+
end
|
126
|
+
|
62
127
|
private
|
63
128
|
|
64
129
|
# Build agent registry from provided agents only.
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Agents
|
4
|
+
# AgentTool wraps an agent as a tool, enabling agent-to-agent collaboration
|
5
|
+
# without conversation handoffs. This implementation constrains wrapped agents
|
6
|
+
# for safety and predictability.
|
7
|
+
#
|
8
|
+
# Key constraints:
|
9
|
+
# 1. Wrapped agents cannot perform handoffs (empty registry)
|
10
|
+
# 2. Limited turn count to prevent infinite loops
|
11
|
+
# 3. Isolated context (only shared state, no conversation history)
|
12
|
+
# 4. Always returns to calling agent
|
13
|
+
#
|
14
|
+
# @example Customer support copilot with specialized agents
|
15
|
+
# conversation_agent = Agent.new(
|
16
|
+
# name: "ConversationAnalyzer",
|
17
|
+
# instructions: "Extract order IDs and customer intent from conversation history"
|
18
|
+
# )
|
19
|
+
#
|
20
|
+
# actions_agent = Agent.new(
|
21
|
+
# name: "ShopifyActions",
|
22
|
+
# instructions: "Perform Shopify operations",
|
23
|
+
# tools: [shopify_tool]
|
24
|
+
# )
|
25
|
+
#
|
26
|
+
# copilot = Agent.new(
|
27
|
+
# name: "SupportCopilot",
|
28
|
+
# tools: [
|
29
|
+
# conversation_agent.as_tool(
|
30
|
+
# name: "analyze_conversation",
|
31
|
+
# description: "Extract key info from conversation history"
|
32
|
+
# ),
|
33
|
+
# actions_agent.as_tool(
|
34
|
+
# name: "shopify_action",
|
35
|
+
# description: "Perform Shopify operations like refunds"
|
36
|
+
# )
|
37
|
+
# ]
|
38
|
+
# )
|
39
|
+
class AgentTool < Tool
|
40
|
+
attr_reader :wrapped_agent, :tool_name, :tool_description, :output_extractor
|
41
|
+
|
42
|
+
# Default parameter for agent tools
|
43
|
+
param :input, type: "string", desc: "Input message for the agent"
|
44
|
+
|
45
|
+
# Initialize an AgentTool that wraps an agent as a callable tool
|
46
|
+
#
|
47
|
+
# @param agent [Agents::Agent] The agent to wrap as a tool
|
48
|
+
# @param name [String, nil] Override the tool name (defaults to snake_case agent name)
|
49
|
+
# @param description [String, nil] Override the tool description
|
50
|
+
# @param output_extractor [Proc, nil] Custom proc to extract/transform the agent's output
|
51
|
+
def initialize(agent:, name: nil, description: nil, output_extractor: nil)
|
52
|
+
@wrapped_agent = agent
|
53
|
+
@tool_name = name || transform_agent_name(agent.name)
|
54
|
+
@tool_description = description || "Execute #{agent.name} agent"
|
55
|
+
@output_extractor = output_extractor
|
56
|
+
|
57
|
+
super()
|
58
|
+
end
|
59
|
+
|
60
|
+
def name
|
61
|
+
@tool_name
|
62
|
+
end
|
63
|
+
|
64
|
+
def description
|
65
|
+
@tool_description
|
66
|
+
end
|
67
|
+
|
68
|
+
# Execute the wrapped agent with constraints to prevent handoffs and recursion
|
69
|
+
def perform(tool_context, input:)
|
70
|
+
# Create isolated context for the wrapped agent
|
71
|
+
isolated_context = create_isolated_context(tool_context.context)
|
72
|
+
|
73
|
+
# Execute with explicit constraints:
|
74
|
+
# 1. Empty registry prevents handoffs
|
75
|
+
# 2. Low max_turns prevents infinite loops
|
76
|
+
# 3. Isolated context prevents history access
|
77
|
+
result = Runner.new.run(
|
78
|
+
@wrapped_agent,
|
79
|
+
input,
|
80
|
+
context: isolated_context,
|
81
|
+
registry: {}, # CONSTRAINT: No handoffs allowed
|
82
|
+
max_turns: 3 # CONSTRAINT: Limited turns for tool execution
|
83
|
+
)
|
84
|
+
|
85
|
+
return "Agent execution failed: #{result.error.message}" if result.error
|
86
|
+
|
87
|
+
# Extract output
|
88
|
+
if @output_extractor
|
89
|
+
@output_extractor.call(result)
|
90
|
+
else
|
91
|
+
result.output || "No output from #{@wrapped_agent.name}"
|
92
|
+
end
|
93
|
+
rescue StandardError => e
|
94
|
+
"Error executing #{@wrapped_agent.name}: #{e.message}"
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def transform_agent_name(name)
|
100
|
+
name.downcase.gsub(/\s+/, "_").gsub(/[^a-z0-9_]/, "")
|
101
|
+
end
|
102
|
+
|
103
|
+
# Create isolated context that only shares state, not conversation artifacts
|
104
|
+
def create_isolated_context(parent_context)
|
105
|
+
isolated = {}
|
106
|
+
|
107
|
+
# Only share the state - everything else is conversation-specific
|
108
|
+
isolated[:state] = parent_context[:state] if parent_context[:state]
|
109
|
+
|
110
|
+
isolated
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Agents
|
4
|
+
# Manager for handling and emitting callback events in a thread-safe manner.
|
5
|
+
# Provides both generic emit() method and typed convenience methods.
|
6
|
+
#
|
7
|
+
# @example Using generic emit
|
8
|
+
# manager.emit(:tool_start, tool_name, args)
|
9
|
+
#
|
10
|
+
# @example Using typed methods
|
11
|
+
# manager.emit_tool_start(tool_name, args)
|
12
|
+
# manager.emit_agent_thinking(agent_name, input)
|
13
|
+
class CallbackManager
|
14
|
+
# Supported callback event types
|
15
|
+
EVENT_TYPES = %i[
|
16
|
+
tool_start
|
17
|
+
tool_complete
|
18
|
+
agent_thinking
|
19
|
+
agent_handoff
|
20
|
+
].freeze
|
21
|
+
|
22
|
+
def initialize(callbacks = {})
|
23
|
+
@callbacks = callbacks.dup.freeze
|
24
|
+
end
|
25
|
+
|
26
|
+
# Generic method to emit any callback event type
|
27
|
+
#
|
28
|
+
# @param event_type [Symbol] The type of event to emit
|
29
|
+
# @param args [Array] Arguments to pass to callbacks
|
30
|
+
def emit(event_type, *args)
|
31
|
+
callback_list = @callbacks[event_type] || []
|
32
|
+
|
33
|
+
callback_list.each do |callback|
|
34
|
+
callback.call(*args)
|
35
|
+
rescue StandardError => e
|
36
|
+
# Log callback errors but don't let them crash execution
|
37
|
+
warn "Callback error for #{event_type}: #{e.message}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Metaprogramming: Create typed emit methods for each event type
|
42
|
+
#
|
43
|
+
# This generates methods like:
|
44
|
+
# emit_tool_start(tool_name, args)
|
45
|
+
# emit_tool_complete(tool_name, result)
|
46
|
+
# emit_agent_thinking(agent_name, input)
|
47
|
+
# emit_agent_handoff(from_agent, to_agent, reason)
|
48
|
+
EVENT_TYPES.each do |event_type|
|
49
|
+
define_method("emit_#{event_type}") do |*args|
|
50
|
+
emit(event_type, *args)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
data/lib/agents/handoff.rb
CHANGED
@@ -12,35 +12,10 @@ module Agents
|
|
12
12
|
# 4. The tool signals the handoff through context
|
13
13
|
# 5. The Runner detects this and switches to the new agent
|
14
14
|
#
|
15
|
-
# ##
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
# During development, we discovered that LLMs could call the same handoff tool multiple times
|
20
|
-
# in a single response, leading to infinite loops:
|
21
|
-
#
|
22
|
-
# 1. User: "My internet isn't working but my account shows active"
|
23
|
-
# 2. Triage Agent hands off to Support Agent
|
24
|
-
# 3. Support Agent sees account info is needed, hands back to Triage Agent
|
25
|
-
# 4. Triage Agent sees technical issue, hands off to Support Agent again
|
26
|
-
# 5. This creates an infinite ping-pong loop
|
27
|
-
#
|
28
|
-
# ### Root Cause Analysis
|
29
|
-
# Unlike OpenAI's SDK which processes tool calls before execution, RubyLLM automatically
|
30
|
-
# executes all tool calls in a response. This meant:
|
31
|
-
# - LLM calls handoff tool 10+ times in one response
|
32
|
-
# - Each call sets context[:pending_handoff], overwriting previous values
|
33
|
-
# - Runner processes handoffs after tool execution, seeing only the last one
|
34
|
-
# - Multiple handoff signals created conflicting state
|
35
|
-
#
|
36
|
-
# TODO: Overall, this problem can be tackled better if we replace the RubyLLM chat
|
37
|
-
# program with our own implementation.
|
38
|
-
#
|
39
|
-
# ### The Solution
|
40
|
-
# We implemented first-call-wins semantics inspired by OpenAI's approach:
|
41
|
-
# - First handoff call in a response sets the pending handoff
|
42
|
-
# - Subsequent calls are ignored with a "transfer in progress" message
|
43
|
-
# - This prevents loops and mirrors OpenAI SDK behavior
|
15
|
+
# ## Loop Prevention
|
16
|
+
# The library prevents infinite handoff loops by processing only the first handoff
|
17
|
+
# tool call in any LLM response. This is handled automatically by the Chat class
|
18
|
+
# which detects handoff tools and processes them separately from regular tools.
|
44
19
|
#
|
45
20
|
# ## Why Tools Instead of Instructions
|
46
21
|
# Using tools for handoffs has several advantages:
|
@@ -66,12 +41,11 @@ module Agents
|
|
66
41
|
# # LLM calls: handoff_to_billing()
|
67
42
|
# # Runner switches to billing_agent for the next turn
|
68
43
|
#
|
69
|
-
# @example
|
44
|
+
# @example Multiple handoff handling
|
70
45
|
# # Single LLM response with multiple handoff calls:
|
71
|
-
# # Call 1: handoff_to_support() ->
|
72
|
-
# # Call 2:
|
73
|
-
# #
|
74
|
-
# # Result: Only transfers to Support Agent (first call wins)
|
46
|
+
# # Call 1: handoff_to_support() -> Processed and executed
|
47
|
+
# # Call 2: handoff_to_billing() -> Ignored (only first handoff processed)
|
48
|
+
# # Result: Only transfers to Support Agent
|
75
49
|
class HandoffTool < Tool
|
76
50
|
attr_reader :target_agent
|
77
51
|
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Agents
|
4
|
+
# Service object responsible for extracting and formatting conversation messages
|
5
|
+
# from RubyLLM chat objects into a format suitable for persistence and context restoration.
|
6
|
+
#
|
7
|
+
# Handles different message types:
|
8
|
+
# - User messages: Basic content preservation
|
9
|
+
# - Assistant messages: Includes agent attribution and tool calls
|
10
|
+
# - Tool result messages: Links back to original tool calls
|
11
|
+
#
|
12
|
+
# @example Extract messages from a chat
|
13
|
+
# messages = MessageExtractor.extract_messages(chat, current_agent)
|
14
|
+
# #=> [
|
15
|
+
# { role: :user, content: "Hello" },
|
16
|
+
# { role: :assistant, content: "Hi!", agent_name: "Support", tool_calls: [...] },
|
17
|
+
# { role: :tool, content: "Result", tool_call_id: "call_123" }
|
18
|
+
# ]
|
19
|
+
class MessageExtractor
|
20
|
+
# Extract messages from a chat object for conversation history persistence
|
21
|
+
#
|
22
|
+
# @param chat [Object] Chat object that responds to :messages
|
23
|
+
# @param current_agent [Agent] The agent currently handling the conversation
|
24
|
+
# @return [Array<Hash>] Array of message hashes suitable for persistence
|
25
|
+
def self.extract_messages(chat, current_agent)
|
26
|
+
new(chat, current_agent).extract
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize(chat, current_agent)
|
30
|
+
@chat = chat
|
31
|
+
@current_agent = current_agent
|
32
|
+
end
|
33
|
+
|
34
|
+
def extract
|
35
|
+
return [] unless @chat.respond_to?(:messages)
|
36
|
+
|
37
|
+
@chat.messages.filter_map do |msg|
|
38
|
+
case msg.role
|
39
|
+
when :user, :assistant
|
40
|
+
extract_user_or_assistant_message(msg)
|
41
|
+
when :tool
|
42
|
+
extract_tool_message(msg)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def extract_user_or_assistant_message(msg)
|
50
|
+
return nil unless msg.content && !msg.content.strip.empty?
|
51
|
+
|
52
|
+
message = {
|
53
|
+
role: msg.role,
|
54
|
+
content: msg.content
|
55
|
+
}
|
56
|
+
|
57
|
+
if msg.role == :assistant
|
58
|
+
# Add agent attribution for conversation continuity
|
59
|
+
message[:agent_name] = @current_agent.name if @current_agent
|
60
|
+
|
61
|
+
# Add tool calls if present
|
62
|
+
if msg.tool_call? && msg.tool_calls
|
63
|
+
# RubyLLM stores tool_calls as Hash with call_id => ToolCall object
|
64
|
+
# Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
|
65
|
+
message[:tool_calls] = msg.tool_calls.values.map(&:to_h)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
message
|
70
|
+
end
|
71
|
+
|
72
|
+
def extract_tool_message(msg)
|
73
|
+
return nil unless msg.tool_result?
|
74
|
+
|
75
|
+
{
|
76
|
+
role: msg.role,
|
77
|
+
content: msg.content,
|
78
|
+
tool_call_id: msg.tool_call_id
|
79
|
+
}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
data/lib/agents/run_context.rb
CHANGED
@@ -56,14 +56,17 @@
|
|
56
56
|
# # - No race conditions or data leakage between runs
|
57
57
|
module Agents
|
58
58
|
class RunContext
|
59
|
-
attr_reader :context, :usage
|
59
|
+
attr_reader :context, :usage, :callbacks, :callback_manager
|
60
60
|
|
61
61
|
# Initialize a new RunContext with execution context and usage tracking
|
62
62
|
#
|
63
63
|
# @param context [Hash] The execution context data (will be duplicated for isolation)
|
64
|
-
|
64
|
+
# @param callbacks [Hash] Optional callbacks for real-time event notifications
|
65
|
+
def initialize(context, callbacks: {})
|
65
66
|
@context = context
|
66
67
|
@usage = Usage.new
|
68
|
+
@callbacks = callbacks || {}
|
69
|
+
@callback_manager = CallbackManager.new(@callbacks)
|
67
70
|
end
|
68
71
|
|
69
72
|
# Usage tracks token consumption across all LLM calls within a single run.
|