ai-agents 0.6.0 → 0.8.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: 7f8fa7ec73784bc0fb1e9e6bd4852c6d0295160c4ca28d435c895cba28f5cd58
4
- data.tar.gz: 3e489f11ae2a5c93b4232ec78e3c7385c83f303c28039ed35fa082669fedaf4b
3
+ metadata.gz: 6391e30443ff9e226e6b3bf3f629f3cd996cbb7fe4479c7a7dc437c82544ae2f
4
+ data.tar.gz: 22fc6ee4f3130006c1dd1f6c8fb704a176457d35602a434088d25cea5dd3c949
5
5
  SHA512:
6
- metadata.gz: ba5050ba743466993826d888673d90696d27f422df3d3972027fa141de05d6436c45cd44dba0836dc6350ad83c0ff1628ba43bbce6d93e5e49e8efc450554e91
7
- data.tar.gz: efed7a181a6f52c4c8feb8b84e1bbb2309b5216a85569dae69dd4038cde0cdad7a283e066181414e50657aee063e79935573799fdc24abc6253b02bcb8e5d09f
6
+ metadata.gz: ae3866cfbec885088c5b41b0e91bbc8532fc3115ed981c3428f56c09b40c1a75bc9bb1277ed1991f19cee4c43118c3f9a5d9ea8d6ecc07d29ad03117a77666e6
7
+ data.tar.gz: b7de2e98dc3ce52b4c80b7b1bcfa4fb1f07f62176d861ab61b9a1092d2de4273657d54edfa18d6afd83acccee21c89e465f18539b8966c5f9645a673b4e5a3c4
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [0.8.0] - 2026-01-07
11
+
12
+ ### Added
13
+ - **Provider Smoke Tests**: Comprehensive smoke tests for validating against the latest RubyLLM version
14
+
15
+ ### Changed
16
+ - **RubyLLM Update**: Updated to latest RubyLLM version with improved API integration and tool call handling
17
+
18
+ ### Fixed
19
+ - **Tool Message Restoration**: Fixed conversation history restoration to properly handle tool calls and results
20
+
21
+ ## [0.7.0] - 2025-10-16
22
+
23
+ ### Added
24
+ - **Lifecycle Callback Hooks**: New callbacks for complete execution visibility and observability integration
25
+ - Added `on_run_start` callback triggered before agent execution begins with agent name, input, and run context
26
+ - Added `on_run_complete` callback triggered after execution ends (success or failure) with agent name, result, and run context
27
+ - Added `on_agent_complete` callback triggered after each agent turn with agent name, result, error (if any), and run context
28
+ - Run context parameter enables storing and retrieving custom data (e.g., span context, trace IDs) throughout execution
29
+ - Designed for integration with observability platforms (OpenTelemetry, Datadog, New Relic, etc.)
30
+ - All callbacks are thread-safe and non-blocking with proper error handling
31
+ - Updated callback documentation with integration patterns for UI feedback, logging, and metrics
32
+
33
+ ### Changed
34
+ - CallbackManager now supports 7 event types (previously 4)
35
+ - Enhanced callback system to provide complete lifecycle coverage for monitoring and tracing
36
+
8
37
  ## [0.6.0] - 2025-10-16
9
38
 
10
39
  ### Added
@@ -11,7 +11,13 @@ The AI Agents SDK provides real-time callbacks that allow you to monitor agent e
11
11
 
12
12
  ## Available Callbacks
13
13
 
14
- The SDK provides four types of callbacks that give you visibility into different stages of agent execution:
14
+ The SDK provides seven types of callbacks that give you visibility into different stages of agent execution:
15
+
16
+ **Run Start** - Triggered before agent execution begins. Receives the agent name, input message, and run context.
17
+
18
+ **Run Complete** - Called after agent execution ends (whether successful or failed). Receives the agent name, result object, and run context.
19
+
20
+ **Agent Complete** - Triggered after each agent turn finishes. Receives the agent name, result, error (if any), and run context.
15
21
 
16
22
  **Agent Thinking** - Triggered when an agent is about to make an LLM call. Useful for showing "thinking" indicators in UIs.
17
23
 
@@ -27,6 +33,9 @@ Callbacks are registered on the AgentRunner using chainable methods:
27
33
 
28
34
  ```ruby
29
35
  runner = Agents::Runner.with_agents(triage, support)
36
+ .on_run_start { |agent, input, ctx| puts "Starting: #{agent}" }
37
+ .on_run_complete { |agent, result, ctx| puts "Completed: #{agent}" }
38
+ .on_agent_complete { |agent, result, error, ctx| puts "Agent done: #{agent}" }
30
39
  .on_agent_thinking { |agent, input| puts "#{agent} thinking..." }
31
40
  .on_tool_start { |tool, args| puts "Using #{tool}" }
32
41
  .on_tool_complete { |tool, result| puts "#{tool} completed" }
@@ -35,7 +44,32 @@ runner = Agents::Runner.with_agents(triage, support)
35
44
 
36
45
  ## Integration Patterns
37
46
 
38
- Callbacks work well with real-time web frameworks like Rails ActionCable, allowing you to stream agent status updates directly to browser clients. They're also useful for logging, metrics collection, and building debug interfaces.
47
+ ### UI Feedback
48
+
49
+ Callbacks work well with real-time web frameworks like Rails ActionCable, allowing you to stream agent status updates directly to browser clients:
50
+
51
+ ```ruby
52
+ runner = Agents::Runner.with_agents(agent)
53
+ .on_agent_thinking { |agent, input|
54
+ ActionCable.server.broadcast("agent_#{user_id}", { type: 'thinking', agent: agent })
55
+ }
56
+ .on_tool_start { |tool, args|
57
+ ActionCable.server.broadcast("agent_#{user_id}", { type: 'tool', name: tool })
58
+ }
59
+ ```
60
+
61
+ ### Logging & Metrics
62
+
63
+ Callbacks are also useful for structured logging and metrics collection:
64
+
65
+ ```ruby
66
+ runner = Agents::Runner.with_agents(agent)
67
+ .on_run_start { |agent, input, ctx| logger.info("Run started", agent: agent) }
68
+ .on_tool_start { |tool, args| metrics.increment("tool.calls", tags: ["tool:#{tool}"]) }
69
+ .on_agent_complete do |agent, result, error, ctx|
70
+ logger.error("Agent failed", agent: agent, error: error) if error
71
+ end
72
+ ```
39
73
 
40
74
  ## Thread Safety
41
75
 
@@ -44,6 +44,9 @@ module Agents
44
44
 
45
45
  # Initialize callback storage - use thread-safe arrays
46
46
  @callbacks = {
47
+ run_start: [],
48
+ run_complete: [],
49
+ agent_complete: [],
47
50
  tool_start: [],
48
51
  tool_complete: [],
49
52
  agent_thinking: [],
@@ -125,6 +128,42 @@ module Agents
125
128
  self
126
129
  end
127
130
 
131
+ # Register a callback for run start events.
132
+ # Called before agent execution begins.
133
+ #
134
+ # @param block [Proc] Callback block that receives (agent, input, run_context)
135
+ # @return [self] For method chaining
136
+ def on_run_start(&block)
137
+ return self unless block
138
+
139
+ @callbacks_mutex.synchronize { @callbacks[:run_start] << block }
140
+ self
141
+ end
142
+
143
+ # Register a callback for run complete events.
144
+ # Called after agent execution ends (success or error).
145
+ #
146
+ # @param block [Proc] Callback block that receives (agent, result, run_context)
147
+ # @return [self] For method chaining
148
+ def on_run_complete(&block)
149
+ return self unless block
150
+
151
+ @callbacks_mutex.synchronize { @callbacks[:run_complete] << block }
152
+ self
153
+ end
154
+
155
+ # Register a callback for agent complete events.
156
+ # Called after each agent turn finishes.
157
+ #
158
+ # @param block [Proc] Callback block that receives (agent_name, result, error, run_context)
159
+ # @return [self] For method chaining
160
+ def on_agent_complete(&block)
161
+ return self unless block
162
+
163
+ @callbacks_mutex.synchronize { @callbacks[:agent_complete] << block }
164
+ self
165
+ end
166
+
128
167
  private
129
168
 
130
169
  # Build agent registry from provided agents only.
@@ -13,6 +13,9 @@ module Agents
13
13
  class CallbackManager
14
14
  # Supported callback event types
15
15
  EVENT_TYPES = %i[
16
+ run_start
17
+ run_complete
18
+ agent_complete
16
19
  tool_start
17
20
  tool_complete
18
21
  agent_thinking
@@ -1,29 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Agents::Helpers::Headers
4
- module_function
3
+ module Agents
4
+ module Helpers
5
+ module Headers
6
+ module_function
5
7
 
6
- def normalize(headers, freeze_result: false)
7
- return freeze_result ? {}.freeze : {} if headers.nil? || (headers.respond_to?(:empty?) && headers.empty?)
8
+ def normalize(headers, freeze_result: false)
9
+ return freeze_result ? {}.freeze : {} if headers.nil? || (headers.respond_to?(:empty?) && headers.empty?)
8
10
 
9
- hash = headers.respond_to?(:to_h) ? headers.to_h : headers
10
- raise ArgumentError, "headers must be a Hash or respond to #to_h" unless hash.is_a?(Hash)
11
+ hash = headers.respond_to?(:to_h) ? headers.to_h : headers
12
+ raise ArgumentError, "headers must be a Hash or respond to #to_h" unless hash.is_a?(Hash)
11
13
 
12
- result = symbolize_keys(hash)
13
- freeze_result ? result.freeze : result
14
- end
14
+ result = symbolize_keys(hash)
15
+ freeze_result ? result.freeze : result
16
+ end
15
17
 
16
- def merge(agent_headers, runtime_headers)
17
- return runtime_headers if agent_headers.empty?
18
- return agent_headers if runtime_headers.empty?
18
+ def merge(agent_headers, runtime_headers)
19
+ return runtime_headers if agent_headers.empty?
20
+ return agent_headers if runtime_headers.empty?
19
21
 
20
- agent_headers.merge(runtime_headers) { |_key, _agent_value, runtime_value| runtime_value }
21
- end
22
+ agent_headers.merge(runtime_headers) { |_key, _agent_value, runtime_value| runtime_value }
23
+ end
22
24
 
23
- def symbolize_keys(hash)
24
- hash.each_with_object({}) do |(key, value), memo|
25
- memo[key.is_a?(Symbol) ? key : key.to_sym] = value
25
+ def symbolize_keys(hash)
26
+ hash.transform_keys do |key|
27
+ key.is_a?(Symbol) ? key : key.to_sym
28
+ end
29
+ end
30
+ private_class_method :symbolize_keys
26
31
  end
27
32
  end
28
- private_class_method :symbolize_keys
29
33
  end
@@ -15,74 +15,87 @@
15
15
  # { role: :assistant, content: "Hi!", agent_name: "Support", tool_calls: [...] },
16
16
  # { role: :tool, content: "Result", tool_call_id: "call_123" }
17
17
  # ]
18
- module Agents::Helpers::MessageExtractor
19
- module_function
18
+ module Agents
19
+ module Helpers
20
+ module MessageExtractor
21
+ module_function
20
22
 
21
- # Check if content is considered empty (handles both String and Hash content)
22
- #
23
- # @param content [String, Hash, nil] The content to check
24
- # @return [Boolean] true if content is empty, false otherwise
25
- def content_empty?(content)
26
- case content
27
- when String
28
- content.strip.empty?
29
- when Hash
30
- content.empty?
31
- else
32
- content.nil?
33
- end
34
- end
23
+ # Check if content is considered empty (handles both String and Hash content)
24
+ #
25
+ # @param content [String, Hash, nil] The content to check
26
+ # @return [Boolean] true if content is empty, false otherwise
27
+ def content_empty?(content)
28
+ case content
29
+ when String
30
+ content.strip.empty?
31
+ when Hash
32
+ content.empty?
33
+ else
34
+ content.nil?
35
+ end
36
+ end
35
37
 
36
- # Extract messages from a chat object for conversation history persistence
37
- #
38
- # @param chat [Object] Chat object that responds to :messages
39
- # @param current_agent [Agent] The agent currently handling the conversation
40
- # @return [Array<Hash>] Array of message hashes suitable for persistence
41
- def extract_messages(chat, current_agent)
42
- return [] unless chat.respond_to?(:messages)
38
+ # Extract messages from a chat object for conversation history persistence
39
+ #
40
+ # @param chat [Object] Chat object that responds to :messages
41
+ # @param current_agent [Agent] The agent currently handling the conversation
42
+ # @return [Array<Hash>] Array of message hashes suitable for persistence
43
+ def extract_messages(chat, current_agent)
44
+ return [] unless chat.respond_to?(:messages)
43
45
 
44
- chat.messages.filter_map do |msg|
45
- case msg.role
46
- when :user, :assistant
47
- extract_user_or_assistant_message(msg, current_agent)
48
- when :tool
49
- extract_tool_message(msg)
46
+ chat.messages.filter_map do |msg|
47
+ case msg.role
48
+ when :user, :assistant
49
+ extract_user_or_assistant_message(msg, current_agent)
50
+ when :tool
51
+ extract_tool_message(msg)
52
+ end
53
+ end
50
54
  end
51
- end
52
- end
53
55
 
54
- def extract_user_or_assistant_message(msg, current_agent)
55
- return nil unless msg.content && !content_empty?(msg.content)
56
+ def extract_user_or_assistant_message(msg, current_agent)
57
+ content_present = message_content?(msg)
58
+ tool_calls_present = assistant_tool_calls?(msg)
59
+ return nil unless content_present || tool_calls_present
60
+
61
+ message = {
62
+ role: msg.role,
63
+ content: content_present ? msg.content : ""
64
+ }
56
65
 
57
- message = {
58
- role: msg.role,
59
- content: msg.content
60
- }
66
+ return message unless msg.role == :assistant
61
67
 
62
- if msg.role == :assistant
63
- # Add agent attribution for conversation continuity
64
- message[:agent_name] = current_agent.name if current_agent
68
+ message[:agent_name] = current_agent.name if current_agent
65
69
 
66
- # Add tool calls if present
67
- if msg.tool_call? && msg.tool_calls
68
- # RubyLLM stores tool_calls as Hash with call_id => ToolCall object
69
- # Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
70
- message[:tool_calls] = msg.tool_calls.values.map(&:to_h)
70
+ if tool_calls_present
71
+ # RubyLLM stores tool_calls as Hash with call_id => ToolCall object
72
+ # Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
73
+ message[:tool_calls] = msg.tool_calls.values.map(&:to_h)
74
+ end
75
+
76
+ message
71
77
  end
72
- end
73
78
 
74
- message
75
- end
76
- private_class_method :extract_user_or_assistant_message
79
+ def message_content?(msg)
80
+ msg.content && !content_empty?(msg.content)
81
+ end
82
+
83
+ def assistant_tool_calls?(msg)
84
+ msg.role == :assistant && msg.tool_call? && msg.tool_calls && !msg.tool_calls.empty?
85
+ end
77
86
 
78
- def extract_tool_message(msg)
79
- return nil unless msg.tool_result?
87
+ def extract_tool_message(msg)
88
+ return nil unless msg.tool_result?
80
89
 
81
- {
82
- role: msg.role,
83
- content: msg.content,
84
- tool_call_id: msg.tool_call_id
85
- }
90
+ {
91
+ role: msg.role,
92
+ content: msg.content,
93
+ tool_call_id: msg.tool_call_id
94
+ }
95
+ end
96
+
97
+ private_class_method :extract_user_or_assistant_message, :message_content?, :assistant_tool_calls?,
98
+ :extract_tool_message
99
+ end
86
100
  end
87
- private_class_method :extract_tool_message
88
101
  end
@@ -94,15 +94,21 @@ module Agents
94
94
  end
95
95
 
96
96
  # Add usage metrics from an LLM response to the running totals.
97
- # Safely handles nil values in the usage object.
97
+ # Only tracks usage for responses that have token data (e.g., RubyLLM::Message).
98
+ # Safely skips responses without token methods (e.g., RubyLLM::Tool::Halt).
98
99
  #
99
- # @param usage [Object] An object responding to input_tokens, output_tokens, and total_tokens
100
+ # @param response [RubyLLM::Message] A RubyLLM::Message object with token usage data
100
101
  # @example Adding usage from an LLM response
101
- # usage.add(llm_response.usage)
102
- def add(usage)
103
- @input_tokens += usage.input_tokens || 0
104
- @output_tokens += usage.output_tokens || 0
105
- @total_tokens += usage.total_tokens || 0
102
+ # usage.add(llm_response)
103
+ def add(response)
104
+ return unless response.respond_to?(:input_tokens)
105
+
106
+ input = response.input_tokens || 0
107
+ output = response.output_tokens || 0
108
+
109
+ @input_tokens += input
110
+ @output_tokens += output
111
+ @total_tokens += input + output
106
112
  end
107
113
  end
108
114
  end
data/lib/agents/runner.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Agents
4
6
  # The execution engine that orchestrates conversations between users and agents.
5
7
  # Runner manages the conversation flow, handles tool execution through RubyLLM,
@@ -90,6 +92,9 @@ module Agents
90
92
  context_wrapper = RunContext.new(context_copy, callbacks: callbacks)
91
93
  current_turn = 0
92
94
 
95
+ # Emit run start event
96
+ context_wrapper.callback_manager.emit_run_start(current_agent.name, input, context_wrapper)
97
+
93
98
  runtime_headers = Helpers::Headers.normalize(headers)
94
99
  agent_headers = Helpers::Headers.normalize(current_agent.headers)
95
100
 
@@ -100,7 +105,6 @@ module Agents
100
105
  configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
101
106
  restore_conversation_history(chat, context_wrapper)
102
107
 
103
-
104
108
  loop do
105
109
  current_turn += 1
106
110
  raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
@@ -116,6 +120,7 @@ module Agents
116
120
  chat.complete
117
121
  end
118
122
  response = result
123
+ track_usage(response, context_wrapper)
119
124
 
120
125
  # Check for handoff via RubyLLM's halt mechanism
121
126
  if response.is_a?(RubyLLM::Tool::Halt) && context_wrapper.context[:pending_handoff]
@@ -127,18 +132,28 @@ module Agents
127
132
  unless registry[next_agent.name]
128
133
  save_conversation_state(chat, context_wrapper, current_agent)
129
134
  error = AgentNotFoundError.new("Handoff failed: Agent '#{next_agent.name}' not found in registry")
130
- return RunResult.new(
135
+
136
+ result = RunResult.new(
131
137
  output: nil,
132
138
  messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
133
139
  usage: context_wrapper.usage,
134
140
  context: context_wrapper.context,
135
141
  error: error
136
142
  )
143
+
144
+ # Emit agent complete and run complete events with error
145
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, error, context_wrapper)
146
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
147
+
148
+ return result
137
149
  end
138
150
 
139
151
  # Save current conversation state before switching
140
152
  save_conversation_state(chat, context_wrapper, current_agent)
141
153
 
154
+ # Emit agent complete event before handoff
155
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, nil, nil, context_wrapper)
156
+
142
157
  # Emit agent handoff event
143
158
  context_wrapper.callback_manager.emit_agent_handoff(current_agent.name, next_agent.name, "handoff")
144
159
 
@@ -161,12 +176,19 @@ module Agents
161
176
  # Handle non-handoff halts - return the halt content as final response
162
177
  if response.is_a?(RubyLLM::Tool::Halt)
163
178
  save_conversation_state(chat, context_wrapper, current_agent)
164
- return RunResult.new(
179
+
180
+ result = RunResult.new(
165
181
  output: response.content,
166
182
  messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
167
183
  usage: context_wrapper.usage,
168
184
  context: context_wrapper.context
169
185
  )
186
+
187
+ # Emit agent complete and run complete events
188
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, nil, context_wrapper)
189
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
190
+
191
+ return result
170
192
  end
171
193
 
172
194
  # If tools were called, continue the loop to let them execute
@@ -177,35 +199,53 @@ module Agents
177
199
  # Save final state before returning
178
200
  save_conversation_state(chat, context_wrapper, current_agent)
179
201
 
180
- return RunResult.new(
202
+ result = RunResult.new(
181
203
  output: response.content,
182
204
  messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
183
205
  usage: context_wrapper.usage,
184
206
  context: context_wrapper.context
185
207
  )
208
+
209
+ # Emit agent complete and run complete events
210
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, nil, context_wrapper)
211
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
212
+
213
+ return result
186
214
  end
187
215
  rescue MaxTurnsExceeded => e
188
216
  # Save state even on error
189
217
  save_conversation_state(chat, context_wrapper, current_agent) if chat
190
218
 
191
- RunResult.new(
219
+ result = RunResult.new(
192
220
  output: "Conversation ended: #{e.message}",
193
221
  messages: chat ? Helpers::MessageExtractor.extract_messages(chat, current_agent) : [],
194
222
  usage: context_wrapper.usage,
195
223
  error: e,
196
224
  context: context_wrapper.context
197
225
  )
226
+
227
+ # Emit agent complete and run complete events with error
228
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, e, context_wrapper)
229
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
230
+
231
+ result
198
232
  rescue StandardError => e
199
233
  # Save state even on error
200
234
  save_conversation_state(chat, context_wrapper, current_agent) if chat
201
235
 
202
- RunResult.new(
236
+ result = RunResult.new(
203
237
  output: nil,
204
238
  messages: chat ? Helpers::MessageExtractor.extract_messages(chat, current_agent) : [],
205
239
  usage: context_wrapper.usage,
206
240
  error: e,
207
241
  context: context_wrapper.context
208
242
  )
243
+
244
+ # Emit agent complete and run complete events with error
245
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, e, context_wrapper)
246
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
247
+
248
+ result
209
249
  end
210
250
 
211
251
  private
@@ -227,26 +267,98 @@ module Agents
227
267
 
228
268
  # Restores conversation history from context into RubyLLM chat.
229
269
  # Converts stored message hashes back into RubyLLM::Message objects with proper content handling.
270
+ # Supports user, assistant, and tool role messages for complete conversation continuity.
230
271
  #
231
272
  # @param chat [RubyLLM::Chat] The chat instance to restore history into
232
273
  # @param context_wrapper [RunContext] Context containing conversation history
233
274
  def restore_conversation_history(chat, context_wrapper)
234
275
  history = context_wrapper.context[:conversation_history] || []
276
+ valid_tool_call_ids = Set.new
235
277
 
236
278
  history.each do |msg|
237
- # Only restore user and assistant messages with content
238
- next unless %i[user assistant].include?(msg[:role].to_sym)
239
- next unless msg[:content] && !Helpers::MessageExtractor.content_empty?(msg[:content])
279
+ next unless restorable_message?(msg)
240
280
 
241
- # Extract text content safely - handle both string and hash content
242
- content = RubyLLM::Content.new(msg[:content])
281
+ if msg[:role].to_sym == :tool &&
282
+ msg[:tool_call_id] &&
283
+ !valid_tool_call_ids.include?(msg[:tool_call_id])
284
+ Agents.logger&.warn("Skipping tool message without matching assistant tool_call_id #{msg[:tool_call_id]}")
285
+ next
286
+ end
243
287
 
244
- # Create a proper RubyLLM::Message and pass it to add_message
245
- message = RubyLLM::Message.new(
246
- role: msg[:role].to_sym,
247
- content: content
248
- )
288
+ message_params = build_message_params(msg)
289
+ next unless message_params # Skip invalid messages
290
+
291
+ message = RubyLLM::Message.new(**message_params)
249
292
  chat.add_message(message)
293
+
294
+ if message.role == :assistant && message_params[:tool_calls]
295
+ valid_tool_call_ids.merge(message_params[:tool_calls].keys)
296
+ end
297
+ end
298
+ end
299
+
300
+ # Check if a message should be restored
301
+ def restorable_message?(msg)
302
+ role = msg[:role].to_sym
303
+ return false unless %i[user assistant tool].include?(role)
304
+
305
+ # Allow assistant messages that only contain tool calls (no text content)
306
+ tool_calls_present = role == :assistant && msg[:tool_calls] && !msg[:tool_calls].empty?
307
+ return false if role != :tool && !tool_calls_present &&
308
+ Helpers::MessageExtractor.content_empty?(msg[:content])
309
+
310
+ true
311
+ end
312
+
313
+ # Build message parameters for restoration
314
+ def build_message_params(msg)
315
+ role = msg[:role].to_sym
316
+
317
+ content_value = msg[:content]
318
+ # Assistant tool-call messages may have empty text, but still need placeholder content
319
+ content_value = "" if content_value.nil? && role == :assistant && msg[:tool_calls]&.any?
320
+
321
+ params = {
322
+ role: role,
323
+ content: RubyLLM::Content.new(content_value)
324
+ }
325
+
326
+ # Handle tool-specific parameters (Tool Results)
327
+ if role == :tool
328
+ return nil unless valid_tool_message?(msg)
329
+
330
+ params[:tool_call_id] = msg[:tool_call_id]
331
+ end
332
+
333
+ # FIX: Restore tool_calls on assistant messages
334
+ # This is required by OpenAI/Anthropic API contracts to link
335
+ # subsequent tool result messages back to this request.
336
+ if role == :assistant && msg[:tool_calls] && !msg[:tool_calls].empty?
337
+ # Convert stored array of hashes back into the Hash format RubyLLM expects
338
+ # RubyLLM stores tool_calls as: { call_id => ToolCall_object, ... }
339
+ # Reference: openai/tools.rb:35 uses hash iteration |_, tc|
340
+ params[:tool_calls] = msg[:tool_calls].each_with_object({}) do |tc, hash|
341
+ tool_call_id = tc[:id] || tc["id"]
342
+ next unless tool_call_id
343
+
344
+ hash[tool_call_id] = RubyLLM::ToolCall.new(
345
+ id: tool_call_id,
346
+ name: tc[:name] || tc["name"],
347
+ arguments: tc[:arguments] || tc["arguments"] || {}
348
+ )
349
+ end
350
+ end
351
+
352
+ params
353
+ end
354
+
355
+ # Validate tool message has required tool_call_id
356
+ def valid_tool_message?(msg)
357
+ if msg[:tool_call_id]
358
+ true
359
+ else
360
+ Agents.logger&.warn("Skipping tool message without tool_call_id in conversation history")
361
+ false
250
362
  end
251
363
  end
252
364
 
@@ -303,6 +415,12 @@ module Agents
303
415
  chat.with_headers(**headers)
304
416
  end
305
417
 
418
+ def track_usage(response, context_wrapper)
419
+ return unless context_wrapper&.usage
420
+
421
+ context_wrapper.usage.add(response)
422
+ end
423
+
306
424
  # Builds thread-safe tool wrappers for an agent's tools and handoff tools.
307
425
  #
308
426
  # @param agent [Agents::Agent] The agent whose tools to wrap
@@ -73,6 +73,15 @@ module Agents
73
73
  @tool.parameters
74
74
  end
75
75
 
76
+ # Expose params schema for RubyLLM providers that expect it
77
+ def params_schema
78
+ @tool.respond_to?(:params_schema) ? @tool.params_schema : nil
79
+ end
80
+
81
+ def provider_params
82
+ @tool.respond_to?(:provider_params) ? @tool.provider_params : {}
83
+ end
84
+
76
85
  # Make this work with RubyLLM's tool calling
77
86
  def to_s
78
87
  name
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
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.6.0
4
+ version: 0.8.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.8.2
18
+ version: 1.9.1
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.8.2
25
+ version: 1.9.1
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: