ai-agents 0.6.0 → 0.7.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: c143345c7d0dd3a91ff483e0db22a7d23f6c85393275727eddcaf288a1341901
4
+ data.tar.gz: 0f1abfe692571706be41b85a1da924a717f980b8721b21ece7dd62ad4e995fbf
5
5
  SHA512:
6
- metadata.gz: ba5050ba743466993826d888673d90696d27f422df3d3972027fa141de05d6436c45cd44dba0836dc6350ad83c0ff1628ba43bbce6d93e5e49e8efc450554e91
7
- data.tar.gz: efed7a181a6f52c4c8feb8b84e1bbb2309b5216a85569dae69dd4038cde0cdad7a283e066181414e50657aee063e79935573799fdc24abc6253b02bcb8e5d09f
6
+ metadata.gz: 74c96799a138a5e725b3e889eba22c45e9581e7606a0cd235cecd50b8bdde1c6f10281ec1516fde77f48373c39022d2f72172be8c29489260df79fb575d92de8
7
+ data.tar.gz: 257fd42a178107182a53eb737848ae1a023c28712ac450c4603b12cb60e2ad39dbe7b957a324bd6c24a3a175af8b7c373ec30e25e36425e81642a8f160da7ceb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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.7.0] - 2025-10-16
9
+
10
+ ### Added
11
+ - **Lifecycle Callback Hooks**: New callbacks for complete execution visibility and observability integration
12
+ - Added `on_run_start` callback triggered before agent execution begins with agent name, input, and run context
13
+ - Added `on_run_complete` callback triggered after execution ends (success or failure) with agent name, result, and run context
14
+ - Added `on_agent_complete` callback triggered after each agent turn with agent name, result, error (if any), and run context
15
+ - Run context parameter enables storing and retrieving custom data (e.g., span context, trace IDs) throughout execution
16
+ - Designed for integration with observability platforms (OpenTelemetry, Datadog, New Relic, etc.)
17
+ - All callbacks are thread-safe and non-blocking with proper error handling
18
+ - Updated callback documentation with integration patterns for UI feedback, logging, and metrics
19
+
20
+ ### Changed
21
+ - CallbackManager now supports 7 event types (previously 4)
22
+ - Enhanced callback system to provide complete lifecycle coverage for monitoring and tracing
23
+
8
24
  ## [0.6.0] - 2025-10-16
9
25
 
10
26
  ### 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,78 @@
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
+ return nil unless msg.content && !content_empty?(msg.content)
56
58
 
57
- message = {
58
- role: msg.role,
59
- content: msg.content
60
- }
59
+ message = {
60
+ role: msg.role,
61
+ content: msg.content
62
+ }
61
63
 
62
- if msg.role == :assistant
63
- # Add agent attribution for conversation continuity
64
- message[:agent_name] = current_agent.name if current_agent
64
+ if msg.role == :assistant
65
+ # Add agent attribution for conversation continuity
66
+ message[:agent_name] = current_agent.name if current_agent
65
67
 
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)
71
- end
72
- end
68
+ # Add tool calls if present
69
+ if msg.tool_call? && msg.tool_calls
70
+ # RubyLLM stores tool_calls as Hash with call_id => ToolCall object
71
+ # Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
72
+ message[:tool_calls] = msg.tool_calls.values.map(&:to_h)
73
+ end
74
+ end
73
75
 
74
- message
75
- end
76
- private_class_method :extract_user_or_assistant_message
76
+ message
77
+ end
78
+ private_class_method :extract_user_or_assistant_message
77
79
 
78
- def extract_tool_message(msg)
79
- return nil unless msg.tool_result?
80
+ def extract_tool_message(msg)
81
+ return nil unless msg.tool_result?
80
82
 
81
- {
82
- role: msg.role,
83
- content: msg.content,
84
- tool_call_id: msg.tool_call_id
85
- }
83
+ {
84
+ role: msg.role,
85
+ content: msg.content,
86
+ tool_call_id: msg.tool_call_id
87
+ }
88
+ end
89
+ private_class_method :extract_tool_message
90
+ end
86
91
  end
87
- private_class_method :extract_tool_message
88
92
  end
data/lib/agents/runner.rb CHANGED
@@ -90,6 +90,9 @@ module Agents
90
90
  context_wrapper = RunContext.new(context_copy, callbacks: callbacks)
91
91
  current_turn = 0
92
92
 
93
+ # Emit run start event
94
+ context_wrapper.callback_manager.emit_run_start(current_agent.name, input, context_wrapper)
95
+
93
96
  runtime_headers = Helpers::Headers.normalize(headers)
94
97
  agent_headers = Helpers::Headers.normalize(current_agent.headers)
95
98
 
@@ -100,7 +103,6 @@ module Agents
100
103
  configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
101
104
  restore_conversation_history(chat, context_wrapper)
102
105
 
103
-
104
106
  loop do
105
107
  current_turn += 1
106
108
  raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
@@ -127,18 +129,28 @@ module Agents
127
129
  unless registry[next_agent.name]
128
130
  save_conversation_state(chat, context_wrapper, current_agent)
129
131
  error = AgentNotFoundError.new("Handoff failed: Agent '#{next_agent.name}' not found in registry")
130
- return RunResult.new(
132
+
133
+ result = RunResult.new(
131
134
  output: nil,
132
135
  messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
133
136
  usage: context_wrapper.usage,
134
137
  context: context_wrapper.context,
135
138
  error: error
136
139
  )
140
+
141
+ # Emit agent complete and run complete events with error
142
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, error, context_wrapper)
143
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
144
+
145
+ return result
137
146
  end
138
147
 
139
148
  # Save current conversation state before switching
140
149
  save_conversation_state(chat, context_wrapper, current_agent)
141
150
 
151
+ # Emit agent complete event before handoff
152
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, nil, nil, context_wrapper)
153
+
142
154
  # Emit agent handoff event
143
155
  context_wrapper.callback_manager.emit_agent_handoff(current_agent.name, next_agent.name, "handoff")
144
156
 
@@ -161,12 +173,19 @@ module Agents
161
173
  # Handle non-handoff halts - return the halt content as final response
162
174
  if response.is_a?(RubyLLM::Tool::Halt)
163
175
  save_conversation_state(chat, context_wrapper, current_agent)
164
- return RunResult.new(
176
+
177
+ result = RunResult.new(
165
178
  output: response.content,
166
179
  messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
167
180
  usage: context_wrapper.usage,
168
181
  context: context_wrapper.context
169
182
  )
183
+
184
+ # Emit agent complete and run complete events
185
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, nil, context_wrapper)
186
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
187
+
188
+ return result
170
189
  end
171
190
 
172
191
  # If tools were called, continue the loop to let them execute
@@ -177,35 +196,53 @@ module Agents
177
196
  # Save final state before returning
178
197
  save_conversation_state(chat, context_wrapper, current_agent)
179
198
 
180
- return RunResult.new(
199
+ result = RunResult.new(
181
200
  output: response.content,
182
201
  messages: Helpers::MessageExtractor.extract_messages(chat, current_agent),
183
202
  usage: context_wrapper.usage,
184
203
  context: context_wrapper.context
185
204
  )
205
+
206
+ # Emit agent complete and run complete events
207
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, nil, context_wrapper)
208
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
209
+
210
+ return result
186
211
  end
187
212
  rescue MaxTurnsExceeded => e
188
213
  # Save state even on error
189
214
  save_conversation_state(chat, context_wrapper, current_agent) if chat
190
215
 
191
- RunResult.new(
216
+ result = RunResult.new(
192
217
  output: "Conversation ended: #{e.message}",
193
218
  messages: chat ? Helpers::MessageExtractor.extract_messages(chat, current_agent) : [],
194
219
  usage: context_wrapper.usage,
195
220
  error: e,
196
221
  context: context_wrapper.context
197
222
  )
223
+
224
+ # Emit agent complete and run complete events with error
225
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, e, context_wrapper)
226
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
227
+
228
+ result
198
229
  rescue StandardError => e
199
230
  # Save state even on error
200
231
  save_conversation_state(chat, context_wrapper, current_agent) if chat
201
232
 
202
- RunResult.new(
233
+ result = RunResult.new(
203
234
  output: nil,
204
235
  messages: chat ? Helpers::MessageExtractor.extract_messages(chat, current_agent) : [],
205
236
  usage: context_wrapper.usage,
206
237
  error: e,
207
238
  context: context_wrapper.context
208
239
  )
240
+
241
+ # Emit agent complete and run complete events with error
242
+ context_wrapper.callback_manager.emit_agent_complete(current_agent.name, result, e, context_wrapper)
243
+ context_wrapper.callback_manager.emit_run_complete(current_agent.name, result, context_wrapper)
244
+
245
+ result
209
246
  end
210
247
 
211
248
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shivam Mishra