ai-agents 0.4.3 → 0.5.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/CHANGELOG.md +8 -0
- data/CLAUDE.md +1 -3
- data/docs/concepts/callbacks.md +1 -1
- data/docs/guides/multi-agent-systems.md +13 -13
- data/docs/guides/rails-integration.md +56 -56
- data/docs/guides/structured-output.md +2 -2
- data/docs/index.md +1 -1
- data/examples/isp-support/agents_factory.rb +6 -19
- data/examples/isp-support/interactive.rb +4 -14
- data/lib/agents/handoff.rb +13 -5
- data/lib/agents/runner.rb +52 -23
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +0 -1
- metadata +3 -4
- data/lib/agents/chat.rb +0 -161
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d07ac97ca06177ee4504601099af6bc6ba3e4d8557b28d34cd3222240b27575d
|
4
|
+
data.tar.gz: cd0c7121918c8a28c3760325c9acd319e86d349f34fb715d6b953912a99f98a8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 63db003d8bf43b2ba8d52d48dbb24d2f8bf86ee4b4e8ab704316cf89d0b80156110a8438a7ca86a6fc37182ff9f5a723ace7c474e53a078de050cb22eff4b268
|
7
|
+
data.tar.gz: 05606d58c663ca0b9b062ed537cba33a4b987e5fd720a1188c003150f880a7c953cc28096ca4024854dcdec0a9896a7da35653ebb4f95c2e04a0f5b97e3e1c2e
|
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
+
## [0.5.0] - 2025-08-20
|
9
|
+
|
10
|
+
### Added
|
11
|
+
- Tool halting functionality for enhanced agent control
|
12
|
+
|
13
|
+
### Removed
|
14
|
+
- Removed chat.rb component that made the codebase brittle
|
15
|
+
|
8
16
|
## [0.4.3] - 2025-08-04
|
9
17
|
|
10
18
|
### Fixed
|
data/CLAUDE.md
CHANGED
@@ -63,7 +63,6 @@ This will start a command-line interface where you can interact with the multi-a
|
|
63
63
|
- **Agent**: An AI assistant with a specific role, instructions, and tools.
|
64
64
|
- **Tool**: A custom function that an agent can use to perform actions (e.g., look up customer data, send an email).
|
65
65
|
- **Handoff**: The process of transferring a conversation from one agent to another. This is a core feature of the SDK.
|
66
|
-
- **AgentRunner**: The thread-safe execution manager that coordinates multi-agent conversations and provides the main API.
|
67
66
|
- **Runner**: Internal component that manages individual conversation turns (used by AgentRunner).
|
68
67
|
- **Context**: A shared state object that stores conversation history and agent information, fully serializable for persistence.
|
69
68
|
- **Callbacks**: Event hooks for monitoring agent execution, including agent thinking, tool start/complete, and handoffs.
|
@@ -114,7 +113,6 @@ ruby examples/isp-support/interactive.rb
|
|
114
113
|
### Core Components
|
115
114
|
|
116
115
|
- **Agents::Agent**: Individual AI agents with specific roles, instructions, and tools
|
117
|
-
- **Agents::AgentRunner**: Thread-safe execution manager with callback support
|
118
116
|
- **Agents::Runner**: Orchestrates multi-agent conversations with automatic handoffs
|
119
117
|
- **Agents::Tool**: Base class for custom tools that agents can execute
|
120
118
|
- **Agents::Context**: Shared state management across agent interactions
|
@@ -174,7 +172,7 @@ support = Agent.new(name: "Support", instructions: "Technical support...")
|
|
174
172
|
triage.register_handoffs(billing, support)
|
175
173
|
|
176
174
|
# Create thread-safe runner (first agent is default entry point)
|
177
|
-
runner = Agents::
|
175
|
+
runner = Agents::Runner.with_agents(triage, billing, support)
|
178
176
|
|
179
177
|
# Add real-time callbacks for monitoring
|
180
178
|
runner.on_agent_thinking { |agent_name, input| puts "🧠 #{agent_name} is thinking..." }
|
data/docs/concepts/callbacks.md
CHANGED
@@ -26,7 +26,7 @@ The SDK provides four types of callbacks that give you visibility into different
|
|
26
26
|
Callbacks are registered on the AgentRunner using chainable methods:
|
27
27
|
|
28
28
|
```ruby
|
29
|
-
runner = Agents::
|
29
|
+
runner = Agents::Runner.with_agents(triage, support)
|
30
30
|
.on_agent_thinking { |agent, input| puts "#{agent} thinking..." }
|
31
31
|
.on_tool_start { |tool, args| puts "Using #{tool}" }
|
32
32
|
.on_tool_complete { |tool, result| puts "#{tool} completed" }
|
@@ -23,7 +23,7 @@ billing_agent = Agents::Agent.new(
|
|
23
23
|
)
|
24
24
|
|
25
25
|
support_agent = Agents::Agent.new(
|
26
|
-
name: "Support",
|
26
|
+
name: "Support",
|
27
27
|
instructions: "Provide technical troubleshooting and product support."
|
28
28
|
)
|
29
29
|
|
@@ -37,7 +37,7 @@ triage_agent = Agents::Agent.new(
|
|
37
37
|
triage_agent.register_handoffs(billing_agent, support_agent)
|
38
38
|
|
39
39
|
# Create runner with triage as entry point
|
40
|
-
runner = Agents::
|
40
|
+
runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
|
41
41
|
```
|
42
42
|
|
43
43
|
### Dynamic Instructions
|
@@ -52,7 +52,7 @@ support_agent = Agents::Agent.new(
|
|
52
52
|
<<~INSTRUCTIONS
|
53
53
|
You are a technical support specialist for #{customer_tier} tier customers.
|
54
54
|
#{customer_tier == "premium" ? "Provide priority white-glove service." : ""}
|
55
|
-
|
55
|
+
|
56
56
|
Available tools: diagnostics, escalation
|
57
57
|
INSTRUCTIONS
|
58
58
|
}
|
@@ -73,7 +73,7 @@ sales_agent = Agents::Agent.new(
|
|
73
73
|
)
|
74
74
|
|
75
75
|
support_agent = Agents::Agent.new(
|
76
|
-
name: "Support",
|
76
|
+
name: "Support",
|
77
77
|
instructions: "Handle technical issues and product troubleshooting. Transfer sales questions to sales team."
|
78
78
|
)
|
79
79
|
```
|
@@ -108,7 +108,7 @@ The first agent in `AgentRunner.with_agents()` becomes the default entry point:
|
|
108
108
|
|
109
109
|
```ruby
|
110
110
|
# Triage agent handles all initial conversations
|
111
|
-
runner = Agents::
|
111
|
+
runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
|
112
112
|
|
113
113
|
# Start conversation
|
114
114
|
result = runner.run("I need help with my account")
|
@@ -168,9 +168,9 @@ triage_agent = Agents::Agent.new(
|
|
168
168
|
name: "Triage",
|
169
169
|
instructions: ->(context) {
|
170
170
|
business_hours = context[:business_hours] || false
|
171
|
-
|
171
|
+
|
172
172
|
base_instructions = "Route users to appropriate departments."
|
173
|
-
|
173
|
+
|
174
174
|
if business_hours
|
175
175
|
base_instructions + " All departments are available."
|
176
176
|
else
|
@@ -189,8 +189,8 @@ Test each agent in isolation:
|
|
189
189
|
```ruby
|
190
190
|
RSpec.describe "BillingAgent" do
|
191
191
|
let(:agent) { create_billing_agent }
|
192
|
-
let(:runner) { Agents::
|
193
|
-
|
192
|
+
let(:runner) { Agents::Runner.with_agents(agent) }
|
193
|
+
|
194
194
|
it "handles payment inquiries" do
|
195
195
|
result = runner.run("What payment methods do you accept?")
|
196
196
|
expect(result.output).to include("credit card", "bank transfer")
|
@@ -205,13 +205,13 @@ Test complete workflows:
|
|
205
205
|
```ruby
|
206
206
|
RSpec.describe "Customer Support Workflow" do
|
207
207
|
let(:runner) { create_support_runner } # Creates triage + specialists
|
208
|
-
|
208
|
+
|
209
209
|
it "routes billing questions correctly" do
|
210
210
|
result = runner.run("I have a billing question")
|
211
|
-
|
211
|
+
|
212
212
|
# Verify handoff occurred
|
213
213
|
expect(result.context[:current_agent]).to eq("Billing")
|
214
|
-
|
214
|
+
|
215
215
|
# Test continued conversation
|
216
216
|
followup = runner.run("What are your payment terms?", context: result.context)
|
217
217
|
expect(followup.output).to include("payment terms")
|
@@ -258,4 +258,4 @@ threads = users.map do |user|
|
|
258
258
|
# Handle result...
|
259
259
|
end
|
260
260
|
end
|
261
|
-
```
|
261
|
+
```
|
@@ -49,7 +49,7 @@ class CreateConversations < ActiveRecord::Migration[7.0]
|
|
49
49
|
t.string :current_agent
|
50
50
|
t.timestamps
|
51
51
|
end
|
52
|
-
|
52
|
+
|
53
53
|
add_index :conversations, [:user_id, :created_at]
|
54
54
|
end
|
55
55
|
end
|
@@ -61,25 +61,25 @@ Define the Conversation model:
|
|
61
61
|
# app/models/conversation.rb
|
62
62
|
class Conversation < ApplicationRecord
|
63
63
|
belongs_to :user
|
64
|
-
|
64
|
+
|
65
65
|
# Serialize context as JSON
|
66
66
|
serialize :context, JSON
|
67
|
-
|
67
|
+
|
68
68
|
validates :context, presence: true
|
69
|
-
|
69
|
+
|
70
70
|
def self.for_user(user)
|
71
71
|
where(user: user).order(:created_at)
|
72
72
|
end
|
73
|
-
|
73
|
+
|
74
74
|
def self.latest_for_user(user)
|
75
75
|
for_user(user).last
|
76
76
|
end
|
77
|
-
|
77
|
+
|
78
78
|
# Convert to agent context hash
|
79
79
|
def to_agent_context
|
80
80
|
context.deep_symbolize_keys
|
81
81
|
end
|
82
|
-
|
82
|
+
|
83
83
|
# Create from agent result
|
84
84
|
def self.from_agent_result(user, result)
|
85
85
|
create!(
|
@@ -102,26 +102,26 @@ class AgentConversationService
|
|
102
102
|
@user = user
|
103
103
|
@runner = create_agent_runner
|
104
104
|
end
|
105
|
-
|
105
|
+
|
106
106
|
def send_message(message)
|
107
107
|
# Get existing conversation context
|
108
108
|
context = load_conversation_context
|
109
|
-
|
109
|
+
|
110
110
|
# Run agent with message
|
111
111
|
result = @runner.run(message, context: context)
|
112
|
-
|
112
|
+
|
113
113
|
# Persist updated conversation
|
114
114
|
save_conversation(result)
|
115
|
-
|
115
|
+
|
116
116
|
result
|
117
117
|
end
|
118
|
-
|
118
|
+
|
119
119
|
def reset_conversation
|
120
120
|
Conversation.where(user: @user).destroy_all
|
121
121
|
end
|
122
|
-
|
122
|
+
|
123
123
|
private
|
124
|
-
|
124
|
+
|
125
125
|
def create_agent_runner
|
126
126
|
# Create your agents here
|
127
127
|
triage_agent = Agents::Agent.new(
|
@@ -129,52 +129,52 @@ class AgentConversationService
|
|
129
129
|
instructions: build_triage_instructions,
|
130
130
|
tools: [CustomerLookupTool.new]
|
131
131
|
)
|
132
|
-
|
132
|
+
|
133
133
|
billing_agent = Agents::Agent.new(
|
134
134
|
name: "Billing",
|
135
135
|
instructions: "Handle billing and payment inquiries.",
|
136
136
|
tools: [BillingTool.new, PaymentTool.new]
|
137
137
|
)
|
138
|
-
|
138
|
+
|
139
139
|
support_agent = Agents::Agent.new(
|
140
140
|
name: "Support",
|
141
141
|
instructions: "Provide technical support and troubleshooting.",
|
142
142
|
tools: [TechnicalTool.new]
|
143
143
|
)
|
144
|
-
|
144
|
+
|
145
145
|
triage_agent.register_handoffs(billing_agent, support_agent)
|
146
|
-
|
147
|
-
Agents::
|
146
|
+
|
147
|
+
Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
|
148
148
|
end
|
149
|
-
|
149
|
+
|
150
150
|
def build_triage_instructions
|
151
151
|
->(context) {
|
152
152
|
user_info = context[:user_info] || {}
|
153
|
-
|
153
|
+
|
154
154
|
<<~INSTRUCTIONS
|
155
155
|
You are a customer service triage agent for #{@user.name}.
|
156
|
-
|
156
|
+
|
157
157
|
Customer Details:
|
158
158
|
- Name: #{@user.name}
|
159
159
|
- Email: #{@user.email}
|
160
160
|
- Account Type: #{user_info[:account_type] || 'standard'}
|
161
|
-
|
161
|
+
|
162
162
|
Route customers to the appropriate department:
|
163
163
|
- Billing: Payment issues, account billing, refunds
|
164
164
|
- Support: Technical problems, product questions
|
165
|
-
|
165
|
+
|
166
166
|
Always be professional and helpful.
|
167
167
|
INSTRUCTIONS
|
168
168
|
}
|
169
169
|
end
|
170
|
-
|
170
|
+
|
171
171
|
def load_conversation_context
|
172
172
|
latest_conversation = Conversation.latest_for_user(@user)
|
173
173
|
return initial_context unless latest_conversation
|
174
|
-
|
174
|
+
|
175
175
|
latest_conversation.to_agent_context
|
176
176
|
end
|
177
|
-
|
177
|
+
|
178
178
|
def initial_context
|
179
179
|
{
|
180
180
|
user_id: @user.id,
|
@@ -185,7 +185,7 @@ class AgentConversationService
|
|
185
185
|
}
|
186
186
|
}
|
187
187
|
end
|
188
|
-
|
188
|
+
|
189
189
|
def save_conversation(result)
|
190
190
|
Conversation.from_agent_result(@user, result)
|
191
191
|
end
|
@@ -200,13 +200,13 @@ Create a controller for handling agent conversations:
|
|
200
200
|
# app/controllers/agent_conversations_controller.rb
|
201
201
|
class AgentConversationsController < ApplicationController
|
202
202
|
before_action :authenticate_user!
|
203
|
-
|
203
|
+
|
204
204
|
def create
|
205
205
|
service = AgentConversationService.new(current_user)
|
206
|
-
|
206
|
+
|
207
207
|
begin
|
208
208
|
result = service.send_message(params[:message])
|
209
|
-
|
209
|
+
|
210
210
|
render json: {
|
211
211
|
response: result.output,
|
212
212
|
agent: result.context[:current_agent],
|
@@ -217,19 +217,19 @@ class AgentConversationsController < ApplicationController
|
|
217
217
|
render json: { error: "Unable to process your request" }, status: 500
|
218
218
|
end
|
219
219
|
end
|
220
|
-
|
220
|
+
|
221
221
|
def reset
|
222
222
|
service = AgentConversationService.new(current_user)
|
223
223
|
service.reset_conversation
|
224
|
-
|
224
|
+
|
225
225
|
render json: { message: "Conversation reset successfully" }
|
226
226
|
end
|
227
|
-
|
227
|
+
|
228
228
|
def history
|
229
229
|
conversations = Conversation.for_user(current_user)
|
230
230
|
.includes(:user)
|
231
231
|
.limit(50)
|
232
|
-
|
232
|
+
|
233
233
|
render json: conversations.map do |conv|
|
234
234
|
{
|
235
235
|
id: conv.id,
|
@@ -252,13 +252,13 @@ class CustomerLookupTool < Agents::Tool
|
|
252
252
|
name "lookup_customer"
|
253
253
|
description "Look up customer information by email or ID"
|
254
254
|
param :identifier, type: "string", desc: "Email address or customer ID"
|
255
|
-
|
255
|
+
|
256
256
|
def perform(tool_context, identifier:)
|
257
257
|
# Access Rails models safely
|
258
258
|
customer = User.find_by(email: identifier) || User.find_by(id: identifier)
|
259
|
-
|
259
|
+
|
260
260
|
return "Customer not found" unless customer
|
261
|
-
|
261
|
+
|
262
262
|
{
|
263
263
|
name: customer.name,
|
264
264
|
email: customer.email,
|
@@ -274,13 +274,13 @@ class BillingTool < Agents::Tool
|
|
274
274
|
name "get_billing_info"
|
275
275
|
description "Retrieve billing information for a customer"
|
276
276
|
param :user_id, type: "integer", desc: "Customer user ID"
|
277
|
-
|
277
|
+
|
278
278
|
def perform(tool_context, user_id:)
|
279
279
|
user = User.find(user_id)
|
280
280
|
billing_info = user.billing_profile
|
281
|
-
|
281
|
+
|
282
282
|
return "No billing information found" unless billing_info
|
283
|
-
|
283
|
+
|
284
284
|
{
|
285
285
|
plan: billing_info.plan_name,
|
286
286
|
status: billing_info.status,
|
@@ -301,13 +301,13 @@ For longer conversations, use background jobs:
|
|
301
301
|
# app/jobs/agent_conversation_job.rb
|
302
302
|
class AgentConversationJob < ApplicationJob
|
303
303
|
queue_as :default
|
304
|
-
|
304
|
+
|
305
305
|
def perform(user_id, message, conversation_id = nil)
|
306
306
|
user = User.find(user_id)
|
307
307
|
service = AgentConversationService.new(user)
|
308
|
-
|
308
|
+
|
309
309
|
result = service.send_message(message)
|
310
|
-
|
310
|
+
|
311
311
|
# Broadcast result via ActionCable
|
312
312
|
ActionCable.server.broadcast(
|
313
313
|
"agent_conversation_#{user_id}",
|
@@ -327,7 +327,7 @@ def create_async
|
|
327
327
|
params[:message],
|
328
328
|
params[:conversation_id]
|
329
329
|
)
|
330
|
-
|
330
|
+
|
331
331
|
render json: { job_id: job_id }
|
332
332
|
end
|
333
333
|
```
|
@@ -341,12 +341,12 @@ Implement comprehensive error handling:
|
|
341
341
|
class AgentConversationService
|
342
342
|
class AgentError < StandardError; end
|
343
343
|
class ContextError < StandardError; end
|
344
|
-
|
344
|
+
|
345
345
|
def send_message(message)
|
346
346
|
validate_message(message)
|
347
|
-
|
347
|
+
|
348
348
|
context = load_conversation_context
|
349
|
-
|
349
|
+
|
350
350
|
begin
|
351
351
|
result = @runner.run(message, context: context)
|
352
352
|
save_conversation(result)
|
@@ -359,9 +359,9 @@ class AgentConversationService
|
|
359
359
|
raise ContextError, "Conversation context corrupted"
|
360
360
|
end
|
361
361
|
end
|
362
|
-
|
362
|
+
|
363
363
|
private
|
364
|
-
|
364
|
+
|
365
365
|
def validate_message(message)
|
366
366
|
raise ArgumentError, "Message cannot be blank" if message.blank?
|
367
367
|
raise ArgumentError, "Message too long" if message.length > 5000
|
@@ -378,26 +378,26 @@ Test Rails integration with RSpec:
|
|
378
378
|
RSpec.describe AgentConversationService do
|
379
379
|
let(:user) { create(:user) }
|
380
380
|
let(:service) { described_class.new(user) }
|
381
|
-
|
381
|
+
|
382
382
|
describe '#send_message' do
|
383
383
|
it 'creates a conversation record' do
|
384
384
|
expect {
|
385
385
|
service.send_message("Hello")
|
386
386
|
}.to change(Conversation, :count).by(1)
|
387
387
|
end
|
388
|
-
|
388
|
+
|
389
389
|
it 'persists context correctly' do
|
390
390
|
result = service.send_message("Hello")
|
391
391
|
conversation = Conversation.last
|
392
|
-
|
392
|
+
|
393
393
|
expect(conversation.user).to eq(user)
|
394
394
|
expect(conversation.context).to include('user_id' => user.id)
|
395
395
|
end
|
396
396
|
end
|
397
|
-
|
397
|
+
|
398
398
|
describe '#reset_conversation' do
|
399
399
|
before { service.send_message("Hello") }
|
400
|
-
|
400
|
+
|
401
401
|
it 'destroys all conversations for user' do
|
402
402
|
expect {
|
403
403
|
service.reset_conversation
|
@@ -437,4 +437,4 @@ add_index :conversations, :created_at
|
|
437
437
|
every 1.day, at: '2:00 am' do
|
438
438
|
runner "Conversation.where('created_at < ?', 30.days.ago).destroy_all"
|
439
439
|
end
|
440
|
-
```
|
440
|
+
```
|
@@ -30,7 +30,7 @@ extraction_agent = Agents::Agent.new(
|
|
30
30
|
}
|
31
31
|
)
|
32
32
|
|
33
|
-
runner = Agents::
|
33
|
+
runner = Agents::Runner.with_agents(extraction_agent)
|
34
34
|
result = runner.run("I love the new product features, especially the API and dashboard!")
|
35
35
|
|
36
36
|
# Response will be valid JSON matching the schema:
|
@@ -62,7 +62,7 @@ contact_agent = Agents::Agent.new(
|
|
62
62
|
response_schema: ContactSchema
|
63
63
|
)
|
64
64
|
|
65
|
-
runner = Agents::
|
65
|
+
runner = Agents::Runner.with_agents(contact_agent)
|
66
66
|
result = runner.run("Hi, I'm Sarah Johnson from TechCorp. You can reach me at sarah@techcorp.com or 555-0123. I'm interested in AI and automation solutions.")
|
67
67
|
|
68
68
|
# Returns structured contact data:
|
data/docs/index.md
CHANGED
@@ -81,7 +81,7 @@ support = Agents::Agent.new(
|
|
81
81
|
triage.register_handoffs(support)
|
82
82
|
|
83
83
|
# Create runner and start conversation
|
84
|
-
runner = Agents::
|
84
|
+
runner = Agents::Runner.with_agents(triage, support)
|
85
85
|
result = runner.run("I need help with a technical issue")
|
86
86
|
|
87
87
|
puts result.output
|
@@ -90,7 +90,7 @@ module ISPSupport
|
|
90
90
|
|
91
91
|
Keep responses brief and professional. Use handoff tools to transfer to specialists.
|
92
92
|
|
93
|
-
Your response MUST be in the required JSON format with
|
93
|
+
Your response MUST be in the required JSON format with response, clarifying_question, needs_clarification, and intent fields.
|
94
94
|
INSTRUCTIONS
|
95
95
|
end
|
96
96
|
|
@@ -98,30 +98,17 @@ module ISPSupport
|
|
98
98
|
{
|
99
99
|
type: "object",
|
100
100
|
properties: {
|
101
|
-
|
101
|
+
response: {
|
102
102
|
type: "string",
|
103
|
-
description: "
|
103
|
+
description: "Your response to the customer"
|
104
104
|
},
|
105
|
-
|
105
|
+
intent: {
|
106
106
|
type: "string",
|
107
107
|
enum: %w[sales support unclear],
|
108
|
-
description: "The detected category
|
109
|
-
},
|
110
|
-
needs_clarification: {
|
111
|
-
type: "boolean",
|
112
|
-
description: "Whether the intent is unclear and needs clarification"
|
113
|
-
},
|
114
|
-
clarifying_question: {
|
115
|
-
type: ["string", "null"],
|
116
|
-
description: "A question to ask if the intent is unclear (null if clear)"
|
117
|
-
},
|
118
|
-
recommended_agent: {
|
119
|
-
type: ["string", "null"],
|
120
|
-
enum: ["Sales Agent", "Support Agent", null],
|
121
|
-
description: "The recommended specialist agent to route to (null if unclear)"
|
108
|
+
description: "The detected intent category"
|
122
109
|
}
|
123
110
|
},
|
124
|
-
required: %w[
|
111
|
+
required: %w[response intent],
|
125
112
|
additionalProperties: false
|
126
113
|
}
|
127
114
|
end
|
@@ -58,23 +58,13 @@ class ISPSupportDemo
|
|
58
58
|
|
59
59
|
# Handle structured output from triage agent
|
60
60
|
output = result.output || "[No output]"
|
61
|
-
if @context[:current_agent] == "Triage Agent" && output.
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
puts "🤖 #{structured["greeting"]}"
|
66
|
-
if structured["intent_category"]
|
67
|
-
puts " [Intent: #{structured["intent_category"]}, Routing to: #{structured["recommended_agent"] || "TBD"}]"
|
68
|
-
end
|
69
|
-
rescue JSON::ParserError
|
70
|
-
# Fall back to regular output if not valid JSON
|
71
|
-
puts "🤖 #{output}"
|
72
|
-
end
|
61
|
+
if @context[:current_agent] == "Triage Agent" && output.is_a?(Hash)
|
62
|
+
# Display the response from structured response
|
63
|
+
puts "🤖 #{output["response"]}"
|
64
|
+
puts "\e[2m [Intent]: #{output["intent"]}\e[0m" if output["intent"]
|
73
65
|
else
|
74
66
|
puts "🤖 #{output}"
|
75
67
|
end
|
76
|
-
|
77
|
-
puts
|
78
68
|
end
|
79
69
|
end
|
80
70
|
|
data/lib/agents/handoff.rb
CHANGED
@@ -69,11 +69,19 @@ module Agents
|
|
69
69
|
@tool_description
|
70
70
|
end
|
71
71
|
|
72
|
-
#
|
73
|
-
#
|
74
|
-
def perform(
|
75
|
-
#
|
76
|
-
|
72
|
+
# Use RubyLLM's halt mechanism to stop continuation after handoff
|
73
|
+
# Store handoff info in context for Runner to detect and process
|
74
|
+
def perform(tool_context)
|
75
|
+
# Store handoff information in context for Runner to detect
|
76
|
+
# TODO: The following is a race condition that needs to be addressed in future versions
|
77
|
+
# If multiple handoff tools execute concurrently, they overwrite each other's pending_handoff data.
|
78
|
+
tool_context.run_context.context[:pending_handoff] = {
|
79
|
+
target_agent: @target_agent,
|
80
|
+
timestamp: Time.now
|
81
|
+
}
|
82
|
+
|
83
|
+
# Return halt to stop LLM continuation
|
84
|
+
halt("I'll transfer you to #{@target_agent.name} who can better assist you with this.")
|
77
85
|
end
|
78
86
|
|
79
87
|
# NOTE: RubyLLM will handle schema generation internally when needed
|
data/lib/agents/runner.rb
CHANGED
@@ -110,17 +110,23 @@ module Agents
|
|
110
110
|
end
|
111
111
|
response = result
|
112
112
|
|
113
|
-
# Check for handoff
|
114
|
-
if response.is_a?(
|
115
|
-
|
113
|
+
# Check for handoff via RubyLLM's halt mechanism
|
114
|
+
if response.is_a?(RubyLLM::Tool::Halt) && context_wrapper.context[:pending_handoff]
|
115
|
+
handoff_info = context_wrapper.context.delete(:pending_handoff)
|
116
|
+
next_agent = handoff_info[:target_agent]
|
116
117
|
|
117
118
|
# Validate that the target agent is in our registry
|
118
119
|
# This prevents handoffs to agents that weren't explicitly provided
|
119
120
|
unless registry[next_agent.name]
|
120
121
|
puts "[Agents] Warning: Handoff to unregistered agent '#{next_agent.name}', continuing with current agent"
|
121
|
-
|
122
|
-
|
123
|
-
|
122
|
+
# Return the halt content as the final response
|
123
|
+
save_conversation_state(chat, context_wrapper, current_agent)
|
124
|
+
return RunResult.new(
|
125
|
+
output: response.content,
|
126
|
+
messages: MessageExtractor.extract_messages(chat, current_agent),
|
127
|
+
usage: context_wrapper.usage,
|
128
|
+
context: context_wrapper.context
|
129
|
+
)
|
124
130
|
end
|
125
131
|
|
126
132
|
# Save current conversation state before switching
|
@@ -143,6 +149,17 @@ module Agents
|
|
143
149
|
next
|
144
150
|
end
|
145
151
|
|
152
|
+
# Handle non-handoff halts - return the halt content as final response
|
153
|
+
if response.is_a?(RubyLLM::Tool::Halt)
|
154
|
+
save_conversation_state(chat, context_wrapper, current_agent)
|
155
|
+
return RunResult.new(
|
156
|
+
output: response.content,
|
157
|
+
messages: MessageExtractor.extract_messages(chat, current_agent),
|
158
|
+
usage: context_wrapper.usage,
|
159
|
+
context: context_wrapper.context
|
160
|
+
)
|
161
|
+
end
|
162
|
+
|
146
163
|
# If tools were called, continue the loop to let them execute
|
147
164
|
next if response.tool_call?
|
148
165
|
|
@@ -202,16 +219,23 @@ module Agents
|
|
202
219
|
next unless %i[user assistant].include?(msg[:role].to_sym)
|
203
220
|
next unless msg[:content] && !MessageExtractor.content_empty?(msg[:content])
|
204
221
|
|
205
|
-
|
222
|
+
# Extract text content safely - handle both string and hash content
|
223
|
+
content = RubyLLM::Content.new(msg[:content])
|
224
|
+
|
225
|
+
# Create a proper RubyLLM::Message and pass it to add_message
|
226
|
+
message = RubyLLM::Message.new(
|
206
227
|
role: msg[:role].to_sym,
|
207
|
-
content:
|
228
|
+
content: content
|
208
229
|
)
|
230
|
+
chat.add_message(message)
|
209
231
|
rescue StandardError => e
|
210
232
|
# Continue with partial history on error
|
211
|
-
|
233
|
+
# TODO: Remove this, and let the error propagate up the call stack
|
234
|
+
puts "[Agents] Failed to restore message: #{e.message}\n#{e.backtrace.join("\n")}"
|
212
235
|
end
|
213
236
|
rescue StandardError => e
|
214
237
|
# If history restoration completely fails, continue with empty history
|
238
|
+
# TODO: Remove this, and let the error propagate up the call stack
|
215
239
|
puts "[Agents] Failed to restore conversation history: #{e.message}"
|
216
240
|
context_wrapper.context[:conversation_history] = []
|
217
241
|
end
|
@@ -234,24 +258,29 @@ module Agents
|
|
234
258
|
# Get system prompt (may be dynamic)
|
235
259
|
system_prompt = agent.get_system_prompt(context_wrapper)
|
236
260
|
|
237
|
-
#
|
238
|
-
|
239
|
-
regular_tools = agent.tools
|
261
|
+
# Create standard RubyLLM chat
|
262
|
+
chat = RubyLLM::Chat.new(model: agent.model)
|
240
263
|
|
241
|
-
#
|
242
|
-
|
264
|
+
# Combine all tools - both handoff and regular tools need wrapping
|
265
|
+
all_tools = []
|
243
266
|
|
244
|
-
#
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
context_wrapper: context_wrapper, # Pass context directly
|
250
|
-
response_schema: agent.response_schema # Pass structured output schema
|
251
|
-
)
|
267
|
+
# Add handoff tools
|
268
|
+
agent.handoff_agents.each do |target_agent|
|
269
|
+
handoff_tool = HandoffTool.new(target_agent)
|
270
|
+
all_tools << ToolWrapper.new(handoff_tool, context_wrapper)
|
271
|
+
end
|
252
272
|
|
273
|
+
# Add regular tools
|
274
|
+
agent.tools.each do |tool|
|
275
|
+
all_tools << ToolWrapper.new(tool, context_wrapper)
|
276
|
+
end
|
277
|
+
|
278
|
+
# Configure chat with instructions, temperature, tools, and schema
|
253
279
|
chat.with_instructions(system_prompt) if system_prompt
|
254
|
-
chat.
|
280
|
+
chat.with_temperature(agent.temperature) if agent.temperature
|
281
|
+
chat.with_tools(*all_tools) if all_tools.any?
|
282
|
+
chat.with_schema(agent.response_schema) if agent.response_schema
|
283
|
+
|
255
284
|
chat
|
256
285
|
end
|
257
286
|
end
|
data/lib/agents/version.rb
CHANGED
data/lib/agents.rb
CHANGED
@@ -114,7 +114,6 @@ require_relative "agents/handoff"
|
|
114
114
|
require_relative "agents/agent"
|
115
115
|
|
116
116
|
# Execution components
|
117
|
-
require_relative "agents/chat"
|
118
117
|
require_relative "agents/tool_wrapper"
|
119
118
|
require_relative "agents/message_extractor"
|
120
119
|
require_relative "agents/callback_manager"
|
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.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Shivam Mishra
|
@@ -15,14 +15,14 @@ dependencies:
|
|
15
15
|
requirements:
|
16
16
|
- - "~>"
|
17
17
|
- !ruby/object:Gem::Version
|
18
|
-
version:
|
18
|
+
version: 1.6.0
|
19
19
|
type: :runtime
|
20
20
|
prerelease: false
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
22
22
|
requirements:
|
23
23
|
- - "~>"
|
24
24
|
- !ruby/object:Gem::Version
|
25
|
-
version:
|
25
|
+
version: 1.6.0
|
26
26
|
description: Ruby AI Agents SDK enables creating complex AI workflows with multi-agent
|
27
27
|
orchestration, tool execution, safety guardrails, and provider-agnostic LLM integration.
|
28
28
|
email:
|
@@ -101,7 +101,6 @@ files:
|
|
101
101
|
- lib/agents/agent_runner.rb
|
102
102
|
- lib/agents/agent_tool.rb
|
103
103
|
- lib/agents/callback_manager.rb
|
104
|
-
- lib/agents/chat.rb
|
105
104
|
- lib/agents/handoff.rb
|
106
105
|
- lib/agents/message_extractor.rb
|
107
106
|
- lib/agents/result.rb
|
data/lib/agents/chat.rb
DELETED
@@ -1,161 +0,0 @@
|
|
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, temperature: nil, response_schema: nil,
|
30
|
-
**options)
|
31
|
-
super(model: model, **options)
|
32
|
-
@handoff_tools = handoff_tools
|
33
|
-
@context_wrapper = context_wrapper
|
34
|
-
|
35
|
-
# Set temperature if provided (RubyLLM::Chat sets this via accessor)
|
36
|
-
@temperature = temperature if temperature
|
37
|
-
|
38
|
-
# Set response schema if provided
|
39
|
-
with_schema(response_schema) if response_schema
|
40
|
-
|
41
|
-
# Register handoff tools with RubyLLM for schema generation
|
42
|
-
@handoff_tools.each { |tool| with_tool(tool) }
|
43
|
-
end
|
44
|
-
|
45
|
-
# Override the problematic auto-execution method from RubyLLM::Chat
|
46
|
-
def complete(&block)
|
47
|
-
@on[:new_message]&.call
|
48
|
-
response = @provider.complete(
|
49
|
-
messages,
|
50
|
-
tools: @tools,
|
51
|
-
temperature: @temperature,
|
52
|
-
model: @model.id,
|
53
|
-
connection: @connection,
|
54
|
-
params: @params,
|
55
|
-
schema: @schema,
|
56
|
-
&block
|
57
|
-
)
|
58
|
-
@on[:end_message]&.call(response)
|
59
|
-
|
60
|
-
# Handle JSON parsing for structured output (like RubyLLM::Chat)
|
61
|
-
if @schema && response.content.is_a?(String)
|
62
|
-
begin
|
63
|
-
response.content = JSON.parse(response.content)
|
64
|
-
rescue JSON::ParserError
|
65
|
-
# If parsing fails, keep content as string
|
66
|
-
end
|
67
|
-
end
|
68
|
-
|
69
|
-
add_message(response)
|
70
|
-
|
71
|
-
if response.tool_call?
|
72
|
-
handle_tools_with_handoff_detection(response, &block)
|
73
|
-
else
|
74
|
-
response
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
private
|
79
|
-
|
80
|
-
def handle_tools_with_handoff_detection(response, &block)
|
81
|
-
handoff_calls, regular_calls = classify_tool_calls(response.tool_calls)
|
82
|
-
|
83
|
-
if handoff_calls.any?
|
84
|
-
# Execute first handoff only
|
85
|
-
handoff_result = execute_handoff_tool(handoff_calls.first)
|
86
|
-
|
87
|
-
# Add tool result to conversation
|
88
|
-
add_tool_result(handoff_calls.first.id, handoff_result[:message])
|
89
|
-
|
90
|
-
# Return handoff response to signal agent switch (ends turn)
|
91
|
-
HandoffResponse.new(
|
92
|
-
target_agent: handoff_result[:target_agent],
|
93
|
-
response: response,
|
94
|
-
handoff_message: handoff_result[:message]
|
95
|
-
)
|
96
|
-
else
|
97
|
-
# Use RubyLLM's original tool execution for regular tools
|
98
|
-
execute_regular_tools_and_continue(regular_calls, &block)
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
def classify_tool_calls(tool_calls)
|
103
|
-
handoff_tool_names = @handoff_tools.map(&:name).map(&:to_s)
|
104
|
-
|
105
|
-
handoff_calls = []
|
106
|
-
regular_calls = []
|
107
|
-
|
108
|
-
tool_calls.each_value do |tool_call|
|
109
|
-
if handoff_tool_names.include?(tool_call.name)
|
110
|
-
handoff_calls << tool_call
|
111
|
-
else
|
112
|
-
regular_calls << tool_call
|
113
|
-
end
|
114
|
-
end
|
115
|
-
|
116
|
-
[handoff_calls, regular_calls]
|
117
|
-
end
|
118
|
-
|
119
|
-
def execute_handoff_tool(tool_call)
|
120
|
-
tool = @handoff_tools.find { |t| t.name.to_s == tool_call.name }
|
121
|
-
raise "Handoff tool not found: #{tool_call.name}" unless tool
|
122
|
-
|
123
|
-
# Execute the handoff tool directly with context
|
124
|
-
tool_context = ToolContext.new(run_context: @context_wrapper)
|
125
|
-
result = tool.execute(tool_context, **{}) # Handoff tools take no additional params
|
126
|
-
|
127
|
-
{
|
128
|
-
target_agent: tool.target_agent,
|
129
|
-
message: result.to_s
|
130
|
-
}
|
131
|
-
end
|
132
|
-
|
133
|
-
def execute_regular_tools_and_continue(tool_calls, &block)
|
134
|
-
# Execute each regular tool call
|
135
|
-
tool_calls.each do |tool_call|
|
136
|
-
@on[:new_message]&.call
|
137
|
-
result = execute_tool(tool_call)
|
138
|
-
message = add_tool_result(tool_call.id, result)
|
139
|
-
@on[:end_message]&.call(message)
|
140
|
-
end
|
141
|
-
|
142
|
-
# Continue conversation after tool execution
|
143
|
-
complete(&block)
|
144
|
-
end
|
145
|
-
|
146
|
-
# Reuse RubyLLM's existing tool execution logic
|
147
|
-
def execute_tool(tool_call)
|
148
|
-
tool = tools[tool_call.name.to_sym]
|
149
|
-
args = tool_call.arguments
|
150
|
-
tool.call(args)
|
151
|
-
end
|
152
|
-
|
153
|
-
def add_tool_result(tool_use_id, result)
|
154
|
-
add_message(
|
155
|
-
role: :tool,
|
156
|
-
content: result.is_a?(Hash) && result[:error] ? result[:error] : result.to_s,
|
157
|
-
tool_call_id: tool_use_id
|
158
|
-
)
|
159
|
-
end
|
160
|
-
end
|
161
|
-
end
|