actionagent 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +59 -54
  4. data/app/controllers/action_agent/api/agents_controller.rb +19 -3
  5. data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
  6. data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
  7. data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
  8. data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
  9. data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
  13. data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
  15. data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
  16. data/app/jobs/action_agent/code_session_job.rb +171 -0
  17. data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
  18. data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
  19. data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
  20. data/app/models/action_agent/agent.rb +49 -6
  21. data/app/models/action_agent/agent_run.rb +20 -1
  22. data/app/models/action_agent/code_session.rb +145 -0
  23. data/app/models/action_agent/evaluation.rb +64 -0
  24. data/app/models/action_agent/evaluation_run.rb +166 -54
  25. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  26. data/app/models/action_agent/github_connection.rb +75 -0
  27. data/app/models/action_agent/model_pricing.rb +214 -34
  28. data/app/models/action_agent/provider_key.rb +148 -10
  29. data/app/models/action_agent/sandbox_session.rb +194 -17
  30. data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
  31. data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
  32. data/app/services/action_agent/agent_execution_service.rb +4 -2
  33. data/app/services/action_agent/agent_scorecard.rb +50 -21
  34. data/app/services/action_agent/agent_tool_roster.rb +20 -9
  35. data/app/services/action_agent/claude_code_auth.rb +86 -0
  36. data/app/services/action_agent/codex_session_events.rb +37 -0
  37. data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
  38. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  39. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  40. data/app/services/action_agent/evaluation_runner_service.rb +25 -5
  41. data/app/services/action_agent/evaluation_standing.rb +145 -0
  42. data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
  43. data/app/services/action_agent/github_client.rb +111 -0
  44. data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
  45. data/app/services/action_agent/local_sandbox_databases.rb +257 -0
  46. data/app/services/action_agent/mcp_client.rb +5 -1
  47. data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
  48. data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
  49. data/app/services/action_agent/ollama_host_probe.rb +75 -0
  50. data/app/services/action_agent/payload_bounds.rb +36 -0
  51. data/app/services/action_agent/sandbox_manifest.rb +67 -0
  52. data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
  53. data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
  54. data/app/services/action_agent/secret_scrubber.rb +37 -0
  55. data/app/services/action_agent/tool_discovery.rb +19 -5
  56. data/config/routes.rb +23 -4
  57. data/lib/action_agent/engine.rb +1 -0
  58. data/lib/action_agent/version.rb +1 -1
  59. data/lib/action_agent.rb +153 -3
  60. data/lib/generators/action_agent/install_generator.rb +35 -5
  61. data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
  62. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  63. data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
  64. data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
  66. data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
  67. data/lib/tasks/claude_code.rake +16 -0
  68. data/lib/tasks/sandbox.rake +26 -0
  69. metadata +28 -2
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # How Claude Code sessions authenticate (ActionAgent.claude_code_auth), and
5
+ # the one rule for whether an owner's Claude Code is "connected", shared by
6
+ # the sandbox listing, the assistant's configuration and the session API so
7
+ # they never disagree.
8
+ #
9
+ # :api_key the owner's Anthropic API key, stored as a ProviderKey. A
10
+ # Claude subscription token stored by an earlier version does
11
+ # not count (ProviderKey#needs_replacing?).
12
+ # :local_login the login of the machine the dashboard runs on, as
13
+ # `claude auth status` reports it (see
14
+ # LocalSandboxBackend.claude_login_status). The dashboard never
15
+ # sees that credential.
16
+ #
17
+ # Anthropic does not let third-party products store or route requests
18
+ # through Claude.ai subscription credentials, so there is no third mode:
19
+ # https://code.claude.com/docs/en/legal-and-compliance.md
20
+ module ClaudeCodeAuth
21
+ module_function
22
+
23
+ # @return [String] "api_key" or "local_login"
24
+ def mode
25
+ ActionAgent.claude_code_auth.to_s
26
+ end
27
+
28
+ def local_login?
29
+ mode == "local_login"
30
+ end
31
+
32
+ # Why +orchestrator+'s backend cannot run Claude Code sessions with the
33
+ # configured authentication, or nil. A machine's own login is the
34
+ # dashboard user's, so only a backend running sessions as that user, on
35
+ # that machine, may use it: a container or a remote host would either
36
+ # find no login or need it copied there, which is what this mode exists
37
+ # to never do.
38
+ def backend_refusal(orchestrator)
39
+ return unless local_login?
40
+ return if orchestrator.local?
41
+
42
+ "ActionAgent.claude_code_auth = :local_login uses this machine's own Claude Code login, so it works only " \
43
+ "with the :local sandbox backend, not #{orchestrator.backend_name}"
44
+ end
45
+
46
+ # Whether the owner of +provider_keys+ (a ProviderKey scope already
47
+ # narrowed to them) has an API key Claude Code can run on.
48
+ def api_key_connected?(provider_keys)
49
+ provider_keys.where(provider: "claude_code").any? { |key| key.runtime_environment.present? }
50
+ end
51
+
52
+ # What the dashboard reports about Claude Code for the owner of
53
+ # +provider_keys+: booleans and the login's method, never a credential.
54
+ #
55
+ # @return [Hash] { mode:, connected:, login: } (login for :local_login only)
56
+ def status(provider_keys)
57
+ if local_login?
58
+ # Only the :local backend uses the login, and no other one needs
59
+ # this machine's CLI asked about it.
60
+ login = local_backend? ? LocalSandboxBackend.claude_login_status : LocalSandboxBackend::LOGGED_OUT
61
+ { mode: mode, connected: login[:logged_in], login: login.slice(:logged_in, :auth_method) }
62
+ else
63
+ { mode: mode, connected: api_key_connected?(provider_keys) }
64
+ end
65
+ end
66
+
67
+ # A backend that cannot even be loaded is not the :local one.
68
+ def local_backend?
69
+ SandboxOrchestrator.new.local?
70
+ rescue StandardError, LoadError
71
+ false
72
+ end
73
+
74
+ # Why a Claude Code session cannot start in +sandbox+ for want of
75
+ # credentials, or nil.
76
+ def credential_refusal(sandbox)
77
+ if local_login?
78
+ return if LocalSandboxBackend.claude_login_status[:logged_in]
79
+
80
+ "Claude Code is not logged in on this machine: run `claude /login` as the user the dashboard runs as"
81
+ elsif sandbox.runtime_environment.blank?
82
+ "Claude Code is not connected: connect an Anthropic API key in Settings -> Integrations first"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Keep Codex's native JSONL transcript while adapting its terminal event
5
+ # to CodeSession's shared result contract. A process exit alone is never
6
+ # enough to mark a coding session successful.
7
+ class CodexSessionEvents
8
+ def consume(event)
9
+ return unless event.is_a?(Hash)
10
+
11
+ case event["type"]
12
+ when "thread.started"
13
+ @thread_id = event["thread_id"]
14
+ when "item.completed"
15
+ item = event["item"]
16
+ @answer = item["text"] if item.is_a?(Hash) && item["type"] == "agent_message"
17
+ when "turn.completed"
18
+ return result(false, @answer, event["usage"])
19
+ when "turn.failed", "error"
20
+ error = event["error"]
21
+ message = error.is_a?(Hash) ? error["message"] : event["message"]
22
+ return result(true, message.presence || "Codex reported a failed turn", {})
23
+ end
24
+ nil
25
+ end
26
+
27
+ private
28
+
29
+ def result(failed, answer, usage)
30
+ {
31
+ "type" => "result", "subtype" => failed ? "error_during_execution" : "success",
32
+ "is_error" => failed, "result" => answer, "session_id" => @thread_id,
33
+ "usage" => usage.is_a?(Hash) ? usage.slice("input_tokens", "output_tokens") : {}
34
+ }
35
+ end
36
+ end
37
+ end
@@ -31,7 +31,7 @@ module ActionAgent
31
31
  PROCESSING_DISCLOSURE = "The selected provider receives your current message, bounded conversation history, and authorized report excerpts requested through assistant tools."
32
32
  LIMITATIONS = [
33
33
  "Reports describe recorded behavior; no repository branch or current main has been verified.",
34
- "GitHub connections, COI execution, and native Claude Code session connections are not implemented here.",
34
+ "This assistant cannot connect GitHub, start checkout sandboxes, or run Claude Code sessions: do that in Settings → Integrations. COI execution is not available.",
35
35
  "Agent proposals are drafts only. Review them in the builder before saving or running."
36
36
  ].freeze
37
37
  INSTRUCTIONS = <<~TEXT.freeze
@@ -55,9 +55,12 @@ module ActionAgent
55
55
  agents. These groups do not provide repository editing, arbitrary browser
56
56
  automation, or private database access. Instructions alone do not add tools
57
57
  or data access. Explain missing capabilities when proposing an agent.
58
- GitHub auth, repo checkout, COI, native Claude Code sessions, running
59
- evaluations, and publishing PRs are not available in this version. Explain
60
- those limitations without suggesting fake authentication links.
58
+ You cannot connect GitHub, start checkout sandboxes, run Claude Code
59
+ sessions, run evaluations, or publish PRs. The developer connects GitHub
60
+ and Claude Code, starts checkout sandboxes, and runs Claude Code sessions
61
+ in Settings → Integrations; point them there instead of claiming a sandbox
62
+ or session started. COI execution is not available in this version.
63
+ Explain those limitations without suggesting fake authentication links.
61
64
  TEXT
62
65
  TOOL_DEFINITIONS = [
63
66
  {
@@ -107,7 +110,7 @@ module ActionAgent
107
110
  providers: DEFAULT_MODELS.map { |id, model| { id: id, configured: provider_configured?(id), default_model: model } },
108
111
  defaults: { provider: nil, model: nil },
109
112
  processing: { consent_required: true, disclosure: PROCESSING_DISCLOSURE },
110
- connections: %i[github coi claude_code].index_with { { supported: false } },
113
+ connections: connections,
111
114
  limits: { message_characters: MAX_MESSAGE_CHARACTERS, history_messages: MAX_HISTORY_MESSAGES, history_characters: MAX_HISTORY_CHARACTERS },
112
115
  limitations: LIMITATIONS
113
116
  }
@@ -191,6 +194,45 @@ module ActionAgent
191
194
 
192
195
  private
193
196
 
197
+ # What the owner has set up in Settings → Integrations, reported by the
198
+ # assistant's configuration endpoint (booleans only, never a token). The
199
+ # model is not told: it starts nothing, and sandboxes and Claude Code
200
+ # sessions are started from that view (see LIMITATIONS).
201
+ #
202
+ # Claude Code is connected by ClaudeCodeAuth's rule, as the sandbox
203
+ # listing reports it: an API key the owner connected (auth "api_key"), or
204
+ # this machine's own login (auth "local_login", with its status).
205
+ def connections
206
+ claude_code = ClaudeCodeAuth.status(ProviderKey.for_owner(@owner))
207
+ {
208
+ github: { supported: true, connected: GithubConnection.for_owner(@owner).exists? },
209
+ claude_code: {
210
+ supported: code_sessions_supported?,
211
+ connected: claude_code[:connected],
212
+ auth: claude_code[:mode],
213
+ login: claude_code[:login]
214
+ }.compact,
215
+ coi: { supported: false }
216
+ }
217
+ end
218
+
219
+ # Claude Code needs a sandbox backend that runs sessions, which :mock and
220
+ # :local do and a host's may not. A backend that cannot even be loaded (a
221
+ # misspelled class in ActionAgent.sandbox_backends, or a class file that
222
+ # requires an SDK the host doesn't bundle) runs none, and must not take
223
+ # the assistant's configuration down with it. The SDK case raises
224
+ # LoadError, a ScriptError rather than a StandardError.
225
+ #
226
+ # Nor can one whose Claude Code authentication does not work there
227
+ # (ActionAgent.claude_code_auth = :local_login needs :local).
228
+ def code_sessions_supported?
229
+ orchestrator = SandboxOrchestrator.new
230
+ orchestrator.supports?(:code_session) && ClaudeCodeAuth.backend_refusal(orchestrator).nil?
231
+ rescue StandardError, LoadError => e
232
+ Rails.logger.warn("[ActionAgent] sandbox backend unavailable: #{e.message}")
233
+ false
234
+ end
235
+
194
236
  def require_processing_consent!
195
237
  return if @allow_provider_processing == true
196
238
 
@@ -22,7 +22,12 @@ module ActionAgent
22
22
  #
23
23
  # The run's per-model summary, criterion scores and recommendations are
24
24
  # computed from the stored results, not taken from the report. The judge's
25
- # verdict and label are taken from it.
25
+ # verdict and label are taken from it, and so is what the judge spent —
26
+ # per result (`result.judge_usage`, kept beside the result's diagnosis) and
27
+ # for the run (`report.judge_usage.run`) — and the release the report says
28
+ # it evaluated (`report.release`), which pins the run to that version of
29
+ # the agent (Agent#find_or_record_release!). A report naming no release
30
+ # leaves the run unrecorded rather than claiming the dashboard's latest.
26
31
  #
27
32
  # An identical retry returns the stored run, before anything is asked of the
28
33
  # `admit` callable. Different content under a run_id already stored raises
@@ -70,6 +75,11 @@ module ActionAgent
70
75
  IDENTIFIER_PATTERN = /\A[^[:cntrl:]]{1,200}\z/
71
76
  AGENT_NAME_PATTERN = /\A[^[:cntrl:]]{2,100}\z/
72
77
  TRACE_PATTERN = /\A[a-zA-Z0-9_-]{1,128}\z/
78
+ DIGEST_PATTERN = /\A[a-zA-Z0-9._-]{1,64}\z/
79
+ # The judge usage keys a report may send, per result and for the run.
80
+ JUDGE_USAGE_KEYS = %w[calls input_tokens output_tokens cost model by_kind source].freeze
81
+ JUDGE_USAGE_SOURCES = %w[reported traces meter].freeze
82
+ MAX_JUDGE_KINDS = 10
73
83
  SCOPE_PATTERN = %r{\A[\w .:/@-]{1,100}\z}
74
84
  STATUSES = %w[passed failed errored].freeze
75
85
  # Report metadata that tells one evaluation of a suite from another, in the
@@ -253,6 +263,7 @@ module ActionAgent
253
263
  external_tenant: tenant_key,
254
264
  external_run_id: @payload["run_id"],
255
265
  external_report_digest: digest,
266
+ agent_version: reported_release_version(agent),
256
267
  status: :complete,
257
268
  selection: selection,
258
269
  scores: recorded_scores,
@@ -267,6 +278,15 @@ module ActionAgent
267
278
  run
268
279
  end
269
280
 
281
+ # The agent version for the release the report names, or nil for a
282
+ # report that names none.
283
+ def reported_release_version(agent)
284
+ release = report["release"]
285
+ return nil unless release.is_a?(Hash) && release["digest"].present?
286
+
287
+ agent.find_or_record_release!(digest: release["digest"], revision: release["revision"].presence, label: release["label"].presence)
288
+ end
289
+
270
290
  # --- ownership -------------------------------------------------------------
271
291
 
272
292
  # Who a newly observed agent belongs to. Whatever the host's
@@ -492,11 +512,28 @@ module ActionAgent
492
512
  "key" => result["scenario_key"], "group" => result["group"], "prompt" => result["prompt"],
493
513
  "position" => scenario.position, "expectations" => {}
494
514
  }
495
- ),
515
+ ).merge(bounded_judge_usage(result["judge_usage"]).then { |usage| usage ? { "_judge_usage" => usage } : {} }),
496
516
  error_message: truncated(result["error"], TEXT_BYTES)
497
517
  )
498
518
  end
499
519
 
520
+ # A judge usage as the engine stores it: the keys the dashboard reads,
521
+ # each cut to its type, or nil for none.
522
+ def bounded_judge_usage(usage)
523
+ return nil unless usage.is_a?(Hash)
524
+
525
+ by_kind = usage["by_kind"].is_a?(Hash) ? usage["by_kind"].first(MAX_JUDGE_KINDS).to_h { |kind, count| [ kind.to_s.first(40), count.to_i ] } : {}
526
+ {
527
+ "calls" => usage["calls"].to_i,
528
+ "input_tokens" => usage["input_tokens"].to_i,
529
+ "output_tokens" => usage["output_tokens"].to_i,
530
+ "cost" => usage["cost"].is_a?(Numeric) ? usage["cost"].to_f : nil,
531
+ "model" => usage["model"].is_a?(String) ? usage["model"].first(200).presence : nil,
532
+ "by_kind" => by_kind,
533
+ "source" => JUDGE_USAGE_SOURCES.include?(usage["source"]) ? usage["source"] : "reported"
534
+ }
535
+ end
536
+
500
537
  # The first +bytes+ bytes of +text+, dropping a character the cut splits,
501
538
  # or nil for blank text.
502
539
  def truncated(text, bytes)
@@ -521,14 +558,18 @@ module ActionAgent
521
558
  {
522
559
  "_verdict" => report["verdict"],
523
560
  "_selection" => selection,
524
- "_metadata" => metadata
561
+ "_metadata" => metadata,
562
+ "_judge_usage_run" => bounded_judge_usage(report.dig("judge_usage", "run"))
525
563
  }.compact.merge("_judge_label" => judge_label)
526
564
  end
527
565
 
528
566
  # The run's scores in the shape the Evaluations view renders
529
- # (ScenarioEvaluationRunner#scores_for), summarized from the stored results.
567
+ # (ScenarioEvaluationRunner#scores_for), summarized from the stored
568
+ # results as the application reported them — no cost is estimated here,
569
+ # so the summaries stored stay the application's own figures; the API
570
+ # adds the estimates when it serves the run (EvaluationRun#model_summaries).
530
571
  def summarized_scores(run)
531
- rebuilt = run.to_report
572
+ rebuilt = run.to_report(estimate: false)
532
573
  rebuilt.criterion_scores.merge(
533
574
  "_models" => rebuilt.summary_by_model,
534
575
  "_recommendations" => rebuilt.recommendations
@@ -559,6 +600,9 @@ module ActionAgent
559
600
 
560
601
  judge_trace_ids!(metadata["judge_trace_ids"])
561
602
  string!(report["judge"], "report.judge", 200)
603
+ validate_release!
604
+ validate_judge_usage!(report.dig("judge_usage", "run"), "report.judge_usage.run") if report["judge_usage"].is_a?(Hash)
605
+ optional_object!(report["judge_usage"], "report.judge_usage")
562
606
  object!(report["models"], "report.models")
563
607
  raise Invalid, "report.models must contain 1-#{MAX_MODELS} models" unless report["models"].size.between?(1, MAX_MODELS)
564
608
  unless results.is_a?(Array) && results.size.between?(1, MAX_RESULTS)
@@ -605,6 +649,7 @@ module ActionAgent
605
649
  end
606
650
  trace_id!(result_metadata["trace_id"]) if result_metadata["trace_id"]
607
651
  judge_trace_ids!(result_metadata["judge_trace_ids"])
652
+ validate_judge_usage!(result["judge_usage"], "result.judge_usage")
608
653
  end
609
654
  distinct_specs = label_specs.values.uniq
610
655
  raise Invalid, "two model labels name the same provider/model" if distinct_specs.size < label_specs.size
@@ -655,6 +700,40 @@ module ActionAgent
655
700
  string!(judge.dig("suggested_tool", "name"), "result.diagnosis.judge.suggested_tool.name", 200)
656
701
  end
657
702
 
703
+ # The release the report says it evaluated: a digest the dashboard can
704
+ # match to a version cut on deploy, and the deploy and label as text.
705
+ def validate_release!
706
+ release = report["release"]
707
+ return if release.nil?
708
+
709
+ object!(release, "report.release")
710
+ unless release["digest"].nil? || (release["digest"].is_a?(String) && DIGEST_PATTERN.match?(release["digest"]))
711
+ raise Invalid, "report.release.digest must be 1-64 letters, digits or . _ -"
712
+ end
713
+
714
+ string!(release["revision"], "report.release.revision", 200)
715
+ string!(release["label"], "report.release.label", 200)
716
+ end
717
+
718
+ # What the judge spent, as ActiveAgent::Evals::Report writes it per
719
+ # result and for the run.
720
+ def validate_judge_usage!(usage, name)
721
+ return if usage.nil?
722
+
723
+ object!(usage, name)
724
+ %w[calls input_tokens output_tokens].each do |key|
725
+ next if usage[key].nil?
726
+ raise Invalid, "#{name}.#{key} must be a non-negative integer" unless usage[key].is_a?(Integer) && usage[key] >= 0 && usage[key] <= NUMERIC_LIMITS["input_tokens"]
727
+ end
728
+ numeric!(usage["cost"], "#{name}.cost", max: NUMERIC_LIMITS["cost"])
729
+ string!(usage["model"], "#{name}.model", 200)
730
+ string!(usage["source"], "#{name}.source", 20)
731
+ optional_object!(usage["by_kind"], "#{name}.by_kind")
732
+ (usage["by_kind"] || {}).each_value do |count|
733
+ raise Invalid, "#{name}.by_kind counts must be non-negative integers" unless count.is_a?(Integer) && count >= 0
734
+ end
735
+ end
736
+
658
737
  def validate_verdict!
659
738
  verdict = report["verdict"]
660
739
  return if verdict.nil?