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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -0
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +61 -61
  5. data/app/controllers/action_agent/api/agents_controller.rb +23 -0
  6. data/app/controllers/action_agent/api/code_sessions_controller.rb +49 -10
  7. data/app/controllers/action_agent/api/evaluation_reports_controller.rb +66 -2
  8. data/app/controllers/action_agent/api/evaluations_controller.rb +56 -6
  9. data/app/controllers/action_agent/api/interactions_controller.rb +55 -1
  10. data/app/controllers/action_agent/api/sandboxes_controller.rb +13 -2
  11. data/app/controllers/action_agent/api/trace_reports_controller.rb +11 -6
  12. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +2 -2
  13. data/app/jobs/action_agent/code_session_job.rb +19 -2
  14. data/app/jobs/action_agent/evaluation_judge_job.rb +39 -0
  15. data/app/jobs/action_agent/evaluation_replay_job.rb +28 -0
  16. data/app/models/action_agent/agent.rb +54 -1
  17. data/app/models/action_agent/code_session.rb +80 -0
  18. data/app/models/action_agent/evaluation_run.rb +37 -5
  19. data/app/models/action_agent/provider_key.rb +5 -1
  20. data/app/queries/action_agent/trace_window_report.rb +60 -0
  21. data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
  22. data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
  23. data/app/services/action_agent/agent_execution_service.rb +37 -0
  24. data/app/services/action_agent/agent_tool_roster.rb +53 -18
  25. data/app/services/action_agent/agent_toolbox.rb +156 -3
  26. data/app/services/action_agent/browser_client_token.rb +86 -0
  27. data/app/services/action_agent/checkout_tools.rb +154 -0
  28. data/app/services/action_agent/code_conversation.rb +133 -0
  29. data/app/services/action_agent/codex_auth.rb +81 -0
  30. data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
  31. data/app/services/action_agent/diff_stat.rb +27 -0
  32. data/app/services/action_agent/evaluation_fix_comparison.rb +4 -1
  33. data/app/services/action_agent/evaluation_report_export.rb +175 -0
  34. data/app/services/action_agent/evaluation_report_import.rb +47 -19
  35. data/app/services/action_agent/evaluation_runner_service.rb +106 -14
  36. data/app/services/action_agent/local_sandbox_backend.rb +295 -30
  37. data/app/services/action_agent/mcp_catalog.rb +25 -0
  38. data/app/services/action_agent/mcp_client.rb +1 -0
  39. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
  40. data/app/services/action_agent/project_boot_fix.rb +10 -3
  41. data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
  42. data/app/services/action_agent/scenario_evaluation_runner.rb +381 -3
  43. data/app/services/action_agent/slack_tools.rb +101 -0
  44. data/config/routes.rb +10 -1
  45. data/lib/action_agent/engine.rb +6 -0
  46. data/lib/action_agent/version.rb +1 -1
  47. data/lib/action_agent.rb +50 -2
  48. data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
  49. data/lib/tasks/evaluations.rake +78 -0
  50. 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
- scores["_judge_usage"] = judge_usage if judge_usage
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: @evaluation.judge_model.presence || judge_provider.to_s) do |instructions:, prompt:, kind:|
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
@@ -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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.9.1"
4
+ VERSION = "1.9.3"
5
5
  end