actionagent 1.9.1 → 1.9.3
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/README.md +23 -0
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +61 -61
- data/app/controllers/action_agent/api/agents_controller.rb +23 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +49 -10
- data/app/controllers/action_agent/api/evaluation_reports_controller.rb +66 -2
- data/app/controllers/action_agent/api/evaluations_controller.rb +56 -6
- data/app/controllers/action_agent/api/interactions_controller.rb +55 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +13 -2
- data/app/controllers/action_agent/api/trace_reports_controller.rb +11 -6
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +2 -2
- data/app/jobs/action_agent/code_session_job.rb +19 -2
- data/app/jobs/action_agent/evaluation_judge_job.rb +39 -0
- data/app/jobs/action_agent/evaluation_replay_job.rb +28 -0
- data/app/models/action_agent/agent.rb +54 -1
- data/app/models/action_agent/code_session.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +37 -5
- data/app/models/action_agent/provider_key.rb +5 -1
- data/app/queries/action_agent/trace_window_report.rb +60 -0
- data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
- data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
- data/app/services/action_agent/agent_execution_service.rb +37 -0
- data/app/services/action_agent/agent_tool_roster.rb +53 -18
- data/app/services/action_agent/agent_toolbox.rb +156 -3
- data/app/services/action_agent/browser_client_token.rb +86 -0
- data/app/services/action_agent/checkout_tools.rb +154 -0
- data/app/services/action_agent/code_conversation.rb +133 -0
- data/app/services/action_agent/codex_auth.rb +81 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
- data/app/services/action_agent/diff_stat.rb +27 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +4 -1
- data/app/services/action_agent/evaluation_report_export.rb +175 -0
- data/app/services/action_agent/evaluation_report_import.rb +47 -19
- data/app/services/action_agent/evaluation_runner_service.rb +106 -14
- data/app/services/action_agent/local_sandbox_backend.rb +295 -30
- data/app/services/action_agent/mcp_catalog.rb +25 -0
- data/app/services/action_agent/mcp_client.rb +1 -0
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
- data/app/services/action_agent/project_boot_fix.rb +10 -3
- data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +381 -3
- data/app/services/action_agent/slack_tools.rb +101 -0
- data/config/routes.rb +10 -1
- data/lib/action_agent/engine.rb +6 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +50 -2
- data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
- data/lib/tasks/evaluations.rake +78 -0
- metadata +15 -2
|
@@ -17,6 +17,9 @@ module ActionAgent
|
|
|
17
17
|
# "_models" — per model: pass rate, mean score, latency, tokens, cost, fault counts
|
|
18
18
|
# "_recommendations" — faults grouped across scenarios with the fix each calls for
|
|
19
19
|
# "_verdict" — the best model and why (judge-written when a judge is available)
|
|
20
|
+
# "_comparison" — when comparing models: each model's pass rate with its
|
|
21
|
+
# interval, every pair's difference and whether it is
|
|
22
|
+
# decisive (ActiveAgent::Evals::Comparison)
|
|
20
23
|
# "_selection" — the scenarios and models this run covered, and
|
|
21
24
|
# the checkout sandbox it replayed against, if any
|
|
22
25
|
#
|
|
@@ -87,6 +90,10 @@ module ActionAgent
|
|
|
87
90
|
|
|
88
91
|
ensure_browser_live!
|
|
89
92
|
|
|
93
|
+
adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(@evaluation)
|
|
94
|
+
judge_provider if adapter.nil? && @evaluation.judge_model.present?
|
|
95
|
+
return fan_out!(run, scenarios, specs) if fan_out?(adapter)
|
|
96
|
+
|
|
90
97
|
records = scenarios.index_by(&:key)
|
|
91
98
|
tasks = scenarios.map { |scenario| Evals::Scenario.from_hash(scenario.as_json_summary) }
|
|
92
99
|
expected = tasks.product(specs).map { |task, spec| [ task.key, spec.label ] }
|
|
@@ -102,7 +109,6 @@ module ActionAgent
|
|
|
102
109
|
persist(run, records.fetch(result.scenario.key), result)
|
|
103
110
|
persisted << pair
|
|
104
111
|
end
|
|
105
|
-
adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(@evaluation)
|
|
106
112
|
report = if adapter
|
|
107
113
|
raise ArgumentError, "scenario evaluation adapter must be callable" unless adapter.respond_to?(:call)
|
|
108
114
|
|
|
@@ -142,8 +148,361 @@ module ActionAgent
|
|
|
142
148
|
stop_started_browser
|
|
143
149
|
end
|
|
144
150
|
|
|
151
|
+
# --- fan-out (EvaluationReplayJob, EvaluationJudgeJob) -----------------
|
|
152
|
+
#
|
|
153
|
+
# With ActionAgent.scenario_evaluation_fan_out on, #call enqueues one
|
|
154
|
+
# EvaluationReplayJob per scenario x model and returns the run still
|
|
155
|
+
# running. Each replay is stored as a pending result and scored by its
|
|
156
|
+
# own EvaluationJudgeJob, so the agent's next replay need not wait for
|
|
157
|
+
# the judge. The job that scores the last pending result completes the
|
|
158
|
+
# run with the same scores the sequential path writes.
|
|
159
|
+
#
|
|
160
|
+
# The agent's tool roster is discovered once, before the replays, and
|
|
161
|
+
# kept with the run's bookkeeping (scores["_fan_out"]) for every job to
|
|
162
|
+
# read, as the sequential path resolves it once per run: a diagnosis
|
|
163
|
+
# names the same tools whichever job wrote it, and the run's discovery
|
|
164
|
+
# warning does not depend on which job completed the run.
|
|
165
|
+
|
|
166
|
+
# Replays one scenario under one model and stores the answer as a pending
|
|
167
|
+
# result, then enqueues its scoring. A result already stored for the
|
|
168
|
+
# pair (a retried job) is not replayed again.
|
|
169
|
+
def replay_scenario!(run, scenario_id, spec_value)
|
|
170
|
+
return unless run.running?
|
|
171
|
+
|
|
172
|
+
scenario = @evaluation.scenarios.find(scenario_id)
|
|
173
|
+
spec = model_spec_from(spec_value)
|
|
174
|
+
row = stored_result(run, scenario, spec)
|
|
175
|
+
unless row
|
|
176
|
+
task = Evals::Scenario.from_hash(scenario.as_json_summary)
|
|
177
|
+
replay = fan_out_replay(task, spec)
|
|
178
|
+
row = run.with_lock { stored_result(run, scenario, spec) || store_replay(run, scenario, task, spec, replay) }
|
|
179
|
+
end
|
|
180
|
+
EvaluationJudgeJob.perform_later(row.id, @selection.to_h, run.id) if row.pending?
|
|
181
|
+
row
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Scores, diagnoses and stores one pending result, then completes the run
|
|
185
|
+
# when it was the last.
|
|
186
|
+
#
|
|
187
|
+
# A job delivered again for a result that is already scored, or that a
|
|
188
|
+
# concurrent delivery has claimed, scores nothing. It still checks
|
|
189
|
+
# whether the run is complete: the earlier delivery may have stored the
|
|
190
|
+
# last result and stopped before completing the run.
|
|
191
|
+
def judge_result!(row)
|
|
192
|
+
run = row.evaluation_run
|
|
193
|
+
return unless run.running?
|
|
194
|
+
return finish_fan_out!(run) unless row.pending? && claim_result!(row)
|
|
195
|
+
|
|
196
|
+
spec = fan_out_specs(run).fetch([ row.provider.to_s, row.model ]) do
|
|
197
|
+
Evals::ModelSpec.new(label: row.model, provider: row.provider.to_s, model: row.model)
|
|
198
|
+
end
|
|
199
|
+
result = fan_out_scorer(run).evaluate(Evals::Scenario.from_hash(row.evaluated_scenario), spec, replay_from(row))
|
|
200
|
+
row.update!(
|
|
201
|
+
status: result.status,
|
|
202
|
+
score: result.score,
|
|
203
|
+
scores: result.scores,
|
|
204
|
+
fault: result.fault,
|
|
205
|
+
recommendation: result.recommendation,
|
|
206
|
+
diagnosis: (result.diagnosis || {}).merge(row.diagnosis.to_h.slice("_replay_metadata", "_scenario_snapshot"))
|
|
207
|
+
.merge("_judge_usage" => judge_usage).compact
|
|
208
|
+
)
|
|
209
|
+
# The result now holds what its scoring cost; whatever this runner asks
|
|
210
|
+
# the judge from here on (the verdict, when it completes the run) is the
|
|
211
|
+
# run's own.
|
|
212
|
+
@judge_usage = nil
|
|
213
|
+
finish_fan_out!(run)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Stores +error+ as the result of a pair whose job failed outside the
|
|
217
|
+
# replay (which records its own errors), so the run still completes. A
|
|
218
|
+
# scenario removed from the suite during the run has no result to
|
|
219
|
+
# store; the run completes without it (see #finish_fan_out!).
|
|
220
|
+
def fail_result!(run, scenario_id, spec_value, error)
|
|
221
|
+
return unless run.running?
|
|
222
|
+
|
|
223
|
+
scenario = @evaluation.scenarios.find_by(id: scenario_id)
|
|
224
|
+
if scenario
|
|
225
|
+
spec = model_spec_from(spec_value)
|
|
226
|
+
run.with_lock do
|
|
227
|
+
row = stored_result(run, scenario, spec)
|
|
228
|
+
task = Evals::Scenario.from_hash(scenario.as_json_summary)
|
|
229
|
+
row ||= store_replay(run, scenario, task, spec, Evals::Replay.failed(error))
|
|
230
|
+
row.update!(status: :errored, error_message: row.error_message.presence || error_text(error)) if row.pending?
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
finish_fan_out!(run)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Marks a pending result errored when its scoring job failed, so the run
|
|
237
|
+
# still completes.
|
|
238
|
+
def fail_judging!(row, error)
|
|
239
|
+
run = row.evaluation_run
|
|
240
|
+
row.update!(status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending?
|
|
241
|
+
finish_fan_out!(run)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Completes the run once every selected scenario x model has a scored
|
|
245
|
+
# result, with the scores, verdict and judge usage a sequential run
|
|
246
|
+
# writes. A scenario removed from the suite during the run is no longer
|
|
247
|
+
# waited for (its results went with it), so the run completes without it.
|
|
248
|
+
#
|
|
249
|
+
# Nothing is computed under a lock. The verdict is a judge call, and a
|
|
250
|
+
# lock on the run is, on SQLite, the write lock on the whole database:
|
|
251
|
+
# held for the call, it would stall every other writer. The lock covers
|
|
252
|
+
# only the check that the run is still running and the one write that
|
|
253
|
+
# completes it. Of two jobs that both find the run finished, one
|
|
254
|
+
# completes it and the other's work is dropped, at the cost of a second
|
|
255
|
+
# verdict call in that rare tie.
|
|
256
|
+
def finish_fan_out!(run)
|
|
257
|
+
run.reload
|
|
258
|
+
return unless run.running?
|
|
259
|
+
|
|
260
|
+
specs = fan_out_specs(run)
|
|
261
|
+
rows = run.scenario_results.includes(:agent_run).to_a
|
|
262
|
+
return if rows.size < expected_results(run, specs) || rows.any?(&:pending?)
|
|
263
|
+
|
|
264
|
+
attributes = begin
|
|
265
|
+
completion_attributes(run, rows, specs)
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
{ status: :failed, error_message: e.message, completed_at: Time.current }
|
|
268
|
+
end
|
|
269
|
+
run.with_lock do
|
|
270
|
+
run.update!(attributes) if run.running?
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
145
274
|
private
|
|
146
275
|
|
|
276
|
+
def fan_out?(adapter)
|
|
277
|
+
ActionAgent.scenario_evaluation_fan_out == true && adapter.nil? && !browser_requested? &&
|
|
278
|
+
Evals::Runner.instance_method(:initialize).parameters.any? { |_type, name| name == :refine_gate }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def fan_out!(run, scenarios, specs)
|
|
282
|
+
refuse_duplicate_specs!(specs)
|
|
283
|
+
ensure_judge_defined_kpis! if @evaluation.judge_defined?
|
|
284
|
+
# Discovered here, before any lock is taken and before a sandbox the
|
|
285
|
+
# run replays against can expire; the jobs read it from the run.
|
|
286
|
+
roster = { "tools" => tool_roster, "discovery_warning" => discovery_warning }
|
|
287
|
+
update_fan_out_state!(run) do |state|
|
|
288
|
+
state.merge(roster).merge(judge_usage ? { "judge_usage" => judge_usage } : {})
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
selection = @selection.to_h
|
|
292
|
+
scenarios.product(specs).each do |scenario, spec|
|
|
293
|
+
EvaluationReplayJob.perform_later(run.id, scenario.id, spec.to_h, selection)
|
|
294
|
+
end
|
|
295
|
+
run
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# A fanned-out run keys its results by provider and model, where the
|
|
299
|
+
# sequential path keys them by label, so one model listed under two
|
|
300
|
+
# labels ("gpt-5-mini" and "openai/gpt-5-mini") would be replayed once
|
|
301
|
+
# and reported under one of them; the run refuses the selection instead.
|
|
302
|
+
def refuse_duplicate_specs!(specs)
|
|
303
|
+
duplicates = specs.group_by { |spec| [ spec.provider, spec.model ] }.select { |_pair, group| group.size > 1 }
|
|
304
|
+
return if duplicates.empty?
|
|
305
|
+
|
|
306
|
+
names = duplicates.map { |(provider, model), group| "#{provider}/#{model} as #{group.map(&:label).join(' and ')}" }
|
|
307
|
+
raise ArgumentError, "the same model is listed more than once (#{names.to_sentence}); list each provider/model once"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def model_spec_from(value)
|
|
311
|
+
value = value.to_h.stringify_keys
|
|
312
|
+
Evals::ModelSpec.new(label: value["label"].presence || value["model"], provider: value["provider"].to_s, model: value["model"])
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The run's candidate models, in the order it selected them, keyed by
|
|
316
|
+
# provider and model as a result row records them.
|
|
317
|
+
def fan_out_specs(run)
|
|
318
|
+
Array(run.selection&.dig("models")).filter_map do |entry|
|
|
319
|
+
model_spec_from(entry) if entry.is_a?(Hash) && entry["model"].present?
|
|
320
|
+
end.index_by { |spec| [ spec.provider, spec.model ] }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def stored_result(run, scenario, spec)
|
|
324
|
+
run.scenario_results.find_by(evaluation_scenario_id: scenario.id, model: spec.model, provider: spec.provider)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# How many results the run waits for: one per selected scenario still in
|
|
328
|
+
# the suite, per model.
|
|
329
|
+
def expected_results(run, specs)
|
|
330
|
+
ids = Array(run.selection&.dig("scenario_ids"))
|
|
331
|
+
@evaluation.scenarios.where(id: ids).count * specs.size
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Claims a pending result for this job before it is scored. Of two judge
|
|
335
|
+
# jobs delivered for one result (a replay job delivered again after it
|
|
336
|
+
# enqueued the first), only the one whose claim lands scores it, so the
|
|
337
|
+
# judge is asked once and the run's meter counts each call once. The
|
|
338
|
+
# claim is a conditional write on the row's timestamp: it holds no lock
|
|
339
|
+
# while the judge is asked, and a row whose claimant then stops is still
|
|
340
|
+
# pending for the next delivery.
|
|
341
|
+
def claim_result!(row)
|
|
342
|
+
EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at).update_all(updated_at: Time.current) == 1
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# What Evals::Runner does with a replay that raises: an errored replay,
|
|
346
|
+
# scored like any other.
|
|
347
|
+
def fan_out_replay(task, spec)
|
|
348
|
+
replay(task, spec)
|
|
349
|
+
rescue StandardError => e
|
|
350
|
+
Evals::Replay.failed(e)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def store_replay(run, scenario, task, spec, replay)
|
|
354
|
+
run.scenario_results.create!(
|
|
355
|
+
scenario: scenario,
|
|
356
|
+
agent_run_id: replay.metadata["agent_run_id"],
|
|
357
|
+
model: spec.model,
|
|
358
|
+
provider: spec.provider,
|
|
359
|
+
status: :pending,
|
|
360
|
+
output: replay.answer.to_s.byteslice(0, 20_000).to_s.scrub.presence,
|
|
361
|
+
tool_calls: replay.tool_calls,
|
|
362
|
+
duration_ms: replay.duration_ms,
|
|
363
|
+
input_tokens: replay.input_tokens,
|
|
364
|
+
output_tokens: replay.output_tokens,
|
|
365
|
+
cost: replay.cost,
|
|
366
|
+
diagnosis: {
|
|
367
|
+
"_replay_metadata" => replay.metadata,
|
|
368
|
+
"_scenario_snapshot" => task.to_h.merge(expectations: task.expectations)
|
|
369
|
+
},
|
|
370
|
+
error_message: replay.error
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The stored replay, with the answer as the agent run recorded it. The
|
|
375
|
+
# result keeps the first 20,000 bytes of the answer to show; a rule or
|
|
376
|
+
# an expectation (contains, not_contains, min_length) reads all of it, as
|
|
377
|
+
# it does on the sequential path, so a long answer scores the same
|
|
378
|
+
# fanned out.
|
|
379
|
+
def replay_from(row)
|
|
380
|
+
Evals::Replay.new(
|
|
381
|
+
answer: row.agent_run&.output.presence || row.output, tool_calls: row.tool_calls, duration_ms: row.duration_ms,
|
|
382
|
+
input_tokens: row.input_tokens, output_tokens: row.output_tokens, error: row.error_message,
|
|
383
|
+
cost: row.cost&.to_f, metadata: row.replay_metadata
|
|
384
|
+
)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# A Runner that scores one replay. Its refine_gate shares the run-wide
|
|
388
|
+
# judge_limit with every other result's scorer, and its roster is the one
|
|
389
|
+
# discovered when the run fanned out (a run fanned out before the roster
|
|
390
|
+
# was kept discovers its own).
|
|
391
|
+
def fan_out_scorer(run)
|
|
392
|
+
Evals::Runner.new(
|
|
393
|
+
scenarios: [],
|
|
394
|
+
models: [],
|
|
395
|
+
criteria: sample_criteria,
|
|
396
|
+
judge: evals_judge,
|
|
397
|
+
available_tools: fan_out_state(run).fetch("tools") { tool_roster },
|
|
398
|
+
instructions: @evaluation.agent.instructions,
|
|
399
|
+
agent_name: @evaluation.agent.name,
|
|
400
|
+
threshold: PASS_THRESHOLD,
|
|
401
|
+
replay: ->(*) { raise ArgumentError, "a fanned-out result is scored from its stored replay" },
|
|
402
|
+
refine_gate: -> { reserve_refinement!(run) }
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def reserve_refinement!(run)
|
|
407
|
+
reserved = false
|
|
408
|
+
update_fan_out_state!(run) do |state|
|
|
409
|
+
count = state["refinements"].to_i
|
|
410
|
+
next state if count >= Evals::Runner::DEFAULT_JUDGE_LIMIT
|
|
411
|
+
|
|
412
|
+
reserved = true
|
|
413
|
+
state.merge("refinements" => count + 1)
|
|
414
|
+
end
|
|
415
|
+
reserved
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# The run's fan-out bookkeeping, kept in scores["_fan_out"] until the run
|
|
419
|
+
# completes and its scores are written: the tool roster and discovery
|
|
420
|
+
# warning found before the replays, the judge usage spent before them
|
|
421
|
+
# (judge-defined KPIs), and the recommendations reserved so far.
|
|
422
|
+
def fan_out_state(run)
|
|
423
|
+
state = run.scores.is_a?(Hash) ? run.scores["_fan_out"] : nil
|
|
424
|
+
state.is_a?(Hash) ? state : {}
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Changes the bookkeeping under the run's lock. A completed run's scores
|
|
428
|
+
# are left as written: a reservation that lands after the last result
|
|
429
|
+
# has nothing to reserve.
|
|
430
|
+
def update_fan_out_state!(run)
|
|
431
|
+
run.with_lock do
|
|
432
|
+
next unless run.running?
|
|
433
|
+
|
|
434
|
+
scores = run.scores.is_a?(Hash) ? run.scores : {}
|
|
435
|
+
run.update!(scores: scores.merge("_fan_out" => yield(fan_out_state(run))))
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# What completes the run: the attributes the sequential path writes at
|
|
440
|
+
# the end of #call, from the stored results.
|
|
441
|
+
def completion_attributes(run, rows, specs)
|
|
442
|
+
if rows.empty?
|
|
443
|
+
return { status: :failed, error_message: "Every selected scenario was removed during the run", completed_at: Time.current }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
order = specs.keys
|
|
447
|
+
rows = rows.sort_by do |row|
|
|
448
|
+
[ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, order.index([ row.provider.to_s, row.model ]) || order.size ]
|
|
449
|
+
end
|
|
450
|
+
results = rows.map do |row|
|
|
451
|
+
Evals::Result.new(
|
|
452
|
+
scenario: Evals::Scenario.from_hash(row.evaluated_scenario),
|
|
453
|
+
spec: specs.fetch([ row.provider.to_s, row.model ]) { model_spec_from(row.attributes.slice("provider", "model")) },
|
|
454
|
+
replay: replay_from(row),
|
|
455
|
+
scores: row.scores.to_h, score: row.score, status: row.status,
|
|
456
|
+
diagnosis: row.evaluation_diagnosis.presence
|
|
457
|
+
)
|
|
458
|
+
end
|
|
459
|
+
report = Evals::Report.new(results: results, models: specs.values, judge: evals_judge,
|
|
460
|
+
instructions: @evaluation.agent.instructions, threshold: PASS_THRESHOLD)
|
|
461
|
+
state = fan_out_state(run)
|
|
462
|
+
|
|
463
|
+
# The other jobs' judge calls go into this runner's meter before the
|
|
464
|
+
# verdict is asked for, so the run's usage, and anything scores_for
|
|
465
|
+
# reads from the meter, covers every call once.
|
|
466
|
+
@judge_usage = combined_judge_usage([ state["judge_usage"], *rows.map { |row| row.diagnosis.to_h["_judge_usage"] }, judge_usage ])
|
|
467
|
+
|
|
468
|
+
{
|
|
469
|
+
status: :complete,
|
|
470
|
+
scores: scores_for(report, run),
|
|
471
|
+
samples_evaluated: results.size,
|
|
472
|
+
samples_passed: results.count(&:passed?),
|
|
473
|
+
error_message: state.key?("discovery_warning") ? state["discovery_warning"] : discovery_warning,
|
|
474
|
+
completed_at: Time.current
|
|
475
|
+
}
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Sums the judge usage of the run's parts into the one meter
|
|
479
|
+
# scores["_judge_usage"] holds for a sequential run.
|
|
480
|
+
def combined_judge_usage(parts)
|
|
481
|
+
parts = parts.select { |part| part.is_a?(Hash) }
|
|
482
|
+
return nil if parts.empty?
|
|
483
|
+
|
|
484
|
+
costs = parts.filter_map { |part| part["cost"] }
|
|
485
|
+
{
|
|
486
|
+
"calls" => parts.sum { |part| part["calls"].to_i },
|
|
487
|
+
"input_tokens" => parts.sum { |part| part["input_tokens"].to_i },
|
|
488
|
+
"output_tokens" => parts.sum { |part| part["output_tokens"].to_i },
|
|
489
|
+
"cost" => costs.any? ? costs.sum.round(6) : nil,
|
|
490
|
+
"model" => parts.filter_map { |part| part["model"] }.first,
|
|
491
|
+
"by_kind" => tally(parts, "by_kind"),
|
|
492
|
+
"models" => tally(parts, "models")
|
|
493
|
+
}
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def tally(parts, key)
|
|
497
|
+
parts.each_with_object(Hash.new(0)) do |part, counts|
|
|
498
|
+
part[key].to_h.each { |name, count| counts[name] += count.to_i }
|
|
499
|
+
end.to_h
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def error_text(error)
|
|
503
|
+
error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
|
|
504
|
+
end
|
|
505
|
+
|
|
147
506
|
def default_report(tasks, specs, on_result)
|
|
148
507
|
Evals::Runner.new(
|
|
149
508
|
scenarios: tasks,
|
|
@@ -407,16 +766,35 @@ module ActionAgent
|
|
|
407
766
|
scores["_models"] = report.summary_by_model
|
|
408
767
|
scores["_recommendations"] = report.recommendations
|
|
409
768
|
scores["_verdict"] = report.verdict if report.comparing?
|
|
769
|
+
# The framework gained Report#comparison after the oldest activeagent
|
|
770
|
+
# this engine runs against.
|
|
771
|
+
comparison = report.comparison if report.respond_to?(:comparison)
|
|
772
|
+
scores["_comparison"] = comparison if comparison
|
|
410
773
|
scores["_selection"] = run.selection
|
|
411
774
|
scores["_metadata"] = report.metadata
|
|
412
775
|
scores["_judge_label"] = report.judge_label || report.judge&.label
|
|
413
776
|
# The judge's own spend, apart from the replays' (which the results
|
|
414
777
|
# carry and EvaluationRun#usage sums). A host adapter that runs its own
|
|
415
778
|
# judge is out of reach of this meter, so its runs record none.
|
|
416
|
-
|
|
779
|
+
if judge_usage
|
|
780
|
+
scores["_judge_usage"] = judge_usage
|
|
781
|
+
name_judge!(scores, report.judge&.label)
|
|
782
|
+
end
|
|
417
783
|
scores
|
|
418
784
|
end
|
|
419
785
|
|
|
786
|
+
# Records who judged once the calls are in. A judge with no model pinned
|
|
787
|
+
# started out labelled by its provider; it is renamed after the model
|
|
788
|
+
# that answered, here and on the verdict it wrote.
|
|
789
|
+
def name_judge!(scores, provisional)
|
|
790
|
+
scores["_judge"] = judge_descriptor
|
|
791
|
+
label = judge_display_label
|
|
792
|
+
return unless label && provisional && scores["_judge_label"] == provisional
|
|
793
|
+
|
|
794
|
+
scores["_judge_label"] = label
|
|
795
|
+
scores["_verdict"]["judge"] = label if scores["_verdict"].is_a?(Hash) && scores["_verdict"]["judge"] == provisional
|
|
796
|
+
end
|
|
797
|
+
|
|
420
798
|
# --- judge ------------------------------------------------------------
|
|
421
799
|
|
|
422
800
|
def sample_criteria
|
|
@@ -481,7 +859,7 @@ module ActionAgent
|
|
|
481
859
|
def evals_judge
|
|
482
860
|
return nil unless judge_available?
|
|
483
861
|
|
|
484
|
-
@evals_judge ||= Evals::Judge.new(label:
|
|
862
|
+
@evals_judge ||= Evals::Judge.new(label: judge_display_label) do |instructions:, prompt:, kind:|
|
|
485
863
|
judge_generate(kind, message: prompt, instructions: instructions).message&.content
|
|
486
864
|
end
|
|
487
865
|
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module ActionAgent
|
|
6
|
+
# The slack capability: reading channels and posting as the workspace's bot,
|
|
7
|
+
# through the Slack Web API with the bot token the owner connected in
|
|
8
|
+
# Settings -> Integrations (a ProviderKey of provider "slack"). Results and
|
|
9
|
+
# errors are hashes for the model to read; the token never appears in them.
|
|
10
|
+
class SlackTools
|
|
11
|
+
FUNCTIONS = %w[slack_list_channels slack_read_channel slack_post_message].freeze
|
|
12
|
+
API = "https://slack.com/api/"
|
|
13
|
+
TIMEOUT_SECONDS = 10
|
|
14
|
+
MAX_CHANNELS = 200
|
|
15
|
+
MAX_MESSAGES = 50
|
|
16
|
+
|
|
17
|
+
NOT_CONNECTED = "Slack is not connected: add a bot token (xoxb-…) in Settings -> Integrations -> Slack"
|
|
18
|
+
|
|
19
|
+
def self.function?(name)
|
|
20
|
+
FUNCTIONS.include?(name.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The bot token +owner+ connected, or nil.
|
|
24
|
+
def self.token_for(owner)
|
|
25
|
+
ProviderKey.for_owner(owner).find_by(provider: "slack")&.credential.presence
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param token [String, nil]
|
|
29
|
+
def initialize(token)
|
|
30
|
+
@token = token
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Hash]
|
|
34
|
+
def call(name, kwargs)
|
|
35
|
+
return { error: "Unknown tool: #{name}" } unless self.class.function?(name)
|
|
36
|
+
return { error: NOT_CONNECTED } if @token.blank?
|
|
37
|
+
|
|
38
|
+
args = kwargs.to_h.symbolize_keys
|
|
39
|
+
case name.to_s
|
|
40
|
+
when "slack_list_channels" then list_channels
|
|
41
|
+
when "slack_read_channel" then read_channel(**args.slice(:channel, :limit))
|
|
42
|
+
when "slack_post_message" then post_message(**args.slice(:channel, :text, :thread_ts))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def list_channels
|
|
47
|
+
body = request("conversations.list", { types: "public_channel,private_channel", exclude_archived: true, limit: MAX_CHANNELS })
|
|
48
|
+
return body if body[:error]
|
|
49
|
+
|
|
50
|
+
channels = Array(body["channels"]).map do |channel|
|
|
51
|
+
{ id: channel["id"], name: channel["name"], is_member: channel["is_member"], topic: channel.dig("topic", "value").presence }.compact
|
|
52
|
+
end
|
|
53
|
+
{ channels: channels }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_channel(channel:, limit: nil)
|
|
57
|
+
count = (limit || 20).to_i.clamp(1, MAX_MESSAGES)
|
|
58
|
+
body = request("conversations.history", { channel: channel.to_s, limit: count })
|
|
59
|
+
return body if body[:error]
|
|
60
|
+
|
|
61
|
+
messages = Array(body["messages"]).map do |message|
|
|
62
|
+
{ ts: message["ts"], user: message["user"] || message["bot_id"], text: message["text"], thread_ts: message["thread_ts"] }.compact
|
|
63
|
+
end
|
|
64
|
+
{ channel: channel, messages: messages }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def post_message(channel:, text:, thread_ts: nil)
|
|
68
|
+
return { error: "No text to post" } if text.to_s.strip.empty?
|
|
69
|
+
|
|
70
|
+
body = request("chat.postMessage", { channel: channel.to_s, text: text.to_s, thread_ts: thread_ts.presence }.compact, post: true)
|
|
71
|
+
return body if body[:error]
|
|
72
|
+
|
|
73
|
+
{ posted: true, channel: body["channel"], ts: body["ts"] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Slack answers 200 with { ok: false, error: } for most failures.
|
|
79
|
+
def request(method, params, post: false)
|
|
80
|
+
uri = URI.join(API, method)
|
|
81
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS) do |http|
|
|
82
|
+
headers = { "Authorization" => "Bearer #{@token}", "User-Agent" => "ActiveAgents-Toolbox/1.0" }
|
|
83
|
+
if post
|
|
84
|
+
http.post(uri.path, params.to_json, headers.merge("Content-Type" => "application/json; charset=utf-8"))
|
|
85
|
+
else
|
|
86
|
+
uri.query = URI.encode_www_form(params)
|
|
87
|
+
http.get(uri.request_uri, headers)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
body = JSON.parse(response.body.to_s)
|
|
92
|
+
return { error: "Slack refused #{method}: #{body['error'] || "HTTP #{response.code}"}" } unless body["ok"]
|
|
93
|
+
|
|
94
|
+
body
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
{ error: "Slack answered #{method} with something other than JSON (HTTP #{response&.code})" }
|
|
97
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
98
|
+
{ error: "Could not reach Slack: #{e.message}" }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -29,8 +29,13 @@ ActionAgent::Engine.routes.draw do
|
|
|
29
29
|
|
|
30
30
|
# The collector for evaluation reports an application ran itself
|
|
31
31
|
# (ActiveAgent::Evals::Publisher), at <mount>/api/evaluation_reports.
|
|
32
|
-
# Authenticated like trace ingest, with a bearer token.
|
|
32
|
+
# Authenticated like trace ingest, with a bearer token. GET
|
|
33
|
+
# <mount>/api/evaluation_reports/:run_id exports a stored run in the
|
|
34
|
+
# envelope the collector takes. A run_id may hold dots, so the path has
|
|
35
|
+
# no format extension.
|
|
33
36
|
resources :evaluation_reports, only: [ :create ]
|
|
37
|
+
get "evaluation_reports/:run_id", to: "evaluation_reports#show", as: :evaluation_report,
|
|
38
|
+
constraints: { run_id: %r{[^/]+} }, format: false
|
|
34
39
|
|
|
35
40
|
post "feedback", to: "feedback_ingest#create"
|
|
36
41
|
get "feedback", to: "feedbacks#index"
|
|
@@ -59,6 +64,8 @@ ActionAgent::Engine.routes.draw do
|
|
|
59
64
|
end
|
|
60
65
|
collection do
|
|
61
66
|
get :presets
|
|
67
|
+
# The builder's roster for an agent not created yet.
|
|
68
|
+
get :tool_roster, action: :new_tool_roster
|
|
62
69
|
end
|
|
63
70
|
end
|
|
64
71
|
|
|
@@ -101,6 +108,8 @@ ActionAgent::Engine.routes.draw do
|
|
|
101
108
|
resources :code_sessions, only: [ :index, :create, :show ] do
|
|
102
109
|
member do
|
|
103
110
|
post :cancel
|
|
111
|
+
# Another turn of the session's conversation.
|
|
112
|
+
post :messages
|
|
104
113
|
end
|
|
105
114
|
end
|
|
106
115
|
resource :claude_login, only: [ :show, :create, :destroy ], controller: "claude_logins" do
|
data/lib/action_agent/engine.rb
CHANGED
|
@@ -80,6 +80,12 @@ module ActionAgent
|
|
|
80
80
|
app.config.filter_parameters += [ :credential, :api_key, :access_token, :password, :claude_login_code, /\Arecording_events\z/, /(?:\A|\.)(?:answer|value)\z/i ]
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# Evaluation run reports are served as Markdown (`report.md`). Rails
|
|
84
|
+
# registers text/markdown as :md from 7.1; an older host gets it here.
|
|
85
|
+
initializer "action_agent.markdown_mime_type" do
|
|
86
|
+
Mime::Type.register "text/markdown", :md, [], %w[md markdown] unless Mime[:md]
|
|
87
|
+
end
|
|
88
|
+
|
|
83
89
|
# The project setup assistant's request_secret answers go to its
|
|
84
90
|
# handler. Registered on every reload, so the handler is always the
|
|
85
91
|
# current class's.
|
data/lib/action_agent/version.rb
CHANGED