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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3f2287a007ce187c91546ee4ef723cadcde40e7102454d4d432c2c2c72e4e45
4
- data.tar.gz: 4c9b810362d923c0fb821491a63fa8647c926e2666e6902383200ca8f988cf10
3
+ metadata.gz: d07ac97ca06177ee4504601099af6bc6ba3e4d8557b28d34cd3222240b27575d
4
+ data.tar.gz: cd0c7121918c8a28c3760325c9acd319e86d349f34fb715d6b953912a99f98a8
5
5
  SHA512:
6
- metadata.gz: aaa5ba1612a2551b83d3f779a10301c806f9a95eb082b4c6ab6db56fdc90487093b0a5edd0c5edc16c2a4888e1ada2abc66433bab73c569886b3b66f0c41d528
7
- data.tar.gz: 2669853cd34f497d696fba2ccf02ed384057ea144b7ffdd0c390aa28c1c4bf830929723f181a0bfd130e206707fc80f9c2a0e12259da53099ef8ab92c5f681d8
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::AgentRunner.with_agents(triage, billing, support)
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..." }
@@ -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::AgentRunner.with_agents(triage, support)
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::AgentRunner.with_agents(triage_agent, billing_agent, support_agent)
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::AgentRunner.with_agents(triage_agent, billing_agent, support_agent)
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::AgentRunner.with_agents(agent) }
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::AgentRunner.with_agents(triage_agent, billing_agent, support_agent)
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::AgentRunner.with_agents(extraction_agent)
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::AgentRunner.with_agents(contact_agent)
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::AgentRunner.with_agents(triage, support)
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 greeting, intent_category, needs_clarification, clarifying_question, and recommended_agent fields.
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
- greeting: {
101
+ response: {
102
102
  type: "string",
103
- description: "A brief, friendly greeting acknowledging the customer's inquiry"
103
+ description: "Your response to the customer"
104
104
  },
105
- intent_category: {
105
+ intent: {
106
106
  type: "string",
107
107
  enum: %w[sales support unclear],
108
- description: "The detected category of the customer's intent"
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[greeting intent_category needs_clarification],
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.start_with?("{")
62
- begin
63
- structured = JSON.parse(output)
64
- # Display the greeting from structured response
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
 
@@ -69,11 +69,19 @@ module Agents
69
69
  @tool_description
70
70
  end
71
71
 
72
- # Handoff tools now work with the extended Chat class for proper handoff handling
73
- # No longer need context signaling - the Chat class detects handoffs directly
74
- def perform(_tool_context)
75
- # Simply return the transfer message - Chat class will handle the handoff
76
- "I'll transfer you to #{@target_agent.name} who can better assist you with this."
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 response from our extended chat
114
- if response.is_a?(Agents::Chat::HandoffResponse)
115
- next_agent = response.target_agent
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
- next if response.tool_call?
122
-
123
- next
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
- chat.add_message(
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: msg[: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
- puts "[Agents] Failed to restore message: #{e.message}"
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
- # Separate handoff tools from regular tools
238
- handoff_tools = agent.handoff_agents.map { |target_agent| HandoffTool.new(target_agent) }
239
- regular_tools = agent.tools
261
+ # Create standard RubyLLM chat
262
+ chat = RubyLLM::Chat.new(model: agent.model)
240
263
 
241
- # Only wrap regular tools - handoff tools will be handled directly by Chat
242
- wrapped_regular_tools = regular_tools.map { |tool| ToolWrapper.new(tool, context_wrapper) }
264
+ # Combine all tools - both handoff and regular tools need wrapping
265
+ all_tools = []
243
266
 
244
- # Create extended chat with handoff awareness and context
245
- chat = Agents::Chat.new(
246
- model: agent.model,
247
- temperature: agent.temperature,
248
- handoff_tools: handoff_tools, # Direct tools, no wrapper
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.with_tools(*wrapped_regular_tools) if wrapped_regular_tools.any?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.4.3"
4
+ VERSION = "0.5.0"
5
5
  end
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.3
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: '1.3'
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: '1.3'
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