activeagent 1.9.6 → 1.9.8
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 +612 -28
- data/README.md +9 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +50 -7
- data/lib/active_agent/concerns/tooling.rb +11 -0
- data/lib/active_agent/evals/capture.rb +286 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/model_capabilities.rb +1 -1
- data/lib/active_agent/providers/_base_provider.rb +18 -2
- data/lib/active_agent/providers/anthropic_provider.rb +10 -0
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/common/usage.rb +6 -1
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
- data/lib/active_agent/providers/requesty_provider.rb +8 -0
- data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
- data/lib/active_agent/providers/runcrate/_types.rb +17 -0
- data/lib/active_agent/providers/runcrate/options.rb +39 -0
- data/lib/active_agent/providers/runcrate_provider.rb +72 -0
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +27 -5
- data/lib/tasks/activeagent_tasks.rake +0 -4
data/README.md
CHANGED
|
@@ -48,6 +48,9 @@ bundle add openai
|
|
|
48
48
|
# Anthropic
|
|
49
49
|
bundle add anthropic
|
|
50
50
|
|
|
51
|
+
# Atlas Cloud (uses OpenAI-compatible API)
|
|
52
|
+
bundle add openai
|
|
53
|
+
|
|
51
54
|
# Ollama (uses OpenAI-compatible API)
|
|
52
55
|
bundle add openai
|
|
53
56
|
|
|
@@ -60,6 +63,12 @@ bundle add openai
|
|
|
60
63
|
# Opper (uses OpenAI-compatible API)
|
|
61
64
|
bundle add openai
|
|
62
65
|
|
|
66
|
+
# Cheaper Inference (uses OpenAI-compatible API)
|
|
67
|
+
bundle add openai
|
|
68
|
+
|
|
69
|
+
# Runcrate (uses OpenAI-compatible API)
|
|
70
|
+
bundle add openai
|
|
71
|
+
|
|
63
72
|
# DeepSeek (uses OpenAI-compatible API)
|
|
64
73
|
bundle add openai
|
|
65
74
|
|
data/lib/active_agent/base.rb
CHANGED
|
@@ -336,6 +336,7 @@ module ActiveAgent
|
|
|
336
336
|
exception_handler:,
|
|
337
337
|
stream_broadcaster:,
|
|
338
338
|
tools_function:,
|
|
339
|
+
tool_observer:,
|
|
339
340
|
action_name: generation_action_name,
|
|
340
341
|
input_request_resume: @_input_request_resume,
|
|
341
342
|
announce_input_requests: !input_requests_silenced?
|
|
@@ -86,8 +86,8 @@ module ActiveAgent
|
|
|
86
86
|
p.on("--keys LIST", "Replay only these scenario keys, comma-separated") { |v| arguments[:keys] = list_option(v) }
|
|
87
87
|
p.on("--group GROUP", "Replay only this scenario group") { |v| arguments[:group] = v }
|
|
88
88
|
p.on("--sandbox ID", "Replay against this ready checkout sandbox") { |v| arguments[:sandbox_id] = v }
|
|
89
|
-
p.on("--wait", "Wait for the run to finish") { wait = true }
|
|
90
|
-
p.on("--min-pass-rate RATE", "
|
|
89
|
+
p.on("--wait", "Wait for the run to finish; fail (exit #{EXIT_GATE}) when it failed or missed a pass gate") { wait = true }
|
|
90
|
+
p.on("--min-pass-rate RATE", "Also fail below this whole-run pass rate: 0.8 or 80%; implies --wait") do |v|
|
|
91
91
|
min_pass_rate = pass_rate_option(v)
|
|
92
92
|
wait = true
|
|
93
93
|
end
|
|
@@ -138,11 +138,12 @@ module ActiveAgent
|
|
|
138
138
|
|
|
139
139
|
# Posts a saved report to the collector. FILE is a version-1 envelope
|
|
140
140
|
# (what `evals report --format json` writes) or a bare Report#to_h
|
|
141
|
-
# hash, whose identities then come from the options
|
|
141
|
+
# hash, whose identities then come from the options; the publisher
|
|
142
|
+
# reads the run_id from the report's metadata when neither names one.
|
|
142
143
|
def evals_import(argv)
|
|
143
144
|
identities = {}
|
|
144
145
|
argv = parse(argv, "evals import FILE [--run-id ID --source NAME --agent NAME --suite NAME]") do |p|
|
|
145
|
-
p.on("--run-id ID", "The report's run_id
|
|
146
|
+
p.on("--run-id ID", "The report's run_id, kept across retries; by default the one its metadata records") { |v| identities["run_id"] = v }
|
|
146
147
|
p.on("--source NAME", "The application that ran it") { |v| identities["source"] = v }
|
|
147
148
|
p.on("--agent NAME", "The agent's name") { |v| identities["agent_name"] = v }
|
|
148
149
|
p.on("--suite NAME", "The suite's name") { |v| identities["suite"] = v }
|
|
@@ -156,7 +157,6 @@ module ActiveAgent
|
|
|
156
157
|
raise Error.new("#{path} is a version #{envelope["version"].inspect} envelope; this CLI sends version 1", exit_code: EXIT_USAGE)
|
|
157
158
|
end
|
|
158
159
|
envelope = envelope.merge(identities)
|
|
159
|
-
envelope["run_id"] ||= envelope["report"].is_a?(Hash) ? envelope["report"].dig("metadata", "run_id") : nil
|
|
160
160
|
|
|
161
161
|
receipt = client.publisher.call(report: envelope["report"], run_id: envelope["run_id"], source: envelope["source"],
|
|
162
162
|
agent_name: envelope["agent_name"], suite: envelope["suite"])
|
|
@@ -199,18 +199,28 @@ module ActiveAgent
|
|
|
199
199
|
end
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
# A finished run fails the gate when it failed, when its whole pass
|
|
203
|
+
# rate is under --min-pass-rate, or when it was held to per-group pass
|
|
204
|
+
# gates (the run's "gates", from the suite's or catalog set's `pass`
|
|
205
|
+
# bars) and the dashboard says it did not meet every one. A run
|
|
206
|
+
# without gates is gated on its status and rate alone, as it was.
|
|
202
207
|
def gate(payload, min_pass_rate)
|
|
203
208
|
run = payload.fetch("run", {})
|
|
204
209
|
rate = pass_rate(run)
|
|
205
210
|
failed = run["status"] == "failed"
|
|
206
211
|
below = min_pass_rate && (rate.nil? || rate < min_pass_rate)
|
|
207
|
-
|
|
212
|
+
gates = run_gates(run)
|
|
213
|
+
missed = !gates.nil? && run["gates_met"] != true
|
|
214
|
+
verdict = failed || below || missed ? "fail" : "pass"
|
|
208
215
|
failures = payload.fetch("results", [])
|
|
216
|
+
summary = { "pass_rate" => rate, "min_pass_rate" => min_pass_rate }
|
|
217
|
+
summary["gates"] = gates if gates
|
|
209
218
|
|
|
210
|
-
emit(payload.merge("
|
|
219
|
+
emit(payload.merge(summary).merge("verdict" => verdict)) do
|
|
211
220
|
@out.puts "Run #{run["id"]} #{run["status"]}: #{passed(run) || "no results"}" \
|
|
212
221
|
"#{rate ? format(" (%.1f%%)", rate * 100) : ""}#{min_pass_rate ? format(", threshold %.1f%%", min_pass_rate * 100) : ""}."
|
|
213
222
|
@out.puts "Error: #{run["error_message"]}" if run["error_message"]
|
|
223
|
+
print_gates(gates, missed) if gates
|
|
214
224
|
unless failures.empty?
|
|
215
225
|
@out.puts "Failed results:"
|
|
216
226
|
table(failures, {
|
|
@@ -227,6 +237,39 @@ module ActiveAgent
|
|
|
227
237
|
verdict == "pass" ? EXIT_SUCCESS : EXIT_GATE
|
|
228
238
|
end
|
|
229
239
|
|
|
240
|
+
# The run's per-group pass gates as the dashboard worked them out
|
|
241
|
+
# (EvaluationRun#gates), or nil for a run held to none.
|
|
242
|
+
def run_gates(run)
|
|
243
|
+
rows = run["gates"]
|
|
244
|
+
rows.select { |row| row.is_a?(Hash) } if rows.is_a?(Array)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def print_gates(gates, missed)
|
|
248
|
+
unmet = gates.reject { |gate| gate["met"] == true }
|
|
249
|
+
unless missed
|
|
250
|
+
@out.puts "Gates: every gate met (#{gates.size})."
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
@out.puts(unmet.empty? ? "Gates: no gate was reached." : "Gates not met:")
|
|
255
|
+
unmet.each { |gate| @out.puts " #{gate_sentence(gate)}" }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# A gate the run missed, as ActiveAgent::Evals::Report::Gate#to_s
|
|
259
|
+
# reads it: "Orders · gpt-5-mini: 1/2 (50%) passed, below 100%".
|
|
260
|
+
def gate_sentence(gate)
|
|
261
|
+
passed = gate["passed"].to_i
|
|
262
|
+
total = gate["total"].to_i
|
|
263
|
+
passes = total.positive? ? "#{passed}/#{total} (#{whole_percent(passed.to_f / total)})" : "no results"
|
|
264
|
+
label = cell(gate["label"]) unless gate["label"].to_s.empty?
|
|
265
|
+
"#{cell(gate["name"] || gate["group"] || "All")}#{label ? " · #{label}" : ""}: #{passes} passed, below #{whole_percent(gate["min_pass_rate"])}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def whole_percent(fraction)
|
|
269
|
+
value = Float(fraction, exception: false)
|
|
270
|
+
value ? "#{(value * 100).round}%" : "?"
|
|
271
|
+
end
|
|
272
|
+
|
|
230
273
|
def pass_rate(run)
|
|
231
274
|
evaluated = run["samples_evaluated"].to_i
|
|
232
275
|
evaluated.positive? ? run["samples_passed"].to_f / evaluated : nil
|
|
@@ -26,5 +26,16 @@ module ActiveAgent
|
|
|
26
26
|
raise_without_input_request_secrets(error)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# A callback the provider runs the calls it serves from an MCP server
|
|
31
|
+
# around, which never reach {#tools_function}. It receives the tool name,
|
|
32
|
+
# its arguments and a block that makes the call, and returns the block's
|
|
33
|
+
# result. Nil unless the agent defines one, to audit its MCP calls say,
|
|
34
|
+
# or a host that records a generation's tool calls sets it on the
|
|
35
|
+
# instance. Evals::Capture keeps an agent's own observer and records the
|
|
36
|
+
# call that observer makes.
|
|
37
|
+
#
|
|
38
|
+
# @return [Proc, nil]
|
|
39
|
+
def tool_observer = nil
|
|
29
40
|
end
|
|
30
41
|
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# Runs one agent generation and records what the agent did, as a Replay:
|
|
6
|
+
# the answer, every tool it called (arguments, result or error, timing),
|
|
7
|
+
# how long the generation took and the tokens it used. It is the glue
|
|
8
|
+
# between `MyAgent.with(...).action` and the Runner's `replay` callable,
|
|
9
|
+
# and what TestHelper#run_eval returns.
|
|
10
|
+
#
|
|
11
|
+
# replay = ActiveAgent::Evals::Capture.run(SupportAgent.with(question: "Where is order 4821?").help)
|
|
12
|
+
# replay.answer # => "Order 4821 shipped on Monday."
|
|
13
|
+
# replay.tool_names # => ["lookup_order"]
|
|
14
|
+
# replay.tool_calls # => [{ "name" => "lookup_order", "arguments" => { "number" => "4821" }, ... }]
|
|
15
|
+
#
|
|
16
|
+
# Tool calls are captured on the agent instance's `tools_function`, the
|
|
17
|
+
# proc every provider calls to run a tool, which is also where the
|
|
18
|
+
# telemetry tool spans hang. A tool served by a client-side MCP server
|
|
19
|
+
# (`mcp_strategy: :client`, or a provider with no MCP of its own) is
|
|
20
|
+
# dispatched before that proc, so the provider reports it through the
|
|
21
|
+
# agent's `tool_observer` instead, with a failure the server reported
|
|
22
|
+
# marked as one. An observer the agent defines itself still runs, around
|
|
23
|
+
# the call that is recorded. A tool a provider runs on its own side
|
|
24
|
+
# (Anthropic's or OpenAI's hosted MCP) never reaches this process and is
|
|
25
|
+
# not recorded.
|
|
26
|
+
#
|
|
27
|
+
# `model:` and `provider:` replay the same action under another model,
|
|
28
|
+
# the way a comparison run does. A model is an option on the agent
|
|
29
|
+
# instance. A provider is a subclass of the agent configured with
|
|
30
|
+
# `generate_with provider, model:`, named as its parent so its templates
|
|
31
|
+
# still resolve; the parent's `instructions:` option is carried, and the
|
|
32
|
+
# rest of its options follow `generate_with`'s own rule for a subclass
|
|
33
|
+
# (kept under the same service, dropped under another).
|
|
34
|
+
#
|
|
35
|
+
# A block runs the agent some other way; it may return a PromptResponse,
|
|
36
|
+
# a Replay, a Hash of Replay attributes, or the answer as a String. Tool
|
|
37
|
+
# calls are then whatever the block reports, since nothing wrapped the
|
|
38
|
+
# agent.
|
|
39
|
+
#
|
|
40
|
+
# An exception becomes an errored Replay, as it does under Runner, so a
|
|
41
|
+
# scenario that raised is scored and diagnosed like any other;
|
|
42
|
+
# `raise_errors: true` re-raises it instead, for the backtrace.
|
|
43
|
+
class Capture
|
|
44
|
+
# How much of a tool's result a tool call keeps as its "detail".
|
|
45
|
+
DETAIL_LIMIT = 500
|
|
46
|
+
|
|
47
|
+
# @param generation [ActiveAgent::Generation, nil] the action to run; nil with a block
|
|
48
|
+
# @param model [String, nil] the model to run under instead of the agent's own
|
|
49
|
+
# @param provider [Symbol, String, nil] the provider to run under instead of the agent's own
|
|
50
|
+
# @param metadata [Hash] carried onto the Replay's metadata
|
|
51
|
+
# @param raise_errors [Boolean] re-raise instead of returning an errored Replay
|
|
52
|
+
# @return [Replay]
|
|
53
|
+
def self.run(generation = nil, model: nil, provider: nil, metadata: {}, raise_errors: false, &block)
|
|
54
|
+
new(generation, model: model, provider: provider, metadata: metadata, raise_errors: raise_errors).call(&block)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(generation = nil, model: nil, provider: nil, metadata: {}, raise_errors: false)
|
|
58
|
+
@generation = generation
|
|
59
|
+
@model = model
|
|
60
|
+
@provider = provider
|
|
61
|
+
@metadata = (metadata || {}).to_h.transform_keys(&:to_s)
|
|
62
|
+
@raise_errors = raise_errors
|
|
63
|
+
@tool_calls = []
|
|
64
|
+
@agent = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def call(&block)
|
|
68
|
+
raise ArgumentError, "give Capture a generation to run, or a block that runs the agent" unless @generation || block
|
|
69
|
+
|
|
70
|
+
started = self.class.monotonic_ms
|
|
71
|
+
value =
|
|
72
|
+
begin
|
|
73
|
+
@generation ? run_generation : block.call
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
raise if @raise_errors
|
|
76
|
+
|
|
77
|
+
return Replay.failed(e, tool_calls: @tool_calls, duration_ms: self.class.elapsed_ms(started), metadata: base_metadata)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
replay_for(value, duration_ms: self.class.elapsed_ms(started))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The generation with `model:` and `provider:` applied: the one given
|
|
84
|
+
# when neither is set, otherwise a copy that instantiates the retargeted
|
|
85
|
+
# agent. Public so a host that runs generations itself can retarget the
|
|
86
|
+
# same way.
|
|
87
|
+
def self.retarget(generation, model: nil, provider: nil)
|
|
88
|
+
return generation unless model || provider
|
|
89
|
+
|
|
90
|
+
copy = generation.dup
|
|
91
|
+
# The memoized agent, if the generation was already touched, belongs
|
|
92
|
+
# to the original class and action.
|
|
93
|
+
copy.instance_variable_set(:@agent, nil)
|
|
94
|
+
copy.agent_class = provider ? subclass_under(generation.agent_class, provider, model) : generation.agent_class
|
|
95
|
+
copy
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A subclass of `agent_class` that generates with `provider` (and
|
|
99
|
+
# `model`, when given), named as its parent so `agent_name`, the view
|
|
100
|
+
# prefixes and the telemetry span names stay the parent's.
|
|
101
|
+
def self.subclass_under(agent_class, provider, model)
|
|
102
|
+
parent_name = agent_class.name
|
|
103
|
+
parent_options = agent_class.prompt_options || {}
|
|
104
|
+
|
|
105
|
+
Class.new(agent_class).tap do |klass|
|
|
106
|
+
klass.define_singleton_method(:name) { parent_name }
|
|
107
|
+
klass.generate_with(provider, **{ model: model }.compact)
|
|
108
|
+
# generate_with never inherits a parent's instructions. A replay is
|
|
109
|
+
# the same agent under another model, so they come along.
|
|
110
|
+
klass.prompt_options[:instructions] = parent_options[:instructions] if parent_options.key?(:instructions)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Runs one tool call and appends its record to +calls+: the name, the
|
|
115
|
+
# arguments, a preview of the result and the time it took. A call that
|
|
116
|
+
# raises is recorded as an error and re-raised. So is one an MCP server
|
|
117
|
+
# reported as failed: it comes back as an MCPBridge::ErrorResult rather
|
|
118
|
+
# than an exception, so the model can recover, and is returned to the
|
|
119
|
+
# provider unchanged.
|
|
120
|
+
#
|
|
121
|
+
# @api private
|
|
122
|
+
# @return [Object] what the call returned
|
|
123
|
+
def self.record_call(calls, name, arguments)
|
|
124
|
+
call = { "name" => name.to_s }
|
|
125
|
+
call["arguments"] = jsonable(arguments) unless arguments.nil?
|
|
126
|
+
started = monotonic_ms
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
result = yield
|
|
130
|
+
if mcp_error?(result)
|
|
131
|
+
call["error"] = true
|
|
132
|
+
call["detail"] = preview(result.message)
|
|
133
|
+
else
|
|
134
|
+
call["detail"] = preview(result)
|
|
135
|
+
end
|
|
136
|
+
result
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
call["error"] = true
|
|
139
|
+
call["detail"] = "#{e.class}: #{e.message}"
|
|
140
|
+
raise
|
|
141
|
+
ensure
|
|
142
|
+
call["duration_ms"] = elapsed_ms(started)
|
|
143
|
+
calls << call
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.mcp_error?(result)
|
|
148
|
+
defined?(ActiveAgent::Providers::MCPBridge::ErrorResult) && result.is_a?(ActiveAgent::Providers::MCPBridge::ErrorResult)
|
|
149
|
+
end
|
|
150
|
+
private_class_method :mcp_error?
|
|
151
|
+
|
|
152
|
+
def self.monotonic_ms
|
|
153
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.elapsed_ms(started)
|
|
157
|
+
monotonic_ms - started
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Tool arguments and results as JSON would carry them (string keys,
|
|
161
|
+
# plain values), so a recorded call compares with what a test writes.
|
|
162
|
+
def self.jsonable(value)
|
|
163
|
+
JSON.parse(JSON.generate(value))
|
|
164
|
+
rescue StandardError
|
|
165
|
+
value.to_s
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.preview(value)
|
|
169
|
+
text = value.is_a?(String) ? value : (JSON.generate(value) rescue value.to_s)
|
|
170
|
+
text.length > DETAIL_LIMIT ? "#{text[0, DETAIL_LIMIT]}…" : text
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def run_generation
|
|
176
|
+
generation = self.class.retarget(@generation, model: @model, provider: @provider)
|
|
177
|
+
# Generation#agent is private: nothing outside should touch the agent
|
|
178
|
+
# before prompt_later. A capture runs it now, and needs the instance
|
|
179
|
+
# to hook its tool dispatch, so it reaches in on purpose.
|
|
180
|
+
@agent = generation.send(:agent)
|
|
181
|
+
@agent.prompt_options[:model] = @model if @model && !@provider
|
|
182
|
+
record_tool_calls(@agent)
|
|
183
|
+
generation.generate_now
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Wraps the agent's tools_function so each dispatch lands in
|
|
187
|
+
# @tool_calls. The singleton method takes precedence over the telemetry
|
|
188
|
+
# module prepended on the class, and `base` is that module's wrapped
|
|
189
|
+
# proc, so tool spans still fire.
|
|
190
|
+
#
|
|
191
|
+
# A tool served by a client-side MCP server never reaches tools_function:
|
|
192
|
+
# the provider runs that call inside the agent's `tool_observer`, which
|
|
193
|
+
# records it the same way. The two paths are disjoint, so a call is
|
|
194
|
+
# recorded once, and the observer sees the call without making it again.
|
|
195
|
+
#
|
|
196
|
+
# An observer the agent already has is kept, as tools_function's `base`
|
|
197
|
+
# is: it runs around the call this records, so it sees the same name,
|
|
198
|
+
# arguments, result and exception, and a call it does not make is not
|
|
199
|
+
# recorded as made.
|
|
200
|
+
def record_tool_calls(agent)
|
|
201
|
+
calls = @tool_calls
|
|
202
|
+
base = agent.tools_function
|
|
203
|
+
observer = agent.tool_observer
|
|
204
|
+
capture = self.class
|
|
205
|
+
|
|
206
|
+
agent.define_singleton_method(:tools_function) do
|
|
207
|
+
proc do |name, *args, **kwargs|
|
|
208
|
+
arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
|
|
209
|
+
capture.record_call(calls, name, arguments) { base.call(name, *args, **kwargs) }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
agent.define_singleton_method(:tool_observer) do
|
|
214
|
+
record = proc { |name, arguments, &call| capture.record_call(calls, name, arguments.presence, &call) }
|
|
215
|
+
next record unless observer
|
|
216
|
+
|
|
217
|
+
proc { |name, arguments, &call| observer.call(name, arguments) { record.call(name, arguments, &call) } }
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def replay_for(value, duration_ms:)
|
|
222
|
+
case value
|
|
223
|
+
when Replay
|
|
224
|
+
attributes = value.to_h.transform_keys(&:to_sym).merge(
|
|
225
|
+
tool_calls: value.tool_calls.presence || @tool_calls,
|
|
226
|
+
duration_ms: value.duration_ms || duration_ms,
|
|
227
|
+
metadata: base_metadata.merge(value.metadata)
|
|
228
|
+
)
|
|
229
|
+
Replay.new(**attributes)
|
|
230
|
+
when Hash
|
|
231
|
+
replay_for(Replay.new(**value.to_h.transform_keys(&:to_sym)), duration_ms: duration_ms)
|
|
232
|
+
when String, nil
|
|
233
|
+
Replay.new(answer: value, tool_calls: @tool_calls, duration_ms: duration_ms, metadata: base_metadata)
|
|
234
|
+
else
|
|
235
|
+
unless value.respond_to?(:messages)
|
|
236
|
+
raise ArgumentError, "expected the agent run to return a PromptResponse, a Replay, a Hash or a String, got #{value.class}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
replay_from_response(value, duration_ms: duration_ms)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def replay_from_response(response, duration_ms:)
|
|
244
|
+
usage = response.respond_to?(:usage) ? response.usage : nil
|
|
245
|
+
messages = Array(response.messages)
|
|
246
|
+
|
|
247
|
+
Replay.new(
|
|
248
|
+
answer: final_answer(messages),
|
|
249
|
+
tool_calls: @tool_calls,
|
|
250
|
+
duration_ms: duration_ms,
|
|
251
|
+
input_tokens: usage&.input_tokens,
|
|
252
|
+
output_tokens: usage&.output_tokens,
|
|
253
|
+
metadata: base_metadata.merge({ "prompt" => user_prompt(messages) }.compact)
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The agent's last assistant turn. A tool loop leaves tool calls and
|
|
258
|
+
# results before it; the final text comes last.
|
|
259
|
+
def final_answer(messages)
|
|
260
|
+
message = messages.reverse.find { |candidate| candidate.respond_to?(:role) && candidate.role.to_s == "assistant" }
|
|
261
|
+
return nil unless message
|
|
262
|
+
|
|
263
|
+
message.respond_to?(:text) ? message.text : message.content.to_s
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# What the user asked, for the scenario a test scores the replay
|
|
267
|
+
# against: the first user turn, when it is plain text.
|
|
268
|
+
def user_prompt(messages)
|
|
269
|
+
message = messages.find { |candidate| candidate.respond_to?(:role) && candidate.role.to_s == "user" }
|
|
270
|
+
content = message&.content
|
|
271
|
+
content if content.is_a?(String) && content.present?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def base_metadata
|
|
275
|
+
options = @agent&.prompt_options || {}
|
|
276
|
+
{
|
|
277
|
+
"agent" => @agent&.class&.name,
|
|
278
|
+
"action" => @generation&.action_name&.to_s,
|
|
279
|
+
"provider" => @provider&.to_s,
|
|
280
|
+
"service" => options[:service]&.to_s,
|
|
281
|
+
"model" => (@model || options[:model])&.to_s
|
|
282
|
+
}.compact.merge(@metadata)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|