actionagent 1.9.2 → 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 (46) 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 +41 -3
  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/jobs/action_agent/code_session_job.rb +19 -2
  13. data/app/models/action_agent/agent.rb +54 -1
  14. data/app/models/action_agent/code_session.rb +80 -0
  15. data/app/models/action_agent/evaluation_run.rb +17 -2
  16. data/app/models/action_agent/provider_key.rb +5 -1
  17. data/app/queries/action_agent/trace_window_report.rb +60 -0
  18. data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
  19. data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
  20. data/app/services/action_agent/agent_execution_service.rb +37 -0
  21. data/app/services/action_agent/agent_tool_roster.rb +53 -18
  22. data/app/services/action_agent/agent_toolbox.rb +156 -3
  23. data/app/services/action_agent/browser_client_token.rb +86 -0
  24. data/app/services/action_agent/checkout_tools.rb +154 -0
  25. data/app/services/action_agent/code_conversation.rb +133 -0
  26. data/app/services/action_agent/codex_auth.rb +81 -0
  27. data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
  28. data/app/services/action_agent/diff_stat.rb +27 -0
  29. data/app/services/action_agent/evaluation_report_export.rb +175 -0
  30. data/app/services/action_agent/evaluation_report_import.rb +40 -17
  31. data/app/services/action_agent/evaluation_runner_service.rb +48 -12
  32. data/app/services/action_agent/local_sandbox_backend.rb +295 -30
  33. data/app/services/action_agent/mcp_catalog.rb +25 -0
  34. data/app/services/action_agent/mcp_client.rb +1 -0
  35. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
  36. data/app/services/action_agent/project_boot_fix.rb +10 -3
  37. data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
  38. data/app/services/action_agent/scenario_evaluation_runner.rb +1 -0
  39. data/app/services/action_agent/slack_tools.rb +101 -0
  40. data/config/routes.rb +10 -1
  41. data/lib/action_agent/engine.rb +6 -0
  42. data/lib/action_agent/version.rb +1 -1
  43. data/lib/action_agent.rb +35 -2
  44. data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
  45. data/lib/tasks/evaluations.rake +78 -0
  46. metadata +13 -2
@@ -206,11 +206,13 @@ module ActionAgent
206
206
  # listing reports it: an API key the owner connected (auth "api_key"),
207
207
  # this machine's own login (auth "local_login", with its status), or a
208
208
  # login inside each checkout sandbox (auth "sandbox_login", with the
209
- # actor's per-sandbox state under sandboxes).
209
+ # actor's per-sandbox state under sandboxes). Codex by CodexAuth's: an
210
+ # API key, or this machine's own login.
210
211
  def connections
211
212
  claude_code = ClaudeCodeAuth.status(ProviderKey.for_owner(@owner),
212
213
  sandboxes: SandboxSession.for_owner(@owner).where(sandbox_type: "app_runtime").recent.limit(20),
213
214
  user_id: @actor.respond_to?(:id) ? @actor.id : nil)
215
+ codex = CodexAuth.status(ProviderKey.for_owner(@owner))
214
216
  {
215
217
  github: { supported: true, connected: GithubConnection.for_owner(@owner).exists? },
216
218
  claude_code: {
@@ -220,6 +222,12 @@ module ActionAgent
220
222
  login: claude_code[:login],
221
223
  sandboxes: claude_code[:sandboxes]
222
224
  }.compact,
225
+ codex: {
226
+ supported: codex_sessions_supported?,
227
+ connected: codex[:connected],
228
+ auth: codex[:mode],
229
+ login: codex[:login]
230
+ }.compact,
223
231
  coi: { supported: false }
224
232
  }
225
233
  end
@@ -241,6 +249,16 @@ module ActionAgent
241
249
  false
242
250
  end
243
251
 
252
+ # Codex needs a backend that advertises the runner (code_runners), and
253
+ # one its authentication works on (CodexAuth.backend_refusal).
254
+ def codex_sessions_supported?
255
+ orchestrator = SandboxOrchestrator.new
256
+ orchestrator.supports_code_runner?("codex") && CodexAuth.backend_refusal(orchestrator).nil?
257
+ rescue StandardError, LoadError => e
258
+ Rails.logger.warn("[ActionAgent] sandbox backend unavailable: #{e.message}")
259
+ false
260
+ end
261
+
244
262
  def require_processing_consent!
245
263
  return if @allow_provider_processing == true
246
264
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Counts what a unified diff changes: { files:, additions:, deletions: }.
5
+ # A code session's diff runs from the checkout's base commit, so each turn
6
+ # of a conversation reports everything changed since the checkout, up to
7
+ # and including that turn.
8
+ module DiffStat
9
+ module_function
10
+
11
+ # @param diff [String, nil]
12
+ # @return [Hash, nil] nil when there is no diff
13
+ def call(diff)
14
+ return nil if diff.blank?
15
+
16
+ files = additions = deletions = 0
17
+ diff.each_line do |line|
18
+ if line.start_with?("diff --git ") then files += 1
19
+ elsif line.start_with?("+++ ", "--- ") then next
20
+ elsif line.start_with?("+") then additions += 1
21
+ elsif line.start_with?("-") then deletions += 1
22
+ end
23
+ end
24
+ { files: files, additions: additions, deletions: deletions }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module ActionAgent
7
+ # A complete scenario run as the version-1 envelope
8
+ # ActiveAgent::Evals::Publisher sends, built with Publisher.envelope so the
9
+ # two share one shape. What the export endpoints and
10
+ # `action_agent:evaluations:export` serve: POSTed to another install's
11
+ # <mount>/api/evaluation_reports it is stored there, and POSTed back to this
12
+ # one it is an identical retry (EvaluationReportImport accepts a run's own
13
+ # export as identical to the run).
14
+ #
15
+ # The identities come from the run:
16
+ # run_id — an imported run's own run_id, exactly as published. A run
17
+ # the dashboard executed has none, so it is given
18
+ # "dashboard-run-<id>-<created_at as YYYYMMDDhhmmss UTC>":
19
+ # stable for the run, and unlikely to meet another
20
+ # install's run of the same id where both are imported.
21
+ # source — an imported run's source; for a dashboard run the agent's
22
+ # service, else the host application's name ("support-hub").
23
+ # agent_name — the agent's class name, else its name.
24
+ # suite — an imported run's suite; for a dashboard run the
25
+ # evaluation's name.
26
+ #
27
+ # The report is EvaluationRun#to_report(estimate: false): what the run
28
+ # recorded, with no cost estimated, so the same run always exports the
29
+ # same envelope. Its metadata leaves out the run's database id.
30
+ class EvaluationReportExport
31
+ # The run cannot be exported: it is not complete, or its evaluation is
32
+ # not a scenario suite.
33
+ class NotExportable < StandardError; end
34
+ # #markdown was asked for a part the report does not have.
35
+ class UnknownPart < ArgumentError; end
36
+
37
+ DASHBOARD_RUN_ID = /\Adashboard-run-(\d+)-(\d{14})\z/
38
+ STAMP_FORMAT = "%Y%m%d%H%M%S"
39
+
40
+ # The run_id a run exports under.
41
+ def self.run_id_for(run)
42
+ run.external_run_id.presence || "dashboard-run-#{run.id}-#{run.created_at.utc.strftime(STAMP_FORMAT)}"
43
+ end
44
+
45
+ # The run +run_id+ names for the tenant +account+ (nil on a single-tenant
46
+ # install), or nil. An imported run is found by the run_id it was
47
+ # published under, within the tenant that published it, as
48
+ # EvaluationReportImport finds it. A dashboard run is found by its
49
+ # exported run_id, when its agent is one of the tenant's
50
+ # (ActionAgent.agents_for); a single-tenant key reaches every agent, as
51
+ # the import's does.
52
+ def self.find(run_id, account: nil)
53
+ return nil unless run_id.is_a?(String) && run_id.present?
54
+
55
+ imported = EvaluationRun.find_by(external_tenant: account ? account.id.to_s : "", external_run_id: run_id)
56
+ return imported if imported
57
+
58
+ match = DASHBOARD_RUN_ID.match(run_id)
59
+ return nil unless match
60
+
61
+ run = EvaluationRun.where(external_run_id: nil).find_by(id: match[1].to_i)
62
+ return nil unless run && run_id_for(run) == run_id
63
+ return run if account.nil? && !ActionAgent.multi_tenant?
64
+
65
+ run if account && ActionAgent.agents_for(account).exists?(id: run.evaluation.agent_id)
66
+ end
67
+
68
+ attr_reader :run
69
+
70
+ def initialize(run)
71
+ @run = run
72
+ end
73
+
74
+ def run_id
75
+ self.class.run_id_for(run)
76
+ end
77
+
78
+ # Raises NotExportable for a run that is not a complete scenario run:
79
+ # what every format of the export, not only the envelope, needs.
80
+ def exportable!
81
+ raise NotExportable, "Only a complete run can be exported" unless run.complete?
82
+ raise NotExportable, "Only a scenario suite's run can be exported" unless evaluation.scenario_suite?
83
+ end
84
+
85
+ # Raises NotExportable for a run that is not a complete scenario run.
86
+ def envelope
87
+ @envelope ||= begin
88
+ exportable!
89
+
90
+ built = ActiveAgent::Evals::Publisher.envelope(
91
+ report: run.to_report(links: {}, estimate: false),
92
+ run_id: run_id, source: source, agent_name: agent_name, suite: suite
93
+ )
94
+ # As JSON reads it back, so the digest is the one a POST of it gets.
95
+ JSON.parse(JSON.generate(built)).tap do |parsed|
96
+ # The run's database id means nothing on another install; run_id
97
+ # is the run's identity there.
98
+ metadata = parsed["report"]["metadata"]
99
+ metadata.delete("run") if metadata.is_a?(Hash) && metadata["run"] == run.id
100
+ end
101
+ end
102
+ end
103
+
104
+ def to_json(*)
105
+ JSON.pretty_generate(envelope)
106
+ end
107
+
108
+ # Parts of the report #markdown serves on its own.
109
+ MARKDOWN_PARTS = %w[report verdict fix_items scenario].freeze
110
+
111
+ # The run's report as GitHub-flavored Markdown
112
+ # (ActiveAgent::Evals::ReportMarkdown), with the costs the dashboard shows:
113
+ # the whole report, or one +part+ — "verdict" (with the model
114
+ # comparison), "fix_items" (for +model+, a model label, when given) or
115
+ # "scenario" (the one +scenario+ names). Raises UnknownPart for an
116
+ # unknown part and ActiveRecord::RecordNotFound for a scenario the run
117
+ # has no result for.
118
+ def markdown(part: nil, model: nil, scenario: nil)
119
+ report = run.to_report(links: {})
120
+ case part.presence || "report"
121
+ when "report" then report.to_markdown
122
+ when "verdict" then report.verdict_markdown
123
+ when "fix_items" then report.fix_items_markdown(model: model.presence)
124
+ when "scenario" then scenario_markdown(report, scenario.to_s)
125
+ else raise UnknownPart, "part is one of #{MARKDOWN_PARTS.join(', ')}"
126
+ end
127
+ end
128
+
129
+ # The digest EvaluationReportImport records for this envelope.
130
+ def digest
131
+ EvaluationReportImport.digest(envelope)
132
+ end
133
+
134
+ # "orders-eu-run-3.json" for the third run of "orders (eu)"; a part of
135
+ # the Markdown report adds its name: "orders-eu-run-3-fix-items.md".
136
+ def filename(extension, number: EvaluationSerializer.run_number(evaluation, run), part: nil)
137
+ base = "#{evaluation.name.to_s.parameterize.presence || "evaluation-#{evaluation.id}"}-run-#{number}"
138
+ base += "-#{part.to_s.dasherize.parameterize}" if part.present? && part.to_s != "report"
139
+ "#{base}.#{extension}"
140
+ end
141
+
142
+ private
143
+
144
+ def scenario_markdown(report, key)
145
+ raise ActiveRecord::RecordNotFound, "The run has no result for scenario #{key}" unless report.results.any? { |result| result.scenario.key == key }
146
+
147
+ report.scenario_markdown(key)
148
+ end
149
+
150
+ def evaluation
151
+ run.evaluation
152
+ end
153
+
154
+ def agent
155
+ evaluation.agent
156
+ end
157
+
158
+ def external
159
+ value = evaluation.config.to_h["external"]
160
+ run.imported? && value.is_a?(Hash) ? value : {}
161
+ end
162
+
163
+ def source
164
+ (external["source"].presence || agent.service_name.presence || Rails.application.class.module_parent_name.underscore.dasherize).first(200)
165
+ end
166
+
167
+ def agent_name
168
+ (agent.agent_class_name.presence || agent.name).to_s.first(100)
169
+ end
170
+
171
+ def suite
172
+ (external["suite"].presence || evaluation.name).to_s.first(200)
173
+ end
174
+ end
175
+ end
@@ -30,11 +30,14 @@ module ActionAgent
30
30
  # leaves the run unrecorded rather than claiming the dashboard's latest.
31
31
  #
32
32
  # An identical retry returns the stored run, before anything is asked of the
33
- # `admit` callable. Different content under a run_id already stored raises
34
- # Conflict. Imports for one owner run one at a time under an advisory lock
35
- # (OwnerLock), so concurrent first deliveries find or create one agent,
36
- # evaluation and scenario set, and the unique index on the run's tenant and
37
- # run_id settles any delivery the lock does not serialize.
33
+ # `admit` callable. So does a run's own export (EvaluationReportExport)
34
+ # posted back to the install and tenant that hold the run, whether the run
35
+ # was imported or executed by the dashboard. Different content under a
36
+ # run_id already stored raises Conflict. Imports for one owner run one at a
37
+ # time under an advisory lock (OwnerLock), so concurrent first deliveries
38
+ # find or create one agent, evaluation and scenario set, and the unique
39
+ # index on the run's tenant and run_id settles any delivery the lock does
40
+ # not serialize.
38
41
  class EvaluationReportImport
39
42
  class Error < StandardError; end
40
43
  # The payload is not a report the engine can store as sent; 422.
@@ -119,6 +122,21 @@ module ActionAgent
119
122
  new(payload, account, admit).call
120
123
  end
121
124
 
125
+ # The digest a run records for the payload it was stored from: the
126
+ # SHA-256 of the payload's JSON with every object's keys sorted.
127
+ def self.digest(payload)
128
+ Digest::SHA256.hexdigest(JSON.generate(canonical(payload)))
129
+ end
130
+
131
+ def self.canonical(value)
132
+ case value
133
+ when Hash then value.keys.sort.to_h { |key| [ key, canonical(value[key]) ] }
134
+ when Array then value.map { |child| canonical(child) }
135
+ else value
136
+ end
137
+ end
138
+ private_class_method :canonical
139
+
122
140
  # Why this install cannot store reports, or nil when it can: an install
123
141
  # with no evaluation tables (generated with --traces-only), or one whose
124
142
  # evaluation runs lack a column a later migration adds.
@@ -142,7 +160,7 @@ module ActionAgent
142
160
 
143
161
  def call
144
162
  validate!
145
- digest = Digest::SHA256.hexdigest(JSON.generate(canonical(@payload)))
163
+ digest = self.class.digest(@payload)
146
164
  existing = stored_run
147
165
  return [ identical!(existing, digest), true ] if existing
148
166
 
@@ -240,14 +258,27 @@ module ActionAgent
240
258
  @account ? @account.id.to_s : ""
241
259
  end
242
260
 
261
+ # The run this run_id already names for the tenant: one stored from an
262
+ # earlier delivery, or one this install exported (a dashboard run's
263
+ # exported run_id), found the way the export endpoint finds it.
243
264
  def stored_run
244
- EvaluationRun.find_by(external_tenant: tenant_key, external_run_id: @payload["run_id"])
265
+ EvaluationReportExport.find(@payload["run_id"], account: @account)
245
266
  end
246
267
 
268
+ # The stored run, when the payload is the report it was stored from or
269
+ # the run's own export (EvaluationReportExport), which is how an
270
+ # exported run posted back to its install is told from a different
271
+ # report under the same run_id.
247
272
  def identical!(run, digest)
248
- raise Conflict, "run_id already exists with a different report" unless run.external_report_digest == digest
273
+ return run if run.external_report_digest == digest || exported_digest(run) == digest
249
274
 
250
- run
275
+ raise Conflict, "run_id already exists with a different report"
276
+ end
277
+
278
+ def exported_digest(run)
279
+ EvaluationReportExport.new(run).digest
280
+ rescue EvaluationReportExport::NotExportable
281
+ nil
251
282
  end
252
283
 
253
284
  def admit!
@@ -815,13 +846,5 @@ module ActionAgent
815
846
  else value
816
847
  end
817
848
  end
818
-
819
- def canonical(value)
820
- case value
821
- when Hash then value.keys.sort.to_h { |key| [ key, canonical(value[key]) ] }
822
- when Array then value.map { |child| canonical(child) }
823
- else value
824
- end
825
- end
826
849
  end
827
850
  end
@@ -33,6 +33,10 @@ module ActionAgent
33
33
  def call
34
34
  run = @evaluation.evaluation_runs.create!(status: :running)
35
35
 
36
+ # A pin whose provider has no credentials fails here, before any work.
37
+ # A rules-only run never asks the judge, so a stale pin does not stop it.
38
+ judge_provider if @evaluation.judge_model.present? && judge_needed?
39
+
36
40
  # judge_defined evaluations author their KPI criteria on first run.
37
41
  ensure_judge_defined_kpis! if @evaluation.judge_defined?
38
42
 
@@ -83,19 +87,52 @@ module ActionAgent
83
87
  raise
84
88
  end
85
89
 
86
- # Returns the provider the judge runs on: the first of Anthropic, OpenAI
87
- # and OpenRouter the owner or the host's config has a key for, else Ollama
88
- # when the owner configured a host; nil when none. The judge runs
89
- # `judge_model` as that provider's own model id.
90
+ # Unpinned judges keep the configured default. A pinned model uses the
91
+ # existing ModelSpec syntax, so adding an unrelated key cannot reroute it.
90
92
  def judge_provider
91
- @judge_provider ||=
92
- %i[anthropic openai openrouter].find do |name|
93
- owner_provider_options(name).any? || global_provider_token?(name)
94
- end || (:ollama if owner_provider_options(:ollama).any?)
93
+ @judge_provider ||= begin
94
+ raw = @evaluation&.judge_model.presence
95
+ spec = raw && ActiveAgent::Evals::ModelSpec.parse(raw, default_provider: nil)
96
+ if spec&.provider.present?
97
+ provider = spec.provider.to_sym
98
+ if judge_provider_configured?(provider)
99
+ provider
100
+ elsif default_judge_provider
101
+ raise ArgumentError,
102
+ "No credentials for judge provider #{provider} (#{raw}); configure that provider or pin a provider-qualified gateway model ID"
103
+ end
104
+ else
105
+ default_judge_provider
106
+ end
107
+ end
95
108
  end
96
109
 
97
110
  private
98
111
 
112
+ # Whether a sample-based run asks the judge: to author KPIs, to score an
113
+ # llm_judge criterion, or for a comparison's verdict.
114
+ def judge_needed?
115
+ @evaluation.judge_defined? || @evaluation.llm_criteria.any? || @evaluation.compare_models.any?
116
+ end
117
+
118
+ def default_judge_provider
119
+ %i[anthropic openai openrouter ollama].find { |name| judge_provider_configured?(name) }
120
+ end
121
+
122
+ def judge_provider_configured?(name)
123
+ owner_provider_options(name).any? || (name != :ollama && global_provider_token?(name))
124
+ end
125
+
126
+ # A provider qualifier selects the route, not a prefix to send to the
127
+ # provider. Vendor prefixes inside an explicit gateway ID are retained.
128
+ def judge_pinned_model(provider = judge_provider)
129
+ raw = @evaluation&.judge_model.presence
130
+ return unless raw
131
+
132
+ spec = ActiveAgent::Evals::ModelSpec.parse(raw, default_provider: provider)
133
+ spec.provider.to_sym == provider ? spec.model : raw
134
+ end
135
+
99
136
  # Scores each sample-based criterion once per candidate model cohort and
100
137
  # asks the judge for a comparative verdict — "haiku vs qwen, judged".
101
138
  def score_comparison(run, sample_criteria, telemetry_criteria)
@@ -652,7 +689,7 @@ module ActionAgent
652
689
 
653
690
  config = ActiveAgent.configuration[judge_provider]
654
691
  @judge_model_setting = {
655
- "evaluation" => @evaluation.judge_model,
692
+ "evaluation" => judge_pinned_model,
656
693
  "provider_key" => owner_provider_options(judge_provider)[:model],
657
694
  "config" => (config[:model] if config.respond_to?(:[]))
658
695
  }.find { |_source, model| model.present? }&.then { |source, model| [ source, model.to_s ] }
@@ -665,10 +702,9 @@ module ActionAgent
665
702
  def judge_class
666
703
  @judge_class ||= begin
667
704
  provider = judge_provider
668
- model = @evaluation.judge_model.presence
669
- options = {}
705
+ model = judge_pinned_model(provider)
706
+ options = owner_provider_options(provider).dup
670
707
  options[:model] = model if model
671
- options.merge!(owner_provider_options(provider))
672
708
 
673
709
  Class.new(ActiveAgent::Base) do
674
710
  define_singleton_method(:name) { JUDGE_AGENT_NAME }