ai-agents 0.1.1 → 0.1.3
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/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/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 +21 -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 +95 -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/agents_factory.rb +57 -1
- data/examples/isp-support/tools/create_lead_tool.rb +16 -2
- data/examples/isp-support/tools/crm_lookup_tool.rb +13 -1
- data/lib/agents/agent.rb +52 -6
- data/lib/agents/agent_tool.rb +113 -0
- data/lib/agents/handoff.rb +8 -34
- data/lib/agents/tool_context.rb +36 -0
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +1 -0
- metadata +44 -2
@@ -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
|
@@ -51,7 +51,7 @@ module ISPSupport
|
|
51
51
|
def create_sales_agent
|
52
52
|
Agents::Agent.new(
|
53
53
|
name: "Sales Agent",
|
54
|
-
instructions:
|
54
|
+
instructions: sales_instructions_with_state,
|
55
55
|
model: "gpt-4.1-mini",
|
56
56
|
tools: [ISPSupport::CreateLeadTool.new, ISPSupport::CreateCheckoutTool.new]
|
57
57
|
)
|
@@ -113,6 +113,62 @@ module ISPSupport
|
|
113
113
|
INSTRUCTIONS
|
114
114
|
end
|
115
115
|
|
116
|
+
def sales_instructions_with_state
|
117
|
+
lambda { |context|
|
118
|
+
state = context.context[:state] || {}
|
119
|
+
|
120
|
+
base_instructions = <<~INSTRUCTIONS
|
121
|
+
You are the Sales Agent for an ISP. You handle new customer acquisition, service upgrades,
|
122
|
+
and plan changes.
|
123
|
+
|
124
|
+
**Your tools:**
|
125
|
+
- `create_lead`: Create sales leads with customer information
|
126
|
+
- `create_checkout`: Generate secure checkout links for purchases
|
127
|
+
- Handoff tools: Route back to triage when needed
|
128
|
+
|
129
|
+
**When to hand off:**
|
130
|
+
- Pure technical support questions → Triage Agent for re-routing
|
131
|
+
- Customer needs to speak with human agent → Triage Agent for re-routing
|
132
|
+
INSTRUCTIONS
|
133
|
+
|
134
|
+
# Add customer context if available from previous agent interactions
|
135
|
+
if state[:customer_name] && state[:customer_id]
|
136
|
+
base_instructions += <<~CONTEXT
|
137
|
+
|
138
|
+
**Customer Context Available:**
|
139
|
+
- Customer Name: #{state[:customer_name]}
|
140
|
+
- Customer ID: #{state[:customer_id]}
|
141
|
+
- Email: #{state[:customer_email]}
|
142
|
+
- Phone: #{state[:customer_phone]}
|
143
|
+
- Address: #{state[:customer_address]}
|
144
|
+
#{state[:current_plan] ? "- Current Plan: #{state[:current_plan]}" : ""}
|
145
|
+
#{state[:account_status] ? "- Account Status: #{state[:account_status]}" : ""}
|
146
|
+
#{state[:monthly_usage] ? "- Monthly Usage: #{state[:monthly_usage]}GB" : ""}
|
147
|
+
|
148
|
+
**IMPORTANT:**#{" "}
|
149
|
+
- Use this existing customer information when creating leads or providing service
|
150
|
+
- Do NOT ask for name, email, phone, or address - you already have these details
|
151
|
+
- For new connections, use the existing customer details and only ask for the desired plan
|
152
|
+
- Provide personalized recommendations based on their current information
|
153
|
+
CONTEXT
|
154
|
+
end
|
155
|
+
|
156
|
+
base_instructions += <<~FINAL_INSTRUCTIONS
|
157
|
+
|
158
|
+
**Instructions:**
|
159
|
+
- Be enthusiastic but not pushy
|
160
|
+
- Gather required info: name, email, desired plan for leads
|
161
|
+
- For account verification, ask customer for their account details directly
|
162
|
+
- For existing customers wanting upgrades, collect account info and proceed
|
163
|
+
- Create checkout links for confirmed purchases
|
164
|
+
- Always explain next steps after creating leads or checkout links
|
165
|
+
- Handle billing questions yourself - don't hand off for account verification
|
166
|
+
FINAL_INSTRUCTIONS
|
167
|
+
|
168
|
+
base_instructions
|
169
|
+
}
|
170
|
+
end
|
171
|
+
|
116
172
|
def support_instructions
|
117
173
|
<<~INSTRUCTIONS
|
118
174
|
You are the Support Agent for an ISP. You handle technical support, troubleshooting,
|
@@ -8,8 +8,22 @@ module ISPSupport
|
|
8
8
|
param :email, type: "string", desc: "Customer's email address"
|
9
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
|
@@ -8,7 +8,7 @@ module ISPSupport
|
|
8
8
|
description "Look up customer account information by account ID"
|
9
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
|
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
|
@@ -166,5 +178,39 @@ module Agents
|
|
166
178
|
instructions.call(context)
|
167
179
|
end
|
168
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
|
169
215
|
end
|
170
216
|
end
|
@@ -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
|
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
|
|
data/lib/agents/tool_context.rb
CHANGED
@@ -94,5 +94,41 @@ module Agents
|
|
94
94
|
def usage
|
95
95
|
@run_context.usage
|
96
96
|
end
|
97
|
+
|
98
|
+
# Convenient access to the shared state hash within the context.
|
99
|
+
# This provides tools with a dedicated space to store and retrieve
|
100
|
+
# state that persists across agent interactions within a conversation.
|
101
|
+
#
|
102
|
+
# State is automatically initialized as an empty hash if it doesn't exist.
|
103
|
+
# All state modifications are automatically included in context serialization,
|
104
|
+
# making it persist across process boundaries (e.g., Rails with ActiveRecord).
|
105
|
+
#
|
106
|
+
# @return [Hash] The shared state hash
|
107
|
+
# @example Tool storing customer information in state
|
108
|
+
# def perform(tool_context, customer_id:)
|
109
|
+
# customer = Customer.find(customer_id)
|
110
|
+
#
|
111
|
+
# # Store in shared state for other tools/agents to access
|
112
|
+
# tool_context.state[:customer_id] = customer_id
|
113
|
+
# tool_context.state[:customer_name] = customer.name
|
114
|
+
# tool_context.state[:plan_type] = customer.plan
|
115
|
+
#
|
116
|
+
# "Found customer #{customer.name}"
|
117
|
+
# end
|
118
|
+
#
|
119
|
+
# @example Tool reading from shared state
|
120
|
+
# def perform(tool_context)
|
121
|
+
# customer_id = tool_context.state[:customer_id]
|
122
|
+
# plan_type = tool_context.state[:plan_type]
|
123
|
+
#
|
124
|
+
# if customer_id && plan_type
|
125
|
+
# "Current plan: #{plan_type} for customer #{customer_id}"
|
126
|
+
# else
|
127
|
+
# "No customer information available"
|
128
|
+
# end
|
129
|
+
# end
|
130
|
+
def state
|
131
|
+
context[:state] ||= {}
|
132
|
+
end
|
97
133
|
end
|
98
134
|
end
|
data/lib/agents/version.rb
CHANGED
data/lib/agents.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ai-agents
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Shivam Mishra
|
@@ -37,7 +37,48 @@ files:
|
|
37
37
|
- LICENSE
|
38
38
|
- README.md
|
39
39
|
- Rakefile
|
40
|
+
- docs/Gemfile
|
41
|
+
- docs/Gemfile.lock
|
42
|
+
- docs/_config.yml
|
43
|
+
- docs/_sass/color_schemes/ruby.scss
|
44
|
+
- docs/_sass/custom/custom.scss
|
45
|
+
- docs/architecture.md
|
46
|
+
- docs/assets/fonts/InterVariable.woff2
|
47
|
+
- docs/concepts.md
|
48
|
+
- docs/concepts/agent-tool.md
|
49
|
+
- docs/concepts/agents.md
|
50
|
+
- docs/concepts/context.md
|
51
|
+
- docs/concepts/handoffs.md
|
52
|
+
- docs/concepts/runner.md
|
53
|
+
- docs/concepts/tools.md
|
54
|
+
- docs/guides.md
|
55
|
+
- docs/guides/agent-as-tool-pattern.md
|
56
|
+
- docs/guides/multi-agent-systems.md
|
57
|
+
- docs/guides/rails-integration.md
|
58
|
+
- docs/guides/state-persistence.md
|
59
|
+
- docs/index.md
|
40
60
|
- examples/README.md
|
61
|
+
- examples/collaborative-copilot/README.md
|
62
|
+
- examples/collaborative-copilot/agents/analysis_agent.rb
|
63
|
+
- examples/collaborative-copilot/agents/answer_suggestion_agent.rb
|
64
|
+
- examples/collaborative-copilot/agents/copilot_orchestrator.rb
|
65
|
+
- examples/collaborative-copilot/agents/integrations_agent.rb
|
66
|
+
- examples/collaborative-copilot/agents/research_agent.rb
|
67
|
+
- examples/collaborative-copilot/data/contacts.json
|
68
|
+
- examples/collaborative-copilot/data/conversations.json
|
69
|
+
- examples/collaborative-copilot/data/knowledge_base.json
|
70
|
+
- examples/collaborative-copilot/data/linear_issues.json
|
71
|
+
- examples/collaborative-copilot/data/stripe_billing.json
|
72
|
+
- examples/collaborative-copilot/interactive.rb
|
73
|
+
- examples/collaborative-copilot/tools/create_linear_ticket_tool.rb
|
74
|
+
- examples/collaborative-copilot/tools/get_article_tool.rb
|
75
|
+
- examples/collaborative-copilot/tools/get_contact_tool.rb
|
76
|
+
- examples/collaborative-copilot/tools/get_conversation_tool.rb
|
77
|
+
- examples/collaborative-copilot/tools/get_stripe_billing_tool.rb
|
78
|
+
- examples/collaborative-copilot/tools/search_contacts_tool.rb
|
79
|
+
- examples/collaborative-copilot/tools/search_conversations_tool.rb
|
80
|
+
- examples/collaborative-copilot/tools/search_knowledge_base_tool.rb
|
81
|
+
- examples/collaborative-copilot/tools/search_linear_issues_tool.rb
|
41
82
|
- examples/isp-support/README.md
|
42
83
|
- examples/isp-support/agents_factory.rb
|
43
84
|
- examples/isp-support/data/customers.json
|
@@ -52,6 +93,7 @@ files:
|
|
52
93
|
- lib/agents.rb
|
53
94
|
- lib/agents/agent.rb
|
54
95
|
- lib/agents/agent_runner.rb
|
96
|
+
- lib/agents/agent_tool.rb
|
55
97
|
- lib/agents/chat.rb
|
56
98
|
- lib/agents/handoff.rb
|
57
99
|
- lib/agents/result.rb
|
@@ -75,7 +117,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
117
|
requirements:
|
76
118
|
- - ">="
|
77
119
|
- !ruby/object:Gem::Version
|
78
|
-
version: 3.
|
120
|
+
version: 3.2.0
|
79
121
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
80
122
|
requirements:
|
81
123
|
- - ">="
|