activeagent 1.8.1 → 1.9.1
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 +1058 -0
- data/README.md +9 -1
- data/lib/active_agent/base.rb +22 -1
- data/lib/active_agent/concerns/delegation.rb +4 -0
- data/lib/active_agent/concerns/input_requests.rb +178 -0
- data/lib/active_agent/concerns/parameterized.rb +7 -4
- data/lib/active_agent/concerns/provider.rb +1 -0
- data/lib/active_agent/concerns/tooling.rb +7 -2
- data/lib/active_agent/delegation/runner.rb +31 -0
- data/lib/active_agent/evals/catalog.rb +305 -0
- data/lib/active_agent/evals/ruby_llm.rb +411 -0
- data/lib/active_agent/evals/scenario.rb +10 -3
- data/lib/active_agent/evals/scenario_parser.rb +38 -10
- data/lib/active_agent/evals.rb +1 -0
- data/lib/active_agent/generation.rb +62 -2
- data/lib/active_agent/generation_job.rb +14 -3
- data/lib/active_agent/input_request/resume.rb +199 -0
- data/lib/active_agent/input_request.rb +279 -0
- data/lib/active_agent/providers/_base_provider.rb +153 -31
- data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
- data/lib/active_agent/providers/anthropic_provider.rb +102 -20
- data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
- data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
- data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
- data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
- data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
- data/lib/active_agent/providers/deepseek/_types.rb +4 -0
- data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
- data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
- data/lib/active_agent/providers/deepseek/options.rb +53 -0
- data/lib/active_agent/providers/deepseek_provider.rb +46 -0
- data/lib/active_agent/providers/log_subscriber.rb +13 -0
- data/lib/active_agent/providers/mcp_bridge.rb +691 -0
- data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
- data/lib/active_agent/providers/open_ai/_base.rb +1 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
- data/lib/active_agent/providers/open_ai/options.rb +1 -1
- data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
- data/lib/active_agent/providers/open_router/options.rb +6 -2
- data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
- data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
- data/lib/active_agent/schema_tools.rb +12 -0
- data/lib/active_agent/telemetry/instrumentation.rb +10 -1
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- data/lib/generators/active_agent/schema_tools/USAGE +11 -0
- data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
- data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
- metadata +31 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "active_support/core_ext/object/deep_dup"
|
|
5
|
+
|
|
6
|
+
module ActiveAgent
|
|
7
|
+
module Providers
|
|
8
|
+
# Process-local cache for the tools an MCP server advertises.
|
|
9
|
+
#
|
|
10
|
+
# Asking a server what it offers is a round trip, and an expensive one: a
|
|
11
|
+
# hosted server measures ~450ms for `tools/list` on top of ~680ms for the
|
|
12
|
+
# handshake. The answer changes about as often as the server is redeployed,
|
|
13
|
+
# so a generation that pays that to relearn the same 27 tool names is paying
|
|
14
|
+
# it for nothing.
|
|
15
|
+
#
|
|
16
|
+
# With an entry cached, {MCPBridge} needs no connection at all to advertise
|
|
17
|
+
# the tools — it connects only if the model actually calls one. So the cost
|
|
18
|
+
# becomes zero for a generation that never reaches for an MCP tool, and one
|
|
19
|
+
# connection for a generation that does.
|
|
20
|
+
#
|
|
21
|
+
# Entries hold plain data: no sockets, no child processes, no class
|
|
22
|
+
# references. That is what makes it safe to hold across a fork — a child
|
|
23
|
+
# gets a copy-on-write snapshot with nothing in it bound to the parent's
|
|
24
|
+
# file descriptors or threads.
|
|
25
|
+
#
|
|
26
|
+
# @see ActiveAgent::Providers::MCPBridge
|
|
27
|
+
class MCPToolCache
|
|
28
|
+
# Seconds an entry stays usable. Long enough to cover a burst of
|
|
29
|
+
# generations, short enough that a redeployed server is picked up on its
|
|
30
|
+
# own without a restart.
|
|
31
|
+
DEFAULT_TTL = 300
|
|
32
|
+
|
|
33
|
+
# Upper bound on entries, oldest evicted first. Each entry is one server's
|
|
34
|
+
# tool schemas, so this is small; the cap exists to keep a process that
|
|
35
|
+
# talks to many distinct servers from growing without bound.
|
|
36
|
+
DEFAULT_MAX_ENTRIES = 100
|
|
37
|
+
|
|
38
|
+
@monitor = Monitor.new
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# @return [Numeric] seconds an entry stays usable
|
|
42
|
+
def ttl = @ttl ||= DEFAULT_TTL
|
|
43
|
+
|
|
44
|
+
# @param seconds [Numeric] must be positive, or nothing would ever be
|
|
45
|
+
# reused
|
|
46
|
+
# @return [Numeric]
|
|
47
|
+
def ttl=(seconds)
|
|
48
|
+
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
49
|
+
fail ArgumentError, "MCP tool cache ttl must be a positive number of seconds, got #{seconds.inspect}."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@ttl = seconds
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] entries kept before the oldest is evicted
|
|
56
|
+
def max_entries = @max_entries ||= DEFAULT_MAX_ENTRIES
|
|
57
|
+
|
|
58
|
+
# @param count [Integer]
|
|
59
|
+
# @return [Integer]
|
|
60
|
+
def max_entries=(count)
|
|
61
|
+
unless count.is_a?(Integer) && count.positive?
|
|
62
|
+
fail ArgumentError, "MCP tool cache max_entries must be a positive Integer, got #{count.inspect}."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@max_entries = count
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] whether lookups consult the cache at all
|
|
69
|
+
def enabled? = @enabled.nil? ? true : @enabled
|
|
70
|
+
|
|
71
|
+
# @param value [Boolean]
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def enabled=(value)
|
|
74
|
+
@enabled = !!value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param ttl [Numeric, nil]
|
|
78
|
+
# @param max_entries [Integer, nil]
|
|
79
|
+
# @param enabled [Boolean, nil]
|
|
80
|
+
# @return [Class] self, for chaining
|
|
81
|
+
def configure(ttl: nil, max_entries: nil, enabled: nil)
|
|
82
|
+
self.ttl = ttl if ttl
|
|
83
|
+
self.max_entries = max_entries if max_entries
|
|
84
|
+
self.enabled = enabled unless enabled.nil?
|
|
85
|
+
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The cached tools for +key+, computing and storing them on a miss.
|
|
90
|
+
#
|
|
91
|
+
# The block runs outside the lock: it performs network I/O, and holding
|
|
92
|
+
# the lock across it would serialise every generation in the process.
|
|
93
|
+
# Two threads racing on the same cold key both fetch and the later write
|
|
94
|
+
# wins — a duplicated round trip costs less than a global lock on the
|
|
95
|
+
# latency path.
|
|
96
|
+
#
|
|
97
|
+
# @param key [String] identifies the declaration, e.g. a digest
|
|
98
|
+
# @param enabled [Boolean, nil] per-generation override; nil uses the
|
|
99
|
+
# process-level setting
|
|
100
|
+
# @yieldreturn [Array<Hash>] the tools to cache when there is no entry
|
|
101
|
+
# @return [Array<Hash>] a copy the caller may mutate freely
|
|
102
|
+
def fetch(key, enabled: nil)
|
|
103
|
+
use_cache = enabled.nil? ? enabled? : enabled
|
|
104
|
+
return yield.deep_dup unless use_cache
|
|
105
|
+
|
|
106
|
+
cached = read(key)
|
|
107
|
+
return cached if cached
|
|
108
|
+
|
|
109
|
+
tools = yield
|
|
110
|
+
write(key, tools)
|
|
111
|
+
tools.deep_dup
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Drops one entry, so the next lookup asks the server again.
|
|
115
|
+
#
|
|
116
|
+
# @param key [String]
|
|
117
|
+
# @return [nil]
|
|
118
|
+
def invalidate(key)
|
|
119
|
+
monitor.synchronize { entries.delete(key) }
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Drops every entry. For a redeploy-time hook or a test's setup.
|
|
125
|
+
#
|
|
126
|
+
# @return [nil]
|
|
127
|
+
def clear!
|
|
128
|
+
monitor.synchronize do
|
|
129
|
+
entries.clear
|
|
130
|
+
@hits = 0
|
|
131
|
+
@misses = 0
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Empties the cache and restores the default settings.
|
|
138
|
+
#
|
|
139
|
+
# An application that configured the cache should not have to hand back
|
|
140
|
+
# every value to get a clean slate; a test suite benefits most, since
|
|
141
|
+
# the cache is process-global and outlives a single test.
|
|
142
|
+
#
|
|
143
|
+
# @return [nil]
|
|
144
|
+
def reset!
|
|
145
|
+
monitor.synchronize do
|
|
146
|
+
entries.clear
|
|
147
|
+
@hits = 0
|
|
148
|
+
@misses = 0
|
|
149
|
+
@ttl = nil
|
|
150
|
+
@max_entries = nil
|
|
151
|
+
@enabled = nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Hash] entry count and hit/miss counters, for a health check
|
|
158
|
+
# or to confirm the cache is doing what it should
|
|
159
|
+
def stats
|
|
160
|
+
monitor.synchronize { { size: entries.size, hits: @hits.to_i, misses: @misses.to_i } }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# @param key [String]
|
|
166
|
+
# @return [Array<Hash>, nil] a copy, or nil when absent or expired
|
|
167
|
+
def read(key)
|
|
168
|
+
monitor.synchronize do
|
|
169
|
+
entry = entries[key]
|
|
170
|
+
|
|
171
|
+
if entry.nil? || expired?(entry)
|
|
172
|
+
# A stale entry is dropped rather than left to expire twice.
|
|
173
|
+
entries.delete(key) if entry
|
|
174
|
+
@misses = @misses.to_i + 1
|
|
175
|
+
|
|
176
|
+
nil
|
|
177
|
+
else
|
|
178
|
+
@hits = @hits.to_i + 1
|
|
179
|
+
|
|
180
|
+
entry[:tools].deep_dup
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @param key [String]
|
|
186
|
+
# @param tools [Array<Hash>]
|
|
187
|
+
# @return [nil]
|
|
188
|
+
def write(key, tools)
|
|
189
|
+
monitor.synchronize do
|
|
190
|
+
# Re-inserting moves the key to the back, so eviction stays ordered
|
|
191
|
+
# by write time even when a key is refreshed.
|
|
192
|
+
entries.delete(key)
|
|
193
|
+
entries[key] = { tools: tools.deep_dup, stored_at: monotonic, expires_at: monotonic + ttl }
|
|
194
|
+
|
|
195
|
+
evict!
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @param entry [Hash]
|
|
202
|
+
# @return [Boolean]
|
|
203
|
+
def expired?(entry) = entry[:expires_at] <= monotonic
|
|
204
|
+
|
|
205
|
+
# @return [void]
|
|
206
|
+
def evict!
|
|
207
|
+
entries.reject! { |_, entry| expired?(entry) }
|
|
208
|
+
|
|
209
|
+
# A Ruby Hash is insertion-ordered, so the front is the oldest write.
|
|
210
|
+
entries.shift while entries.size > max_entries
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @return [Hash] insertion-ordered
|
|
214
|
+
def entries = @entries ||= {}
|
|
215
|
+
|
|
216
|
+
# Reentrant, so a future caller can nest without deadlocking. Created with the class, so two first
|
|
217
|
+
# callers cannot each build their own.
|
|
218
|
+
#
|
|
219
|
+
# @return [Monitor]
|
|
220
|
+
def monitor = @monitor
|
|
221
|
+
|
|
222
|
+
# The monotonic clock, so an entry's lifetime is not affected by the
|
|
223
|
+
# wall clock moving.
|
|
224
|
+
#
|
|
225
|
+
# @return [Float]
|
|
226
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -50,7 +50,7 @@ module ActiveAgent
|
|
|
50
50
|
kwargs = JSON.parse(api_function_call[:arguments], symbolize_names: true) if api_function_call[:arguments]
|
|
51
51
|
|
|
52
52
|
instrument("tool_call.active_agent", tool_name: name) do
|
|
53
|
-
|
|
53
|
+
call_tool_function(name, **kwargs)
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -151,14 +151,15 @@ module ActiveAgent
|
|
|
151
151
|
# Processes function/tool calls from the API response
|
|
152
152
|
#
|
|
153
153
|
# Executes each tool call and creates tool response messages
|
|
154
|
-
# for the next iteration of the conversation.
|
|
154
|
+
# for the next iteration of the conversation. Pushes nothing when a
|
|
155
|
+
# tool asked the user for input.
|
|
155
156
|
#
|
|
156
157
|
# @param api_function_calls [Array<Hash>] function calls with :type, :id, and :function keys
|
|
157
158
|
# @return [void]
|
|
158
159
|
# @see Base#process_function_calls
|
|
159
160
|
def process_function_calls(api_function_calls)
|
|
160
|
-
api_function_calls
|
|
161
|
-
|
|
161
|
+
results = dispatch_tool_calls(api_function_calls) do |api_function_call|
|
|
162
|
+
instrument("tool_call.active_agent", tool_name: api_function_call.dig(:function, :name)) do
|
|
162
163
|
case api_function_call[:type].to_s
|
|
163
164
|
when "function"
|
|
164
165
|
process_tool_call_function(api_function_call[:function])
|
|
@@ -166,7 +167,11 @@ module ActiveAgent
|
|
|
166
167
|
fail "Unexpected Tool Call Type: #{api_function_call[:type]}"
|
|
167
168
|
end
|
|
168
169
|
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
return unless results
|
|
169
173
|
|
|
174
|
+
api_function_calls.zip(results).each do |api_function_call, content|
|
|
170
175
|
# Create tool message using gem's message param class
|
|
171
176
|
message = ::OpenAI::Models::Chat::ChatCompletionToolMessageParam.new(
|
|
172
177
|
role: "tool",
|
|
@@ -204,6 +209,20 @@ module ActiveAgent
|
|
|
204
209
|
[ api_message ]
|
|
205
210
|
end
|
|
206
211
|
|
|
212
|
+
# @see InputRequests#tool_call_reference
|
|
213
|
+
# @param api_function_call [Hash]
|
|
214
|
+
# @return [Array(String, String)]
|
|
215
|
+
def tool_call_reference(api_function_call)
|
|
216
|
+
[ api_function_call[:id].to_s, api_function_call.dig(:function, :name).to_s ]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @see InputRequests#tool_call_arguments
|
|
220
|
+
# @param api_function_call [Hash]
|
|
221
|
+
# @return [Hash, String, nil]
|
|
222
|
+
def tool_call_arguments(api_function_call)
|
|
223
|
+
parse_tool_call_arguments(api_function_call.dig(:function, :arguments))
|
|
224
|
+
end
|
|
225
|
+
|
|
207
226
|
# Extracts function calls from the last message in the stack.
|
|
208
227
|
#
|
|
209
228
|
# @return [Array<Hash>, nil] tool call objects or nil if no tool calls
|
|
@@ -25,6 +25,13 @@ module ActiveAgent
|
|
|
25
25
|
Responses::RequestType.new
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# The Responses API accepts remote MCP servers, so those are served by
|
|
29
|
+
# the provider as they always were. A local process cannot be handed to
|
|
30
|
+
# it, so a `command:` declaration runs client-side instead.
|
|
31
|
+
#
|
|
32
|
+
# @return [Array<Symbol>]
|
|
33
|
+
def mcp_native_transports = [ :url ]
|
|
34
|
+
|
|
28
35
|
protected
|
|
29
36
|
|
|
30
37
|
# @see BaseProvider#prepare_prompt_request
|
|
@@ -87,8 +94,10 @@ module ActiveAgent
|
|
|
87
94
|
# - `:"response.output_text.done"` - complete text
|
|
88
95
|
# - `:"response.function_call_arguments.delta"` - function argument updates
|
|
89
96
|
# - `:"response.function_call_arguments.done"` - complete function arguments
|
|
97
|
+
# - `:"response.reasoning_summary_*"`, `:"response.reasoning_text.*"` -
|
|
98
|
+
# reasoning progress, kept from the reasoning item once it is done
|
|
90
99
|
# - `:"response.content_part.done"` - content part completed
|
|
91
|
-
# - `:"response.output_item.done"` - message or function call completed
|
|
100
|
+
# - `:"response.output_item.done"` - message, reasoning or function call completed
|
|
92
101
|
# - `:"response.completed"` - response finished
|
|
93
102
|
#
|
|
94
103
|
# @param api_response_event [Hash] streaming chunk with :type key
|
|
@@ -125,6 +134,12 @@ module ActiveAgent
|
|
|
125
134
|
when :"response.function_call_arguments.delta", :"response.function_call_arguments.done"
|
|
126
135
|
# No-Op: Wait for FC to Land
|
|
127
136
|
|
|
137
|
+
# -> -> -> Reasoning Progress
|
|
138
|
+
when :"response.reasoning_summary_part.added", :"response.reasoning_summary_part.done",
|
|
139
|
+
:"response.reasoning_summary_text.delta", :"response.reasoning_summary_text.done",
|
|
140
|
+
:"response.reasoning_text.delta", :"response.reasoning_text.done"
|
|
141
|
+
# No-Op: Wait for the reasoning item to land
|
|
142
|
+
|
|
128
143
|
# -> -> Content Part Completed [Full Part]
|
|
129
144
|
when :"response.content_part.done"
|
|
130
145
|
|
|
@@ -134,8 +149,9 @@ module ActiveAgent
|
|
|
134
149
|
|
|
135
150
|
# Response Completed
|
|
136
151
|
when :"response.completed"
|
|
137
|
-
#
|
|
138
|
-
|
|
152
|
+
# Completion, tool loop included, runs from stream_finished! once
|
|
153
|
+
# the stream has drained, so each turn's tool calls run once.
|
|
154
|
+
self.stream_completion_pending = true
|
|
139
155
|
else
|
|
140
156
|
raise "Unexpected Response Chunk Type: #{api_response_event.type}"
|
|
141
157
|
end
|
|
@@ -143,8 +159,9 @@ module ActiveAgent
|
|
|
143
159
|
|
|
144
160
|
# Processes output item added events from streaming response
|
|
145
161
|
#
|
|
146
|
-
# Handles message and function_call item types. For messages,
|
|
147
|
-
# with empty content.
|
|
162
|
+
# Handles message, reasoning and function_call item types. For messages,
|
|
163
|
+
# adds to stack with empty content. Reasoning items and function calls
|
|
164
|
+
# wait for their completion event.
|
|
148
165
|
#
|
|
149
166
|
# Required because API returns empty array instead of empty string for
|
|
150
167
|
# initial message content due to serialization bug.
|
|
@@ -157,8 +174,8 @@ module ActiveAgent
|
|
|
157
174
|
# PATCH: API returns an empty array instead of empty string due to a bug in their serialization
|
|
158
175
|
item_hash = Responses::Transforms.gem_to_hash(api_response_event.item).compact_blank
|
|
159
176
|
message_stack << { content: "" }.merge(item_hash)
|
|
160
|
-
when :function_call
|
|
161
|
-
# No-Op: Wait for
|
|
177
|
+
when :function_call, :reasoning
|
|
178
|
+
# No-Op: Wait for the item to land (-> response.output_item.done)
|
|
162
179
|
else
|
|
163
180
|
raise "Unexpected Item Type: #{api_response_event.item.type}"
|
|
164
181
|
end
|
|
@@ -166,8 +183,10 @@ module ActiveAgent
|
|
|
166
183
|
|
|
167
184
|
# Processes output item completion events from streaming response
|
|
168
185
|
#
|
|
169
|
-
# For function calls, adds completed item to
|
|
170
|
-
#
|
|
186
|
+
# For function calls and reasoning items, adds the completed item to the
|
|
187
|
+
# message stack, so the next turn sends each reasoning item back with
|
|
188
|
+
# the function calls it led to. For messages, no action needed as
|
|
189
|
+
# content already updated via delta events.
|
|
171
190
|
#
|
|
172
191
|
# @param api_response_event [Hash] response chunk with completed :item
|
|
173
192
|
# @return [void]
|
|
@@ -175,7 +194,7 @@ module ActiveAgent
|
|
|
175
194
|
case api_response_event.item.type
|
|
176
195
|
when :message
|
|
177
196
|
# No-Op: Message Up to Date
|
|
178
|
-
when :function_call
|
|
197
|
+
when :function_call, :reasoning
|
|
179
198
|
item_hash = Responses::Transforms.gem_to_hash(api_response_event.item)
|
|
180
199
|
message_stack << item_hash
|
|
181
200
|
else
|
|
@@ -183,21 +202,25 @@ module ActiveAgent
|
|
|
183
202
|
end
|
|
184
203
|
end
|
|
185
204
|
|
|
186
|
-
# Executes function calls and creates output messages for conversation
|
|
205
|
+
# Executes function calls and creates output messages for conversation
|
|
206
|
+
# continuation. Pushes nothing when a call paused for the user.
|
|
187
207
|
#
|
|
188
208
|
# @param api_function_calls [Array<Hash>] function calls with :call_id and :name keys
|
|
189
209
|
# @return [void]
|
|
190
210
|
# @see Base#process_function_calls
|
|
191
211
|
def process_function_calls(api_function_calls)
|
|
192
|
-
api_function_calls
|
|
193
|
-
|
|
194
|
-
process_tool_call_function(api_function_call)
|
|
212
|
+
results = dispatch_tool_calls(api_function_calls) do |api_function_call|
|
|
213
|
+
instrument("tool_call.active_agent", tool_name: api_function_call[:name]) do
|
|
214
|
+
process_tool_call_function(api_function_call)
|
|
195
215
|
end
|
|
216
|
+
end
|
|
217
|
+
return unless results
|
|
196
218
|
|
|
219
|
+
api_function_calls.zip(results).each do |api_function_call, result|
|
|
197
220
|
# Create native gem input item for function call output
|
|
198
221
|
message = ::OpenAI::Models::Responses::ResponseInputItem::FunctionCallOutput.new(
|
|
199
222
|
call_id: api_function_call[:call_id],
|
|
200
|
-
output:
|
|
223
|
+
output: result.to_json
|
|
201
224
|
)
|
|
202
225
|
|
|
203
226
|
# Convert to hash for message_stack
|
|
@@ -205,6 +228,36 @@ module ActiveAgent
|
|
|
205
228
|
end
|
|
206
229
|
end
|
|
207
230
|
|
|
231
|
+
# A function_call item's `id` names the item; `call_id` is what its
|
|
232
|
+
# function_call_output answers.
|
|
233
|
+
#
|
|
234
|
+
# @see InputRequests#tool_call_reference
|
|
235
|
+
# @param api_function_call [Hash]
|
|
236
|
+
# @return [Array(String, String)]
|
|
237
|
+
def tool_call_reference(api_function_call)
|
|
238
|
+
[ api_function_call[:call_id].to_s, api_function_call[:name].to_s ]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @see InputRequests#tool_call_arguments
|
|
242
|
+
# @param api_function_call [Hash]
|
|
243
|
+
# @return [Hash, String, nil]
|
|
244
|
+
def tool_call_arguments(api_function_call)
|
|
245
|
+
parse_tool_call_arguments(api_function_call[:arguments])
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Returns `messages` as the request's `input` items. Serializing the
|
|
249
|
+
# whole request would shorten a lone user message to a bare string.
|
|
250
|
+
#
|
|
251
|
+
# @see InputRequests#serialized_messages
|
|
252
|
+
# @return [Array<Hash>]
|
|
253
|
+
def serialized_messages(messages, instructions: nil)
|
|
254
|
+
parameters = { messages:, instructions: }.compact
|
|
255
|
+
return [] if parameters.empty?
|
|
256
|
+
|
|
257
|
+
input = Responses::Transforms.gem_to_hash(prompt_request_type.cast(parameters).__getobj__)[:input]
|
|
258
|
+
input.is_a?(String) ? [ { role: "user", content: input } ] : Array(input)
|
|
259
|
+
end
|
|
260
|
+
|
|
208
261
|
# Converts OpenAI gem response object to hash for storage.
|
|
209
262
|
#
|
|
210
263
|
# @param api_response [OpenAI::Models::Responses::Response]
|
|
@@ -46,7 +46,10 @@ module ActiveAgent
|
|
|
46
46
|
# site_url from Rails routes/ActionMailer default_url_options.
|
|
47
47
|
#
|
|
48
48
|
# @param kwargs [Hash] configuration options
|
|
49
|
-
# @option kwargs [String] :api_key OpenRouter API key
|
|
49
|
+
# @option kwargs [String] :api_key OpenRouter API key. When neither it nor
|
|
50
|
+
# :access_token is given, falls back to OPENROUTER_API_KEY,
|
|
51
|
+
# OPEN_ROUTER_API_KEY, OPENROUTER_ACCESS_TOKEN, then OPEN_ROUTER_ACCESS_TOKEN.
|
|
52
|
+
# @option kwargs [String] :access_token alias for :api_key
|
|
50
53
|
# @option kwargs [String] :app_name application name for rankings
|
|
51
54
|
# @option kwargs [String] :site_url site URL for rankings
|
|
52
55
|
# @return [Options]
|
|
@@ -85,7 +88,8 @@ module ActiveAgent
|
|
|
85
88
|
private
|
|
86
89
|
|
|
87
90
|
def resolve_api_key(kwargs)
|
|
88
|
-
kwargs[
|
|
91
|
+
kwargs[:api_key] ||
|
|
92
|
+
kwargs[:access_token] ||
|
|
89
93
|
ENV["OPENROUTER_API_KEY"] ||
|
|
90
94
|
ENV["OPEN_ROUTER_API_KEY"] ||
|
|
91
95
|
ENV["OPENROUTER_ACCESS_TOKEN"] ||
|
|
@@ -34,8 +34,10 @@ module ActiveAgent
|
|
|
34
34
|
when Array
|
|
35
35
|
grouped = []
|
|
36
36
|
|
|
37
|
+
# Each tool result answers one call by its tool_call_id, so tool
|
|
38
|
+
# messages are never merged.
|
|
37
39
|
value.each do |message|
|
|
38
|
-
if grouped.empty? || grouped.last.role != message.role
|
|
40
|
+
if grouped.empty? || grouped.last.role != message.role || message.role == "tool"
|
|
39
41
|
grouped << message.deep_dup
|
|
40
42
|
else
|
|
41
43
|
grouped.last.content += message.content.deep_dup
|
|
@@ -15,6 +15,11 @@ module ActiveAgent
|
|
|
15
15
|
# e.g. :vertexai, :gemini, :bedrock). A model ID served by several
|
|
16
16
|
# backends otherwise resolves by RubyLLM's registry preference.
|
|
17
17
|
attribute :platform, :string
|
|
18
|
+
# Pins which of the backend's wire protocols carries a request
|
|
19
|
+
# (RubyLLM's protocol:, e.g. :chat_completions or :responses for
|
|
20
|
+
# OpenAI). ruby_llm 2.x only; without it RubyLLM's own setting
|
|
21
|
+
# applies, and for OpenAI that defaults to :responses.
|
|
22
|
+
attribute :protocol, :string
|
|
18
23
|
attribute :temperature, :float
|
|
19
24
|
attribute :max_tokens, :integer
|
|
20
25
|
|