activeagent 1.9.0 → 1.9.2

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.
@@ -37,9 +37,12 @@ module ActiveAgent
37
37
  # @param release [Hash, nil] the release of the agent under evaluation,
38
38
  # `{ "digest", "revision", "label" }`, so the report names the code
39
39
  # it scored: in `to_h["release"]` and as a header chip.
40
+ # @param comparison [Hash, nil] a comparison already recorded for these
41
+ # results (Comparison#to_h). A report rebuilt from a persisted run
42
+ # renders the intervals that run recorded instead of resampling them.
40
43
  def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
41
44
  tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil,
42
- judge_usage: nil, release: nil)
45
+ judge_usage: nil, release: nil, comparison: nil)
43
46
  @results = results
44
47
  @models = models
45
48
  @judge = judge
@@ -53,6 +56,7 @@ module ActiveAgent
53
56
  @recorded_verdict = verdict.is_a?(Hash) ? verdict.to_h.stringify_keys.presence : nil
54
57
  @run_judge_usage = judge_usage.is_a?(Hash) ? judge_usage.to_h.stringify_keys.presence : nil
55
58
  @release = release.is_a?(Hash) ? release.to_h.stringify_keys.compact.presence : nil
59
+ @recorded_comparison = comparison.is_a?(Hash) ? comparison.to_h.deep_stringify_keys.presence : nil
56
60
  end
57
61
 
58
62
  # The release of the agent this report scored, `{ "digest",
@@ -64,7 +68,8 @@ module ActiveAgent
64
68
  end
65
69
 
66
70
  # Per model, keyed by label: scenario count, passes, errors, pass rate,
67
- # mean score, mean latency, tokens, cost and fault counts. "cost" sums
71
+ # mean score, latency (mean, and the P50 and P95 by nearest rank,
72
+ # Statistics.percentile), tokens, cost and fault counts. "cost" sums
68
73
  # the replays that carried a cost — "priced" of the "scenarios", of
69
74
  # which "reported" came from the caller and "estimated" from tokens ×
70
75
  # a model rate (Result#cost_source) — and is nil when none did.
@@ -90,6 +95,8 @@ module ActiveAgent
90
95
  "avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
91
96
  "avg_task_completion" => task_scores.any? ? (task_scores.sum / task_scores.size).round(3) : nil,
92
97
  "avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
98
+ "p50_duration_ms" => Statistics.percentile(durations, 50)&.round,
99
+ "p95_duration_ms" => Statistics.percentile(durations, 95)&.round,
93
100
  "input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
94
101
  "output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
95
102
  "cost" => costs.any? ? costs.sum.to_f.round(6) : nil,
@@ -267,14 +274,27 @@ module ActiveAgent
267
274
  verdict&.dig("winner")
268
275
  end
269
276
 
270
- # Adds "judge_usage" and "release" only when there is one to add, so
271
- # a report built the way it always was serializes exactly as before.
277
+ # Whether the gap between the models is real (Comparison#to_h): each
278
+ # model's pass rate with its interval, every pair's difference with
279
+ # its interval and whether it is decisive, and the conclusion. The
280
+ # comparison recorded for these results when one was handed in. nil
281
+ # for a single model.
282
+ def comparison
283
+ return nil unless comparing?
284
+
285
+ @comparison ||= @recorded_comparison || Comparison.new(results: @results, models: @models).to_h
286
+ end
287
+
288
+ # Adds "judge_usage" and "release" only when there is one to add, and
289
+ # "comparison" only when comparing models, so a single-model report
290
+ # built the way it always was serializes exactly as before.
272
291
  def to_h
273
292
  {
274
293
  "models" => summary_by_model,
275
294
  "criteria" => criterion_scores,
276
295
  "recommendations" => recommendations,
277
296
  "verdict" => verdict,
297
+ "comparison" => comparison,
278
298
  "judge" => @judge_label || @judge&.label,
279
299
  "judge_usage" => judge_usage,
280
300
  "release" => release,
@@ -298,6 +318,7 @@ module ActiveAgent
298
318
  lines.concat(total_lines)
299
319
  lines << "**Best model: #{winner}**" if winner
300
320
  lines << ""
321
+ lines.concat(comparison_lines)
301
322
  lines.concat(matrix_table)
302
323
  lines.concat(detail_lines)
303
324
  lines.concat(recommendation_lines)
@@ -525,22 +546,27 @@ module ActiveAgent
525
546
 
526
547
  # --- Markdown ----------------------------------------------------------
527
548
 
528
- # The per-model table. A Judge column joins it when a judge spent
529
- # anything, so the agent's cost and the evaluation's stay two numbers.
549
+ # The per-model table. Cost / 1,000 puts every model on one
550
+ # denominator however many of its scenarios ran. A Judge column joins
551
+ # it when a judge spent anything, so the agent's cost and the
552
+ # evaluation's stay two numbers.
530
553
  def summary_table
531
554
  judged = judge_usage.present?
532
- columns = [ "Model", "Passed", "Mean score", "Mean latency", "Tokens in/out", "Cost", ("Judge" if judged), "Faults" ].compact
555
+ columns = [ "Model", "Passed", "Mean score", "Mean latency", "Median / P95", "Tokens in/out", "Cost", "Cost / 1,000", ("Judge" if judged), "Faults" ].compact
533
556
  header = [ "| #{columns.join(' | ')} |", "|#{columns.map { '---' }.join('|')}|" ]
534
557
  rows = summary_by_model.map do |label, stats|
535
558
  faults = stats["faults"].map { |fault, count| "#{fault.tr('_', ' ')} ×#{count}" }.join(", ")
536
559
  latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
560
+ percentiles = stats["p50_duration_ms"] ? "#{stats['p50_duration_ms']} / #{stats['p95_duration_ms']} ms" : "—"
537
561
  cells = [
538
562
  "`#{label}`",
539
563
  Format.passes(stats["passed"], stats["scenarios"], style: :markdown),
540
564
  Format.score(stats["avg_score"]),
541
565
  latency,
566
+ percentiles,
542
567
  "#{stats['input_tokens']}/#{stats['output_tokens']}",
543
568
  Format.money(stats["cost"], estimated: estimated_cost?(stats)),
569
+ per_thousand_cell(stats),
544
570
  (judge_cell(stats) if judged),
545
571
  faults.presence || "—"
546
572
  ].compact
@@ -549,6 +575,14 @@ module ActiveAgent
549
575
  header + rows
550
576
  end
551
577
 
578
+ # "$3.6300": what 1,000 scenario runs cost at the model's mean cost
579
+ # per priced replay, marked "~" when that mean is estimated or covers
580
+ # only some of the replays. "—" when none was priced.
581
+ def per_thousand_cell(stats)
582
+ per = cost_per_priced(stats)
583
+ per ? Format.money(per * 1_000, estimated: estimated_cost?(stats)) : "—"
584
+ end
585
+
552
586
  # "~$0.0030 (3 calls)" for a model's judge spend, "—" when the judge
553
587
  # was not asked about its results.
554
588
  def judge_cell(stats)
@@ -572,6 +606,38 @@ module ActiveAgent
572
606
  lines << ""
573
607
  end
574
608
 
609
+ # Each model's pass rate with its interval, best first, then every
610
+ # pair's difference with its interval, the conclusion, and how the
611
+ # intervals were computed. Nothing for a single model.
612
+ def comparison_lines
613
+ return [] unless comparison
614
+
615
+ method = comparison["method"]
616
+ confidence = "#{(method['confidence'].to_f * 100).round}% CI"
617
+ lines = [ "## Comparison", "", "| Model | Pass rate | #{confidence} | Scenarios |", "|---|---|---|---|" ]
618
+ comparison["ranking"].each do |label|
619
+ stats = comparison["models"][label]
620
+ lines << "| `#{label}` | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
621
+ end
622
+ lines += [ "", "| Pair | Difference | #{confidence} | Shared scenarios | Decisive |", "|---|---|---|---|---|" ]
623
+ comparison["pairs"].each do |pair|
624
+ a, b = pair["models"]
625
+ difference = pair["difference"] ? format("%+.1f points", pair["difference"]) : "—"
626
+ lines << "| `#{a}` vs `#{b}` | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
627
+ end
628
+ lines += [ "", comparison["conclusion"].to_s, "",
629
+ "_#{confidence} from #{Format.delimited(method['resamples'])} resamples of whole scenarios (seed #{method['seed']})._", "" ]
630
+ end
631
+
632
+ def rate_text(rate)
633
+ rate ? "#{rate}%" : "—"
634
+ end
635
+
636
+ # "73.8 to 95.2", or "—" when there were too few scenarios.
637
+ def interval_text(interval)
638
+ interval ? "#{interval.first} to #{interval.last}" : "—"
639
+ end
640
+
575
641
  # The scenario matrix, with a trailing Cost column: what the scenario
576
642
  # cost across every model, and in brackets what the judge spent on it.
577
643
  def matrix_table
@@ -151,6 +151,14 @@ module ActiveAgent
151
151
  "#{format("%.#{n >= 10_000 ? 1 : 2}f", n / 1_000).sub(/\.?0+\z/, '')}s"
152
152
  end
153
153
 
154
+ # "4.02s / 22.2s": a model's P50 and P95 latency; "—" when no replay
155
+ # was timed.
156
+ def fmt_percentiles(stats)
157
+ return "—" if stats["p50_duration_ms"].nil?
158
+
159
+ "#{fmt_ms(stats['p50_duration_ms'])} / #{fmt_ms(stats['p95_duration_ms'])}"
160
+ end
161
+
154
162
  # Money reads "~$0.0243" when any part of it was estimated from tokens
155
163
  # × a model rate rather than reported (Format.money); the footer's
156
164
  # legend explains the mark once.
@@ -314,6 +322,7 @@ module ActiveAgent
314
322
  <div class="panel">
315
323
  <div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span></div>
316
324
  #{html_comparison_table if comparing?}
325
+ #{html_intervals}
317
326
  #{blocks.join}
318
327
  #{verdict_row}
319
328
  </div>
@@ -335,12 +344,49 @@ module ActiveAgent
335
344
 
336
345
  <<~TABLE
337
346
  <div class="compare"><table>
338
- <thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
347
+ <thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Median and 95th percentile latency, nearest rank">P50 / P95</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
339
348
  <tbody>#{rows.map { |label, stats| html_comparison_row(label, stats) }.join}</tbody>
340
349
  </table></div>
341
350
  TABLE
342
351
  end
343
352
 
353
+ # The ranked interval plot under the comparison table (Report#comparison):
354
+ # one row per model, best first, its pass rate a dot inside its interval
355
+ # on a fixed 0-100% axis, so a narrow gap is not magnified, with the
356
+ # figures written out beside it; then the conclusion and how the
357
+ # intervals were computed. Nothing for a single model. The rows and the
358
+ # axis share one grid (.iv-row is display: contents), so every track
359
+ # spans the same width and a tick sits under the dots it names.
360
+ def html_intervals
361
+ return "" unless comparison
362
+
363
+ method = comparison["method"]
364
+ confidence = "#{(method['confidence'].to_f * 100).round}% CI"
365
+ rows = comparison["ranking"].map { |label| html_interval_row(label, comparison["models"][label] || {}) }
366
+ ticks = [ 0, 25, 50, 75, 100 ].map { |tick| %(<span class="iv-tick" style="left:#{tick}%">#{tick}%</span>) }
367
+
368
+ <<~PLOT
369
+ <div class="intervals">
370
+ <div class="micro sm">Pass rate · #{h(confidence)}</div>
371
+ #{rows.join}
372
+ <div class="iv-row iv-axis" aria-hidden="true"><span></span><span class="iv-track">#{ticks.join}</span><span></span></div>
373
+ <p class="iv-conclusion">#{h(comparison['conclusion'])}</p>
374
+ <p class="iv-caption">#{h(confidence)} from #{h(Format.delimited(method['resamples']))} resamples of whole scenarios</p>
375
+ </div>
376
+ PLOT
377
+ end
378
+
379
+ def html_interval_row(label, stats)
380
+ rate = stats["pass_rate"]
381
+ low, high = stats["interval"]
382
+ range = high ? %(<span class="iv-range" style="left:#{low}%;width:#{(high - low).round(1)}%"></span>) : ""
383
+ point = rate ? %(<span class="iv-point" style="left:#{rate}%"></span>) : ""
384
+ figures = "<b>#{rate ? "#{rate}%" : '—'}</b> #{high ? "#{low} to #{high}" : 'no interval'} · #{h(plural(stats['scenarios'].to_i, 'scenario'))}"
385
+
386
+ %(<div class="iv-row"><span class="iv-name">#{h(short_name(model_by_label(label)))}</span>) +
387
+ %(<span class="iv-track">#{range}#{point}</span><span class="iv-figures">#{figures}</span></div>\n)
388
+ end
389
+
344
390
  def html_comparison_row(label, stats)
345
391
  short, provider = split_label(model_by_label(label))
346
392
  total = stats["scenarios"].to_i
@@ -362,6 +408,7 @@ module ActiveAgent
362
408
  <td class="num ratio tone-#{tone_for(ratio)}">#{h(fmt_passes(stats['passed'], total))}</td>
363
409
  <td class="num">#{h(fmt_score(stats['avg_score']))}</td>
364
410
  <td class="num">#{h(fmt_ms(stats['avg_duration_ms']))}</td>
411
+ <td class="num">#{h(fmt_percentiles(stats))}</td>
365
412
  <td class="num"#{tokens_title}>#{tokens_cell}</td>
366
413
  <td class="num"#{cost_title_attr(results_for(label), estimated: estimated)}>#{cost_cell}</td>
367
414
  #{judge_cell}<td class="fault">#{typical_fault_text(label, stats)}</td>
@@ -417,7 +464,7 @@ module ActiveAgent
417
464
  <<~BLOCK
418
465
  <div class="model">
419
466
  <div class="line"><span class="name">#{h(short)}</span><span class="provider">#{h(provider)}</span>#{pick}<span class="pass"><span class="bar bar-#{tone}"><span style="width:#{(ratio * 100).round}%"></span></span><span class="ratio tone-#{tone}">#{h(fmt_passes(stats['passed'], total))}</span></span></div>
420
- <div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
467
+ <div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span>p50 / p95 <b>#{h(fmt_percentiles(stats))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
421
468
  <div class="faults">#{faults_html}</div>
422
469
  </div>
423
470
  BLOCK
@@ -830,6 +877,21 @@ module ActiveAgent
830
877
  .pick-model input { position: absolute; opacity: 0; width: 0; height: 0; }
831
878
  .pick-model:has(input:checked) { border-color: var(--color-accent-ui); background: var(--color-accent-ui-tint); color: var(--color-accent-ui); }
832
879
  .clean { font-family: var(--font-mono); font-size: 11px; color: var(--color-success-text); }
880
+ .intervals { padding: 10px 12px; border-top: 1px solid var(--color-border-light); display: grid; grid-template-columns: minmax(120px, 200px) minmax(160px, 1fr) minmax(190px, auto); align-items: center; gap: 6px 12px; }
881
+ .intervals > .micro, .iv-conclusion, .iv-caption { grid-column: 1 / -1; }
882
+ .iv-row { display: contents; }
883
+ .iv-name { font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
884
+ .iv-track { position: relative; height: 14px; }
885
+ .iv-track::before { content: ""; position: absolute; left: 0; right: 0; top: 6px; height: 2px; background: var(--color-muted); }
886
+ .iv-range { position: absolute; top: 3px; height: 8px; box-sizing: border-box; border: 1px solid var(--color-info); border-radius: 999px; background: var(--color-info-soft); }
887
+ .iv-point { position: absolute; top: 2px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: var(--color-info); }
888
+ .iv-figures { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-secondary); white-space: nowrap; }
889
+ .iv-figures b { color: var(--color-text-primary); }
890
+ .iv-axis .iv-track::before { display: none; }
891
+ .iv-tick { position: absolute; top: 0; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
892
+ .iv-conclusion { margin: 4px 0 0; font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
893
+ .iv-caption { margin: 0; font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
894
+ @media (max-width: 640px) { .intervals { grid-template-columns: minmax(0, 1fr); row-gap: 4px; } .iv-row + .iv-row .iv-name { margin-top: 6px; } .iv-axis { display: none; } }
833
895
  .verdict { padding: 10px 12px; border-top: 1px solid var(--color-border-light); font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
834
896
  .verdict .micro { margin-right: 8px; }
835
897
  .fixes { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
@@ -54,10 +54,14 @@ module ActiveAgent
54
54
  # @param release [Hash, nil] the release of the agent under evaluation,
55
55
  # `{ "digest", "revision", "label" }`, carried onto the Report so a
56
56
  # published run names the code it scored (see Report.new)
57
+ # @param refine_gate [#call, nil] asked before each recommendation call,
58
+ # after `judge_limit`; false skips it. A caller that scores each
59
+ # scenario in its own Runner (a background job per scenario) shares one
60
+ # run-wide limit this way, since `judge_limit` counts per instance.
57
61
  def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
58
62
  instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
59
63
  refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
60
- around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil)
64
+ around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil)
61
65
  @scenarios = scenarios
62
66
  @models = models
63
67
  @replay = replay
@@ -75,6 +79,7 @@ module ActiveAgent
75
79
  @require_judge_scores = require_judge_scores
76
80
  @metadata = metadata
77
81
  @release = release
82
+ @refine_gate = refine_gate
78
83
  @judge_calls = 0
79
84
  @scorer = Scorer.new(criteria: criteria, judge: judge)
80
85
  end
@@ -187,6 +192,7 @@ module ActiveAgent
187
192
  def refine!(diagnosis, scenario, replay, result)
188
193
  return unless @judge && @refine_faults.include?(result.fault)
189
194
  return if @judge_calls >= @judge_limit
195
+ return if @refine_gate && !@refine_gate.call
190
196
 
191
197
  @judge_calls += 1
192
198
  refined = @judge.recommend(scenario: scenario, replay: replay, diagnosis: result,
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # The summary statistics a report computes over its results.
6
+ module Statistics
7
+ module_function
8
+
9
+ # Nearest-rank percentile: the value at rank ceil(pct / 100 × n) of the
10
+ # values sorted ascending, so the result is always one of the values. The
11
+ # dashboard's Metrics view uses the same rule (ActionAgent::MetricsReport),
12
+ # so a model's P95 reads the same in both. nil for no values.
13
+ #
14
+ # @param values [Array<Numeric>] in any order; nils are ignored
15
+ # @param pct [Numeric] 0..100
16
+ # @return [Numeric, nil]
17
+ def percentile(values, pct)
18
+ sorted = values.compact.sort
19
+ return nil if sorted.empty?
20
+
21
+ rank = (sorted.size * pct / 100.0).ceil
22
+ sorted[rank.clamp(1, sorted.size) - 1]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -19,6 +19,8 @@ require_relative "evals/catalog"
19
19
  require_relative "evals/model_spec"
20
20
  require_relative "evals/replay"
21
21
  require_relative "evals/format"
22
+ require_relative "evals/statistics"
23
+ require_relative "evals/comparison"
22
24
  require_relative "evals/scorer"
23
25
  require_relative "evals/diagnosis"
24
26
  require_relative "evals/judge"
@@ -43,7 +43,7 @@ module ActiveAgent
43
43
  def initialize(kwargs = {})
44
44
  super
45
45
 
46
- self.json_format_retry_count = kwargs[:max_retries] || ::Anthropic::Client::DEFAULT_MAX_RETRIES
46
+ self.json_format_retry_count = options.max_retries
47
47
  end
48
48
 
49
49
  # @todo Add support for Anthropic::BedrockClient and Anthropic::VertexClient
@@ -222,6 +222,8 @@ module ActiveAgent
222
222
  # No-Op; Wait for Function call to be complete
223
223
  when :thinking_delta, :signature_delta
224
224
  # TODO: Add with thinking rendering support
225
+ when :citations_delta
226
+ # No-Op; the citations arrive with the full block at content_block_stop
225
227
  else
226
228
  raise "Unexpected delta type: #{api_delta.type}"
227
229
  end
@@ -143,6 +143,13 @@ module ActiveAgent
143
143
  # => {type: :"tool_calls.function.arguments.delta", name: "get_current_weather", index: 0, arguments: "", parsed: nil, arguments_delta: ""}
144
144
  when :"tool_calls.function.arguments.done"
145
145
  # => => {type: :"tool_calls.function.arguments.done", index: 0, name: "get_current_weather", arguments: "{\"location\":\"Boston, MA\"}", parsed: nil}
146
+ when :"refusal.delta"
147
+ # No-Op; the refusal is merged into the message from the :chunk event
148
+ when :"refusal.done"
149
+ # A refused message ends here, with no content.done
150
+ self.stream_completion_pending = true
151
+ when :"logprobs.content.delta", :"logprobs.content.done", :"logprobs.refusal.delta", :"logprobs.refusal.done"
152
+ # No-Op; sent when the request asks for logprobs
146
153
  else
147
154
  fail "Unexpected Response Event Type: #{api_response_event.type}"
148
155
  end
@@ -15,6 +15,18 @@ module ActiveAgent
15
15
  class ResponsesProvider < Base
16
16
  include ToolChoiceClearing
17
17
 
18
+ # Output items of OpenAI's built-in tools (web search, file search,
19
+ # image generation, code interpreter, remote MCP).
20
+ BUILT_IN_TOOL_ITEM_TYPES = %i[
21
+ web_search_call file_search_call image_generation_call code_interpreter_call mcp_call mcp_list_tools
22
+ ].freeze
23
+
24
+ # Prefixes of the progress events those tools stream.
25
+ BUILT_IN_TOOL_EVENT_PREFIXES = %w[
26
+ response.web_search_call. response.file_search_call. response.image_generation_call.
27
+ response.code_interpreter_call response.mcp_call response.mcp_list_tools.
28
+ ].freeze
29
+
18
30
  # @return [Class]
19
31
  def self.options_klass
20
32
  Options
@@ -99,6 +111,7 @@ module ActiveAgent
99
111
  # - `:"response.content_part.done"` - content part completed
100
112
  # - `:"response.output_item.done"` - message, reasoning or function call completed
101
113
  # - `:"response.completed"` - response finished
114
+ # - `:"response.incomplete"` - response cut short (e.g. max_output_tokens)
102
115
  #
103
116
  # @param api_response_event [Hash] streaming chunk with :type key
104
117
  # @return [void]
@@ -106,6 +119,12 @@ module ActiveAgent
106
119
  def process_stream_chunk(api_response_event)
107
120
  instrument("stream_chunk.active_agent", chunk_type: api_response_event.type)
108
121
 
122
+ # Built-in tools report their progress with their own events; the
123
+ # finished tool call arrives as an output item.
124
+ if BUILT_IN_TOOL_EVENT_PREFIXES.any? { api_response_event.type.to_s.start_with?(_1) }
125
+ return
126
+ end
127
+
109
128
  case api_response_event.type
110
129
  # Response Created
111
130
  when :"response.created", :"response.in_progress"
@@ -124,12 +143,20 @@ module ActiveAgent
124
143
  message[:content] += api_response_event.delta
125
144
  broadcast_stream_update(message, api_response_event.delta)
126
145
 
146
+ # -> -> -> Content Text Citation (e.g. from web search)
147
+ when :"response.output_text.annotation.added"
148
+ # No-Op: The text is complete at response.output_text.done
149
+
127
150
  # -> -> -> Content Text Completed [Full Text]
128
151
  when :"response.output_text.done"
129
152
  message = message_stack.find { _1[:id] == api_response_event.item_id }
130
153
  message[:content] = api_response_event.text
131
154
  broadcast_stream_update(message, nil) # Don't double send content
132
155
 
156
+ # -> -> -> Refusal (the response still ends with response.completed)
157
+ when :"response.refusal.delta", :"response.refusal.done"
158
+ # No-Op: The message has no refusal field to stream it into
159
+
133
160
  # -> -> -> Content Function Call Append
134
161
  when :"response.function_call_arguments.delta", :"response.function_call_arguments.done"
135
162
  # No-Op: Wait for FC to Land
@@ -147,8 +174,13 @@ module ActiveAgent
147
174
  when :"response.output_item.done"
148
175
  process_stream_output_item_done(api_response_event)
149
176
 
150
- # Response Completed
151
- when :"response.completed"
177
+ # Response Completed, or cut short (e.g. by max_output_tokens) with
178
+ # the output it has so far
179
+ when :"response.completed", :"response.incomplete"
180
+ # The streamed response carries no body for resolve_prompt to read
181
+ # usage from, so it is recorded from the final event.
182
+ record_stream_usage(api_response_event.response.usage)
183
+
152
184
  # Completion, tool loop included, runs from stream_finished! once
153
185
  # the stream has drained, so each turn's tool calls run once.
154
186
  self.stream_completion_pending = true
@@ -174,7 +206,7 @@ module ActiveAgent
174
206
  # PATCH: API returns an empty array instead of empty string due to a bug in their serialization
175
207
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item).compact_blank
176
208
  message_stack << { content: "" }.merge(item_hash)
177
- when :function_call, :reasoning
209
+ when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
178
210
  # No-Op: Wait for the item to land (-> response.output_item.done)
179
211
  else
180
212
  raise "Unexpected Item Type: #{api_response_event.item.type}"
@@ -194,7 +226,8 @@ module ActiveAgent
194
226
  case api_response_event.item.type
195
227
  when :message
196
228
  # No-Op: Message Up to Date
197
- when :function_call, :reasoning
229
+ when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
230
+ # Kept like the non-streamed output, which holds every item
198
231
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item)
199
232
  message_stack << item_hash
200
233
  else