ai-agents 0.12.0 → 0.13.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/CLAUDE.md +1 -1
  4. data/README.md +3 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/concepts/agents.md +16 -0
  7. data/docs/concepts/tools.md +1 -1
  8. data/docs/guides/instrumentation.md +54 -5
  9. data/docs/guides/rails-integration.md +2 -2
  10. data/docs/guides/state-persistence.md +2 -2
  11. data/docs/guides/structured-output.md +3 -3
  12. data/examples/collaborative-copilot/tools/create_linear_ticket_tool.rb +5 -5
  13. data/examples/collaborative-copilot/tools/get_article_tool.rb +1 -1
  14. data/examples/collaborative-copilot/tools/get_contact_tool.rb +1 -1
  15. data/examples/collaborative-copilot/tools/get_conversation_tool.rb +1 -1
  16. data/examples/collaborative-copilot/tools/get_stripe_billing_tool.rb +1 -1
  17. data/examples/collaborative-copilot/tools/search_contacts_tool.rb +3 -2
  18. data/examples/collaborative-copilot/tools/search_conversations_tool.rb +2 -2
  19. data/examples/collaborative-copilot/tools/search_knowledge_base_tool.rb +4 -3
  20. data/examples/collaborative-copilot/tools/search_linear_issues_tool.rb +5 -4
  21. data/examples/isp-support/agents_factory.rb +4 -4
  22. data/examples/isp-support/tools/create_checkout_tool.rb +1 -1
  23. data/examples/isp-support/tools/create_lead_tool.rb +3 -3
  24. data/examples/isp-support/tools/crm_lookup_tool.rb +1 -1
  25. data/examples/isp-support/tools/search_docs_tool.rb +1 -1
  26. data/lib/agents/agent.rb +19 -6
  27. data/lib/agents/agent_tool.rb +1 -1
  28. data/lib/agents/handoff.rb +2 -5
  29. data/lib/agents/helpers/message_extractor.rb +24 -8
  30. data/lib/agents/instrumentation/constants.rb +2 -0
  31. data/lib/agents/instrumentation/tracing_callbacks.rb +53 -24
  32. data/lib/agents/run_context.rb +5 -4
  33. data/lib/agents/runner.rb +76 -57
  34. data/lib/agents/tool.rb +8 -1
  35. data/lib/agents/tool_wrapper.rb +16 -13
  36. data/lib/agents/version.rb +1 -1
  37. data/lib/agents.rb +4 -2
  38. metadata +3 -3
@@ -76,20 +76,21 @@ module Agents
76
76
  end
77
77
 
78
78
  def extract_user_or_assistant_message(msg, current_agent)
79
- content_present = message_content?(msg)
80
79
  tool_calls_present = assistant_tool_calls?(msg)
81
- return nil unless content_present || tool_calls_present
80
+ return nil unless extractable_message?(msg, tool_calls_present)
82
81
 
83
- message = {
84
- role: msg.role,
85
- content: content_present ? msg.content : ""
86
- }
82
+ message = { role: msg.role, content: persisted_content(msg) }
87
83
 
88
84
  return message unless msg.role == :assistant
89
85
 
90
86
  attributed_agent_name = attributed_agent_name_for(msg) || current_agent&.name
91
87
  message[:agent_name] = attributed_agent_name if attributed_agent_name
92
88
 
89
+ if msg.respond_to?(:thinking) && msg.thinking&.signature
90
+ message[:thinking] = msg.thinking.text if msg.thinking.text
91
+ message[:thinking_signature] = msg.thinking.signature
92
+ end
93
+
93
94
  if tool_calls_present
94
95
  # RubyLLM stores tool_calls as Hash with call_id => ToolCall object
95
96
  # Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
@@ -99,6 +100,21 @@ module Agents
99
100
  message
100
101
  end
101
102
 
103
+ def persisted_content(msg)
104
+ content = message_content?(msg) ? msg.content : ""
105
+ return content if msg.attachments.empty?
106
+
107
+ parts = content.to_s.empty? ? [] : [{ type: "text", text: content }]
108
+ parts + msg.attachments.map do |attachment|
109
+ { type: "image_url", image_url: { url: attachment.source.to_s } }
110
+ end
111
+ end
112
+
113
+ def extractable_message?(msg, tool_calls_present)
114
+ message_content?(msg) || tool_calls_present || msg.attachments.any? ||
115
+ (msg.role == :assistant && msg.respond_to?(:thinking) && msg.thinking&.signature)
116
+ end
117
+
102
118
  def message_content?(msg)
103
119
  msg.content && !content_empty?(msg.content)
104
120
  end
@@ -117,8 +133,8 @@ module Agents
117
133
  }
118
134
  end
119
135
 
120
- private_class_method :extract_user_or_assistant_message, :message_content?, :assistant_tool_calls?,
121
- :extract_tool_message
136
+ private_class_method :extract_user_or_assistant_message, :persisted_content, :extractable_message?,
137
+ :message_content?, :assistant_tool_calls?, :extract_tool_message
122
138
  end
123
139
  end
124
140
  end
@@ -19,6 +19,7 @@ module Agents
19
19
  ATTR_GEN_AI_PROVIDER = "gen_ai.provider.name"
20
20
  ATTR_GEN_AI_USAGE_INPUT = "gen_ai.usage.input_tokens"
21
21
  ATTR_GEN_AI_USAGE_OUTPUT = "gen_ai.usage.output_tokens"
22
+ ATTR_GEN_AI_USAGE_REASONING_OUTPUT = "gen_ai.usage.reasoning.output_tokens"
22
23
 
23
24
  # Langfuse trace-level attributes
24
25
  ATTR_LANGFUSE_USER_ID = "langfuse.user.id"
@@ -34,6 +35,7 @@ module Agents
34
35
  ATTR_LANGFUSE_OBS_INPUT = "langfuse.observation.input"
35
36
  ATTR_LANGFUSE_OBS_OUTPUT = "langfuse.observation.output"
36
37
  ATTR_LANGFUSE_OBS_METADATA_PREFIX = "langfuse.observation.metadata."
38
+ ATTR_LANGFUSE_REASONING_SUMMARY = "langfuse.observation.metadata.reasoning_summary"
37
39
  end
38
40
  end
39
41
  end
@@ -60,6 +60,8 @@ module Agents
60
60
  return unless tracing
61
61
 
62
62
  tracing[:pending_llm_input] = serialize_output(input)
63
+ # The response hook runs after the provider call; preserve its real start time.
64
+ tracing[:llm_started_at] = Time.now
63
65
 
64
66
  return if tracing[:current_agent_name] == agent_name
65
67
 
@@ -77,14 +79,18 @@ module Agents
77
79
  finish_agent_span(tracing)
78
80
  end
79
81
 
80
- def on_chat_created(chat, agent_name, model, context_wrapper, temperature = nil)
82
+ def on_chat_created(chat, _agent_name, model, context_wrapper, temperature = nil, protocol = nil, thinking = nil)
81
83
  tracing = tracing_state(context_wrapper)
82
84
  return unless tracing
83
85
 
84
- request_attributes = { model: model, temperature: temperature }
86
+ tracing[:request_attributes] = { model: model, temperature: temperature,
87
+ protocol: protocol, thinking: thinking }
88
+ return if tracing[:instrumented_chat].equal?(chat)
85
89
 
86
- chat.on_end_message do |message|
87
- handle_end_message(chat, agent_name, request_attributes, message, context_wrapper)
90
+ tracing[:instrumented_chat] = chat
91
+
92
+ chat.after_message do |message|
93
+ handle_end_message(chat, message, context_wrapper)
88
94
  end
89
95
  end
90
96
 
@@ -109,7 +115,7 @@ module Agents
109
115
  tracing[:current_tool_span] = tool_span
110
116
  end
111
117
 
112
- def on_tool_complete(_tool_name, result, context_wrapper)
118
+ def on_tool_complete(_tool_name, result, context_wrapper, error = nil)
113
119
  tracing = tracing_state(context_wrapper)
114
120
  return unless tracing
115
121
 
@@ -117,6 +123,10 @@ module Agents
117
123
  return unless tool_span
118
124
 
119
125
  tool_span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, serialize_output(result))
126
+ if error
127
+ tool_span.record_exception(error)
128
+ tool_span.status = OpenTelemetry::Trace::Status.error(error.message)
129
+ end
120
130
  tool_span.finish
121
131
  tracing[:current_tool_span] = nil
122
132
  end
@@ -153,7 +163,7 @@ module Agents
153
163
 
154
164
  private
155
165
 
156
- def handle_end_message(chat, _agent_name, request_attributes, message, context_wrapper)
166
+ def handle_end_message(chat, message, context_wrapper)
157
167
  return unless message.respond_to?(:role) && message.role == :assistant
158
168
 
159
169
  tracing = tracing_state(context_wrapper)
@@ -162,13 +172,14 @@ module Agents
162
172
  llm_span = @tracer.start_span(
163
173
  @llm_span_name,
164
174
  with_parent: parent_context(tracing),
175
+ start_timestamp: tracing.delete(:llm_started_at),
165
176
  attributes: generation_span_attributes(tracing, chat, message, context_wrapper)
166
177
  )
167
178
 
168
- set_llm_request_attributes(llm_span, request_attributes)
179
+ set_llm_request_attributes(llm_span, tracing[:request_attributes])
169
180
 
170
181
  output = llm_output_text(message)
171
- set_llm_response_attributes(llm_span, message, output)
182
+ set_llm_response_attributes(llm_span, message, output, tracing[:request_attributes])
172
183
  tracing[:last_agent_output] = output unless output.empty?
173
184
 
174
185
  llm_span.finish
@@ -218,16 +229,23 @@ module Agents
218
229
  root_span.status = OpenTelemetry::Trace::Status.error(error.message)
219
230
  end
220
231
 
221
- def set_llm_response_attributes(span, response, output)
222
- if response.respond_to?(:input_tokens) && response.input_tokens
223
- span.set_attribute(ATTR_GEN_AI_USAGE_INPUT, response.input_tokens)
224
- end
225
- if response.respond_to?(:output_tokens) && response.output_tokens
226
- span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT, response.output_tokens)
227
- end
232
+ def set_llm_response_attributes(span, response, output, request_attributes)
233
+ tokens = response.tokens if response.respond_to?(:tokens)
234
+ span.set_attribute(ATTR_GEN_AI_USAGE_INPUT, tokens.input) if tokens&.input
235
+ span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT, tokens.output) if tokens&.output
236
+ span.set_attribute(ATTR_GEN_AI_USAGE_REASONING_OUTPUT, tokens.thinking) if tokens&.thinking
237
+ set_reasoning_summary(span, response, request_attributes)
228
238
  span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, output) unless output.empty?
229
239
  end
230
240
 
241
+ def set_reasoning_summary(span, response, request_attributes)
242
+ return unless request_attributes[:protocol] == :responses
243
+ return unless request_attributes.dig(:thinking, :display).to_s == "summarized"
244
+ return unless response.respond_to?(:thinking) && response.thinking&.text
245
+
246
+ span.set_attribute(ATTR_LANGFUSE_REASONING_SUMMARY, response.thinking.text)
247
+ end
248
+
231
249
  # Returns serialized text content if present, otherwise falls back to tool call formatting.
232
250
  # Uses .to_json for Hash/Array (structured output) to avoid Ruby's .to_s format.
233
251
  def llm_output_text(response)
@@ -252,6 +270,7 @@ module Agents
252
270
  def format_single_message(msg)
253
271
  text = serialize_output(msg.content)
254
272
  text = append_tool_calls(msg, text)
273
+ text = append_attachment_summary(text, msg.attachments) if msg.respond_to?(:attachments)
255
274
  { role: msg.role.to_s, content: text }
256
275
  end
257
276
 
@@ -265,7 +284,16 @@ module Agents
265
284
  def serialize_output(value)
266
285
  return serialize_multimodal_content(value) if multimodal_content?(value)
267
286
 
268
- value.is_a?(Hash) || value.is_a?(Array) ? value.to_json : value.to_s
287
+ return value.map { |part| redact_image_source(part) }.to_json if value.is_a?(Array)
288
+
289
+ value.is_a?(Hash) ? redact_image_source(value).to_json : value.to_s
290
+ end
291
+
292
+ def redact_image_source(part)
293
+ return part unless part.is_a?(Hash) && (part[:type] || part["type"]) == "image_url"
294
+
295
+ key = part.key?(:image_url) ? :image_url : "image_url"
296
+ part.merge(key => "[image]")
269
297
  end
270
298
 
271
299
  def format_tool_calls(response)
@@ -426,16 +454,17 @@ module Agents
426
454
  end
427
455
 
428
456
  def serialize_multimodal_content(content)
429
- parts = []
430
- text = content.text
431
- parts << text if text && !text.empty?
457
+ append_attachment_summary(content.text.to_s, content.attachments)
458
+ end
432
459
 
433
- if content.attachments&.any?
434
- urls = content.attachments.map { |a| a.respond_to?(:source) ? a.source.to_s : a.to_s }
435
- parts << "Attachments: #{urls.join(", ")}"
436
- end
460
+ def append_attachment_summary(text, attachments)
461
+ return text unless attachments&.any?
437
462
 
438
- parts.join("\n")
463
+ types = attachments.map do |attachment|
464
+ type = attachment.mime_type if attachment.respond_to?(:mime_type)
465
+ type.to_s.empty? ? "file" : type
466
+ end
467
+ [text, "Attachments: #{types.join(", ")}"].reject(&:empty?).join("\n")
439
468
  end
440
469
  end
441
470
  end
@@ -95,16 +95,17 @@ module Agents
95
95
 
96
96
  # Add usage metrics from an LLM response to the running totals.
97
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
+ # Safely skips values without token usage.
99
99
  #
100
100
  # @param response [RubyLLM::Message] A RubyLLM::Message object with token usage data
101
101
  # @example Adding usage from an LLM response
102
102
  # usage.add(llm_response)
103
103
  def add(response)
104
- return unless response.respond_to?(:input_tokens)
104
+ tokens = response.tokens if response.respond_to?(:tokens)
105
+ return unless tokens
105
106
 
106
- input = response.input_tokens || 0
107
- output = response.output_tokens || 0
107
+ input = tokens.input || 0
108
+ output = tokens.output || 0
108
109
 
109
110
  @input_tokens += input
110
111
  @output_tokens += output
data/lib/agents/runner.rb CHANGED
@@ -103,11 +103,7 @@ module Agents
103
103
  agent_params = Helpers::HashNormalizer.normalize(current_agent.params, label: "params")
104
104
 
105
105
  # Create chat and restore conversation history
106
- chat = RubyLLM::Chat.new(
107
- model: current_agent.model,
108
- provider: current_agent.provider,
109
- assume_model_exists: current_agent.assume_model_exists
110
- )
106
+ chat = build_chat(current_agent)
111
107
  current_headers = Helpers::HashNormalizer.merge(agent_headers, runtime_headers)
112
108
  current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
113
109
  apply_headers(chat, current_headers)
@@ -115,28 +111,24 @@ module Agents
115
111
  configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
116
112
  restore_conversation_history(chat, context_wrapper)
117
113
  input_already_in_history = last_message_matches?(chat, input)
114
+ unless input_already_in_history
115
+ content, attachments = build_content(input)
116
+ chat.ask_later(content, with: attachments)
117
+ end
118
118
  context_wrapper.callback_manager.emit_chat_created(
119
- chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature
119
+ chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature,
120
+ current_agent.protocol, current_agent.thinking
120
121
  )
121
122
 
122
123
  loop do
123
124
  current_turn += 1
124
125
  raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
125
126
 
126
- # Get response from LLM (RubyLLM handles tool execution with halting based handoff detection)
127
+ # One provider request per turn. Run tools separately so handoffs stop before another request.
127
128
  message_count_before_response = chat_message_count(chat)
128
- response = if current_turn == 1
129
- # Emit agent thinking event for initial message
130
- context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input, context_wrapper)
131
- # If conversation history already ends with this user message (e.g. passed
132
- # in via context from an external system), use complete to avoid duplicating it.
133
- input_already_in_history ? chat.complete : chat.ask(input)
134
- else
135
- # Emit agent thinking event for continuation
136
- context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, "(continuing conversation)",
137
- context_wrapper)
138
- chat.complete
139
- end
129
+ thinking_input = current_turn == 1 ? input : "(continuing conversation)"
130
+ context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, thinking_input, context_wrapper)
131
+ response = chat.generate
140
132
  assign_agent_name_to_new_assistant_messages(chat, current_agent, message_count_before_response)
141
133
  track_usage(response, context_wrapper)
142
134
 
@@ -145,8 +137,9 @@ module Agents
145
137
  current_agent.name, current_agent.model, response, context_wrapper
146
138
  )
147
139
 
148
- # Check for handoff via RubyLLM's halt mechanism
149
- if response.is_a?(RubyLLM::Tool::Halt) && context_wrapper.context[:pending_handoff]
140
+ chat.run_tools if response.tool_call?
141
+
142
+ if context_wrapper.context[:pending_handoff]
150
143
  handoff_info = context_wrapper.context.delete(:pending_handoff)
151
144
  next_agent = handoff_info[:target_agent]
152
145
 
@@ -171,16 +164,20 @@ module Agents
171
164
  current_agent = next_agent
172
165
  context_wrapper.context[:current_agent] = next_agent.name
173
166
 
174
- # Reconfigure existing chat for new agent - preserves conversation history automatically
175
- configure_chat_for_agent(chat, current_agent, context_wrapper, replace: true)
167
+ # A fresh chat prevents protocol and thinking settings leaking across agents.
168
+ next_chat = build_chat(current_agent)
169
+ next_chat.messages = chat.messages
170
+ chat = next_chat
171
+ configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
176
172
  agent_headers = Helpers::HashNormalizer.normalize(current_agent.headers, label: "headers")
177
173
  current_headers = Helpers::HashNormalizer.merge(agent_headers, runtime_headers)
178
- apply_headers(chat, current_headers)
174
+ chat.with_headers(current_headers)
179
175
  agent_params = Helpers::HashNormalizer.normalize(current_agent.params, label: "params")
180
176
  current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
181
- apply_params(chat, current_params)
177
+ chat.with_provider_options(current_params)
182
178
  context_wrapper.callback_manager.emit_chat_created(
183
- chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature
179
+ chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature,
180
+ current_agent.protocol, current_agent.thinking
184
181
  )
185
182
 
186
183
  # Force the new agent to respond to the conversation context
@@ -189,16 +186,12 @@ module Agents
189
186
  next
190
187
  end
191
188
 
192
- # Handle non-handoff halts - return the halt content as final response
193
- if response.is_a?(RubyLLM::Tool::Halt)
194
- return finalize_run(chat, context_wrapper, current_agent, output: response.content)
195
- end
196
-
197
- # If tools were called, continue the loop to let them execute
189
+ # A tool result needs another provider request under the turn limit.
198
190
  next if response.tool_call?
199
191
 
200
192
  # If no tools were called, we have our final response
201
- return finalize_run(chat, context_wrapper, current_agent, output: response.content)
193
+ output = current_agent.response_schema ? response.parsed : response.content
194
+ return finalize_run(chat, context_wrapper, current_agent, output: output)
202
195
  end
203
196
  rescue MaxTurnsExceeded => e
204
197
  finalize_run(chat, context_wrapper, current_agent,
@@ -209,8 +202,13 @@ module Agents
209
202
 
210
203
  private
211
204
 
205
+ def build_chat(agent)
206
+ RubyLLM::Chat.new(model: agent.model, provider: agent.provider, protocol: agent.protocol,
207
+ assume_model_exists: agent.assume_model_exists)
208
+ end
209
+
212
210
  # Saves conversation state, builds a RunResult, emits completion callbacks, and returns it.
213
- # Centralises the finalize-and-return pattern used by the normal path, halt path, and error rescues.
211
+ # Centralises the finalize-and-return pattern used by the normal path and error rescues.
214
212
  #
215
213
  # @param chat [RubyLLM::Chat, nil] The chat instance (nil in early-failure rescues)
216
214
  # @param context_wrapper [RunContext] Context wrapper for state and callbacks
@@ -260,7 +258,8 @@ module Agents
260
258
  history = context_wrapper.context[:conversation_history] || []
261
259
  valid_tool_call_ids = Set.new
262
260
 
263
- history.each do |msg|
261
+ history.each_with_index do |msg, index|
262
+ msg = with_completed_tool_calls(history, index, msg)
264
263
  next unless restorable_message?(msg)
265
264
 
266
265
  if msg[:role].to_sym == :tool &&
@@ -283,15 +282,26 @@ module Agents
283
282
  end
284
283
  end
285
284
 
285
+ def with_completed_tool_calls(history, index, msg)
286
+ return msg unless msg[:role].to_sym == :assistant && msg[:tool_calls]&.any?
287
+
288
+ msg.merge(tool_calls: completed_tool_calls(history, index, msg[:tool_calls]))
289
+ end
290
+
291
+ def completed_tool_calls(history, index, tool_calls)
292
+ following_results = history[(index + 1)..].take_while { |entry| entry[:role].to_sym == :tool }
293
+ following_ids = following_results.map { |entry| entry[:tool_call_id] }.to_set
294
+ tool_calls.select { |call| following_ids.include?(call[:id] || call["id"]) }
295
+ end
296
+
286
297
  # Check if a message should be restored
287
298
  def restorable_message?(msg)
288
299
  role = msg[:role].to_sym
289
300
  return false unless %i[user assistant tool].include?(role)
290
301
 
291
302
  # Allow assistant messages that only contain tool calls (no text content)
292
- tool_calls_present = role == :assistant && msg[:tool_calls] && !msg[:tool_calls].empty?
293
- return false if role != :tool && !tool_calls_present &&
294
- Helpers::MessageExtractor.content_empty?(msg[:content])
303
+ assistant_payload = role == :assistant && (msg[:tool_calls]&.any? || msg[:thinking_signature])
304
+ return false if role != :tool && !assistant_payload && Helpers::MessageExtractor.content_empty?(msg[:content])
295
305
 
296
306
  true
297
307
  end
@@ -304,10 +314,12 @@ module Agents
304
314
  # Assistant tool-call messages may have empty text, but still need placeholder content
305
315
  content_value = "" if content_value.nil? && role == :assistant && msg[:tool_calls]&.any?
306
316
 
307
- params = {
308
- role: role,
309
- content: build_content(content_value)
310
- }
317
+ content, attachments = build_content(content_value)
318
+ params = { role: role, content: content }
319
+ params[:attachments] = attachments if attachments.any?
320
+ if role == :assistant && msg[:thinking_signature]
321
+ params[:thinking] = { text: msg[:thinking], signature: msg[:thinking_signature] }
322
+ end
311
323
 
312
324
  # Handle tool-specific parameters (Tool Results)
313
325
  if role == :tool
@@ -330,7 +342,8 @@ module Agents
330
342
  hash[tool_call_id] = RubyLLM::ToolCall.new(
331
343
  id: tool_call_id,
332
344
  name: tc[:name] || tc["name"],
333
- arguments: tc[:arguments] || tc["arguments"] || {}
345
+ arguments: tc[:arguments] || tc["arguments"] || {},
346
+ thought_signature: tc[:thought_signature] || tc["thought_signature"]
334
347
  )
335
348
  end
336
349
  end
@@ -338,10 +351,11 @@ module Agents
338
351
  params
339
352
  end
340
353
 
341
- # Build RubyLLM::Content from stored content, handling multimodal arrays with image attachments.
354
+ # Split stored content into RubyLLM 2 text and attachments.
342
355
  # Multimodal arrays follow the OpenAI content format: [{type: 'text', text: '...'}, {type: 'image_url', ...}]
343
356
  def build_content(content_value)
344
- return RubyLLM::Content.new(content_value) unless content_value.is_a?(Array)
357
+ return [content_value.to_json, []] if content_value.is_a?(Hash)
358
+ return [content_value, []] unless content_value.is_a?(Array)
345
359
 
346
360
  text_parts = content_value.filter_map { |p| p[:text] || p["text"] if (p[:type] || p["type"]) == "text" }
347
361
  image_urls = content_value.filter_map do |p|
@@ -350,10 +364,9 @@ module Agents
350
364
  p.dig(:image_url, :url) || p.dig("image_url", "url")
351
365
  end
352
366
 
353
- return RubyLLM::Content.new(content_value.to_json) if text_parts.empty? && image_urls.empty?
367
+ return [content_value.to_json, []] if text_parts.empty? && image_urls.empty?
354
368
 
355
- text = text_parts.join(" ")
356
- image_urls.any? ? RubyLLM::Content.new(text, image_urls) : RubyLLM::Content.new(text)
369
+ [text_parts.join(" "), image_urls]
357
370
  end
358
371
 
359
372
  # Validate tool message has required tool_call_id
@@ -414,7 +427,7 @@ module Agents
414
427
  end
415
428
 
416
429
  # Configures a RubyLLM chat instance with agent-specific settings.
417
- # Uses RubyLLM's replace option to swap agent context while preserving conversation history during handoffs.
430
+ # Swaps agent settings while preserving conversation history during handoffs.
418
431
  #
419
432
  # @param chat [RubyLLM::Chat] The chat instance to configure
420
433
  # @param agent [Agents::Agent] The agent whose configuration to apply
@@ -433,15 +446,18 @@ module Agents
433
446
  chat.with_model(
434
447
  agent.model,
435
448
  provider: agent.provider,
436
- assume_exists: agent.assume_model_exists
449
+ protocol: agent.protocol,
450
+ assume_model_exists: agent.assume_model_exists
437
451
  )
438
452
  end
439
453
 
440
454
  # Configure chat with instructions, temperature, tools, and schema
441
- chat.with_instructions(system_prompt, replace: replace) if system_prompt
442
- chat.with_temperature(agent.temperature) if agent.temperature
443
- chat.with_tools(*all_tools, replace: replace)
444
- chat.with_schema(agent.response_schema) if agent.response_schema
455
+ chat.with_instructions(system_prompt)
456
+ chat.with_temperature(agent.temperature)
457
+ chat.with_thinking(**agent.thinking) if agent.thinking
458
+ chat.with_tools(nil) if replace
459
+ chat.with_tools(*all_tools)
460
+ chat.with_schema(agent.response_schema)
445
461
 
446
462
  chat
447
463
  end
@@ -458,19 +474,22 @@ module Agents
458
474
  return false unless input && chat.respond_to?(:messages)
459
475
 
460
476
  last_msg = chat.messages.last
461
- last_msg && last_msg.role == :user && last_msg.content.to_s == input.to_s
477
+ return false unless last_msg&.role == :user
478
+
479
+ content, attachments = build_content(input)
480
+ last_msg.content == content && last_msg.attachments.map { |attachment| attachment.source.to_s } == attachments
462
481
  end
463
482
 
464
483
  def apply_headers(chat, headers)
465
484
  return if headers.empty?
466
485
 
467
- chat.with_headers(**headers)
486
+ chat.with_headers(headers)
468
487
  end
469
488
 
470
489
  def apply_params(chat, params)
471
490
  return if params.empty?
472
491
 
473
- chat.with_params(**params)
492
+ chat.with_provider_options(params)
474
493
  end
475
494
 
476
495
  def track_usage(response, context_wrapper)
data/lib/agents/tool.rb CHANGED
@@ -20,7 +20,7 @@
20
20
  # class WeatherTool < Agents::Tool
21
21
  # name "get_weather"
22
22
  # description "Get current weather for a location"
23
- # param :location, type: "string", desc: "City name or coordinates"
23
+ # parameter :location, type: "string", description: "City name or coordinates"
24
24
  #
25
25
  # def perform(tool_context, location:)
26
26
  # # All state comes from parameters - no instance variables!
@@ -38,6 +38,13 @@
38
38
  #
39
39
  module Agents
40
40
  class Tool < RubyLLM::Tool
41
+ # Keep custom tools written for ai-agents 0.12 working with RubyLLM 2.
42
+ def self.param(name, **options)
43
+ old_description = options.delete(:desc)
44
+ options[:description] ||= old_description
45
+ parameter(name, **options)
46
+ end
47
+
41
48
  # Execute the tool with context injection.
42
49
  # This method is called by the runner and handles the thread-safe
43
50
  # execution pattern by passing all state through parameters.
@@ -18,14 +18,14 @@ module Agents
18
18
  #
19
19
  # ## The Solution
20
20
  # Each Runner creates new wrapper instances for each execution, capturing the context
21
- # in the wrapper's closure. When RubyLLM calls execute(), the wrapper injects the
21
+ # in the wrapper's closure. When RubyLLM calls call(), the wrapper injects the
22
22
  # context before calling the actual tool:
23
23
  #
24
24
  # # Runner creates wrapper
25
25
  # wrapped = ToolWrapper.new(my_tool, context_wrapper)
26
26
  #
27
27
  # # RubyLLM calls wrapper
28
- # wrapped.execute(city: "NYC") # No context parameter
28
+ # wrapped.call(city: "NYC") # No context parameter
29
29
  #
30
30
  # # Wrapper injects context
31
31
  # tool_context = ToolContext.new(run_context: context_wrapper)
@@ -40,11 +40,12 @@ module Agents
40
40
  # Copy tool metadata for RubyLLM
41
41
  @name = tool.name
42
42
  @description = tool.description
43
- @params = tool.class.params if tool.class.respond_to?(:params)
44
43
  end
45
44
 
46
45
  # RubyLLM calls this method (follows RubyLLM::Tool pattern)
47
- def call(args)
46
+ def call(tool_call: nil, **args) # rubocop:disable Lint/UnusedMethodArgument
47
+ return "Skipped after agent handoff" if @context_wrapper.context[:pending_handoff]
48
+
48
49
  tool_context = ToolContext.new(run_context: @context_wrapper)
49
50
 
50
51
  @context_wrapper.callback_manager.emit_tool_start(@tool.name, args, @context_wrapper)
@@ -54,7 +55,7 @@ module Agents
54
55
  @context_wrapper.callback_manager.emit_tool_complete(@tool.name, result, @context_wrapper)
55
56
  result
56
57
  rescue StandardError => e
57
- @context_wrapper.callback_manager.emit_tool_complete(@tool.name, "ERROR: #{e.message}", @context_wrapper)
58
+ @context_wrapper.callback_manager.emit_tool_complete(@tool.name, "ERROR: #{e.message}", @context_wrapper, e)
58
59
  raise
59
60
  end
60
61
  end
@@ -68,18 +69,20 @@ module Agents
68
69
  @description || @tool.description
69
70
  end
70
71
 
71
- # RubyLLM calls this to get parameter definitions
72
- def parameters
73
- @tool.parameters
72
+ def parameters_schema
73
+ @tool.parameters_schema
74
+ end
75
+
76
+ def provider_options
77
+ @tool.provider_options
74
78
  end
75
79
 
76
- # Expose params schema for RubyLLM providers that expect it
77
- def params_schema
78
- @tool.respond_to?(:params_schema) ? @tool.params_schema : nil
80
+ def requires_approval?
81
+ @tool.requires_approval?
79
82
  end
80
83
 
81
- def provider_params
82
- @tool.respond_to?(:provider_params) ? @tool.provider_params : {}
84
+ def approval_resolver
85
+ @tool.approval_resolver
83
86
  end
84
87
 
85
88
  # Make this work with RubyLLM's tool calling
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
data/lib/agents.rb CHANGED
@@ -53,6 +53,7 @@ module Agents
53
53
  apply_if_present(config, :openai_api_base)
54
54
  apply_if_present(config, :openai_organization_id)
55
55
  apply_if_present(config, :openai_project_id)
56
+ apply_if_present(config, :openai_protocol)
56
57
 
57
58
  # Other providers
58
59
  apply_if_present(config, :anthropic_api_key)
@@ -75,6 +76,7 @@ module Agents
75
76
  config.default_model = configuration.default_model
76
77
  config.log_level = configuration.debug == true ? :debug : :info
77
78
  apply_if_present(config, :request_timeout)
79
+ apply_if_present(config, :model_registry_file)
78
80
  end
79
81
 
80
82
  def apply_if_present(config, key)
@@ -85,13 +87,13 @@ module Agents
85
87
 
86
88
  class Configuration
87
89
  # Provider API keys and configuration
88
- attr_accessor :openai_api_key, :openai_api_base, :openai_organization_id, :openai_project_id
90
+ attr_accessor :openai_api_key, :openai_api_base, :openai_organization_id, :openai_project_id, :openai_protocol
89
91
  attr_accessor :anthropic_api_key, :azure_api_base, :azure_api_key, :azure_ai_auth_token, :gemini_api_key,
90
92
  :deepseek_api_key, :openrouter_api_key, :ollama_api_base, :bedrock_api_key, :bedrock_secret_key,
91
93
  :bedrock_region, :bedrock_session_token
92
94
 
93
95
  # General configuration
94
- attr_accessor :request_timeout, :default_model, :debug
96
+ attr_accessor :request_timeout, :default_model, :debug, :model_registry_file
95
97
 
96
98
  def initialize
97
99
  @default_model = "gpt-4o-mini"