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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/CLAUDE.md +1 -1
- data/README.md +3 -3
- data/docs/architecture.md +1 -1
- data/docs/concepts/agents.md +16 -0
- data/docs/concepts/tools.md +1 -1
- data/docs/guides/instrumentation.md +54 -5
- data/docs/guides/rails-integration.md +2 -2
- data/docs/guides/state-persistence.md +2 -2
- data/docs/guides/structured-output.md +3 -3
- data/examples/collaborative-copilot/tools/create_linear_ticket_tool.rb +5 -5
- data/examples/collaborative-copilot/tools/get_article_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/get_contact_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/get_conversation_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/get_stripe_billing_tool.rb +1 -1
- data/examples/collaborative-copilot/tools/search_contacts_tool.rb +3 -2
- data/examples/collaborative-copilot/tools/search_conversations_tool.rb +2 -2
- data/examples/collaborative-copilot/tools/search_knowledge_base_tool.rb +4 -3
- data/examples/collaborative-copilot/tools/search_linear_issues_tool.rb +5 -4
- data/examples/isp-support/agents_factory.rb +4 -4
- data/examples/isp-support/tools/create_checkout_tool.rb +1 -1
- data/examples/isp-support/tools/create_lead_tool.rb +3 -3
- data/examples/isp-support/tools/crm_lookup_tool.rb +1 -1
- data/examples/isp-support/tools/search_docs_tool.rb +1 -1
- data/lib/agents/agent.rb +19 -6
- data/lib/agents/agent_tool.rb +1 -1
- data/lib/agents/handoff.rb +2 -5
- data/lib/agents/helpers/message_extractor.rb +24 -8
- data/lib/agents/instrumentation/constants.rb +2 -0
- data/lib/agents/instrumentation/tracing_callbacks.rb +53 -24
- data/lib/agents/run_context.rb +5 -4
- data/lib/agents/runner.rb +76 -57
- data/lib/agents/tool.rb +8 -1
- data/lib/agents/tool_wrapper.rb +16 -13
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +4 -2
- 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
|
|
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, :
|
|
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,
|
|
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
|
-
|
|
87
|
-
|
|
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,
|
|
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?(:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
parts << text if text && !text.empty?
|
|
457
|
+
append_attachment_summary(content.text.to_s, content.attachments)
|
|
458
|
+
end
|
|
432
459
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
parts << "Attachments: #{urls.join(", ")}"
|
|
436
|
-
end
|
|
460
|
+
def append_attachment_summary(text, attachments)
|
|
461
|
+
return text unless attachments&.any?
|
|
437
462
|
|
|
438
|
-
|
|
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
|
data/lib/agents/run_context.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
104
|
+
tokens = response.tokens if response.respond_to?(:tokens)
|
|
105
|
+
return unless tokens
|
|
105
106
|
|
|
106
|
-
input =
|
|
107
|
-
output =
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
#
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
293
|
-
return false if role != :tool && !
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
367
|
+
return [content_value.to_json, []] if text_parts.empty? && image_urls.empty?
|
|
354
368
|
|
|
355
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
442
|
-
chat.with_temperature(agent.temperature)
|
|
443
|
-
chat.
|
|
444
|
-
chat.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
#
|
|
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.
|
data/lib/agents/tool_wrapper.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
82
|
-
@tool.
|
|
84
|
+
def approval_resolver
|
|
85
|
+
@tool.approval_resolver
|
|
83
86
|
end
|
|
84
87
|
|
|
85
88
|
# Make this work with RubyLLM's tool calling
|
data/lib/agents/version.rb
CHANGED
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"
|