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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Where an evaluation stands against the agent as it is now, so a pass
5
+ # rate on the Evaluations page describes the current agent rather than
6
+ # pooling every suite's last run, however old the code it scored.
7
+ #
8
+ # The evaluation's headline run is its newest complete run; a newer run
9
+ # still pending or failed shows beside it, never in its place. Its
10
+ # standing is one of:
11
+ #
12
+ # current — the headline run scored the agent's current version
13
+ # stale — it scored an earlier version, or a release the dashboard
14
+ # has not matched to the agent's current release
15
+ # unrecorded — the run recorded no version and the agent has no release
16
+ # versions at all, so nothing says which code it scored
17
+ # archived — the evaluation is archived (Evaluation#archive!)
18
+ # none — no complete run
19
+ #
20
+ # Only edits the model can see count as a new version: the instructions,
21
+ # the action prompts, the tools, the MCP servers, the model config and
22
+ # the response format. A dashboard edit to the agent's appearance cuts an
23
+ # AgentVersion like any other edit, but the run before it scored the same
24
+ # agent and stays current.
25
+ class EvaluationStanding
26
+ STANDINGS = %w[current stale unrecorded archived none].freeze
27
+ VERSION_STATES = %w[current earlier unrecorded].freeze
28
+ # The configuration snapshot keys the model is given.
29
+ MODEL_FACING = %w[instructions action_prompts tools mcp_servers model_config response_format].freeze
30
+
31
+ # Gives every evaluation its standing with one query per table rather
32
+ # than one per evaluation: the agents' latest versions and whether each
33
+ # has a release are read for the page at once.
34
+ def self.preload(evaluations)
35
+ evaluations = Array(evaluations)
36
+ agent_ids = evaluations.filter_map(&:agent_id).uniq
37
+ return if agent_ids.empty?
38
+
39
+ newest = AgentVersion.where(agent_id: agent_ids).group(:agent_id).maximum(:version_number)
40
+ latest = AgentVersion.where(agent_id: agent_ids).where(version_number: newest.values.uniq)
41
+ .select { |version| newest[version.agent_id] == version.version_number }.index_by(&:agent_id)
42
+ releases = AgentVersion.releases.where(agent_id: agent_ids).distinct.pluck(:agent_id).to_set
43
+
44
+ evaluations.each do |evaluation|
45
+ evaluation.standing_info = new(evaluation, latest_version: latest[evaluation.agent_id], has_releases: releases.include?(evaluation.agent_id))
46
+ end
47
+ end
48
+
49
+ attr_reader :evaluation
50
+
51
+ # @param headline_run [EvaluationRun, nil] the headline run when the
52
+ # caller already holds it (AgentScorecard selects one per evaluation)
53
+ def initialize(evaluation, latest_version: :unknown, has_releases: :unknown, headline_run: :unknown)
54
+ @evaluation = evaluation
55
+ @latest_version = latest_version
56
+ @has_releases = has_releases
57
+ @headline_run = headline_run
58
+ end
59
+
60
+ def headline_run
61
+ return @headline_run unless @headline_run == :unknown
62
+
63
+ @headline_run = evaluation.headline_run
64
+ end
65
+
66
+ # The same standing read against +run+ as the headline run.
67
+ def with_headline(run)
68
+ self.class.new(evaluation, latest_version: latest_version, has_releases: has_releases?, headline_run: run)
69
+ end
70
+
71
+ def standing
72
+ return "archived" if evaluation.archived?
73
+ return "none" unless headline_run
74
+
75
+ case version_state(headline_run)
76
+ when "current" then "current"
77
+ when "unrecorded" then has_releases? ? "stale" : "unrecorded"
78
+ else "stale"
79
+ end
80
+ end
81
+
82
+ # Whether +run+ scored the agent's current version: "current",
83
+ # "earlier", or "unrecorded" when it recorded no version.
84
+ def version_state(run)
85
+ version = run&.agent_version
86
+ return "unrecorded" unless version
87
+
88
+ # The agent's last recorded deploy is the truth about the code that
89
+ # runs: a release version matching it is current whatever the
90
+ # dashboard edited since, and one that does not is earlier even when
91
+ # it was recorded last (a report of an older deploy published late).
92
+ if version.release? && (deployed = evaluation.agent&.release_digest.presence)
93
+ return deployed == version.release_digest ? "current" : "earlier"
94
+ end
95
+
96
+ latest = latest_version
97
+ return "current" if latest.nil? || latest.id == version.id
98
+ return "current" if model_facing(version) == model_facing(latest)
99
+
100
+ "earlier"
101
+ end
102
+
103
+ # Passes per model label of the headline run, `{ label => { passed, total } }`,
104
+ # from its recorded summaries; empty without them.
105
+ def per_model
106
+ run = headline_run
107
+ return {} unless run
108
+
109
+ summaries = run.scores.is_a?(Hash) ? (run.scores["_models"].presence || run.scores["_cohorts"]) : nil
110
+ return {} unless summaries.is_a?(Hash)
111
+
112
+ summaries.filter_map do |label, stats|
113
+ next unless stats.is_a?(Hash)
114
+
115
+ total = (stats["scenarios"] || stats["samples"]).to_i
116
+ [ label, { passed: stats["passed"].to_i, total: total } ]
117
+ end.to_h
118
+ end
119
+
120
+ # What the index and the scorecards pool: a current or unrecorded
121
+ # headline run; a stale or archived evaluation is left out and said so.
122
+ def counted?
123
+ %w[current unrecorded].include?(standing)
124
+ end
125
+
126
+ private
127
+
128
+ def latest_version
129
+ return @latest_version unless @latest_version == :unknown
130
+
131
+ @latest_version = evaluation.agent&.latest_version
132
+ end
133
+
134
+ def has_releases?
135
+ return @has_releases unless @has_releases == :unknown
136
+
137
+ @has_releases = evaluation.agent&.agent_versions&.releases&.exists? || false
138
+ end
139
+
140
+ def model_facing(version)
141
+ snapshot = version.configuration_snapshot.to_h.stringify_keys
142
+ MODEL_FACING.to_h { |key| [ key, snapshot[key] ] }
143
+ end
144
+ end
145
+ end
@@ -80,6 +80,24 @@ module ActionAgent
80
80
  configured_keys.to_a
81
81
  end
82
82
 
83
+ # The tools the agent's entry for +key+ allows, or nil when that entry
84
+ # names none — which offers every tool the server serves. This is the rule
85
+ # AgentToolRoster#allowed_tools reads for the Tools tab, which saves
86
+ # {key, name, tools: [...]} when some are switched off and tools: [] when
87
+ # all are: a later entry for the same key wins, as it does there.
88
+ #
89
+ # @param key [String] a server key
90
+ # @return [Array<String>, nil]
91
+ def allowed_tools_for(key)
92
+ entry = configured_entries.select { |candidate| normalize(entry_key(candidate)) == normalize(key) }.last
93
+ return nil unless entry.respond_to?(:key?)
94
+
95
+ names = entry["tools"] || entry[:tools]
96
+ return nil if names.nil?
97
+
98
+ Array(names).filter_map { |tool| (tool.respond_to?(:key?) ? tool["name"] || tool[:name] : tool).to_s.strip.presence }
99
+ end
100
+
83
101
  private
84
102
 
85
103
  # The catalog's name when it has one; otherwise the name the agent's
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # The few GitHub calls the dashboard makes: the OAuth code exchange, the
5
+ # authenticated user, and the repositories that user can reach. Plain
6
+ # Net::HTTP, like the provider model lookups, so the engine carries no
7
+ # GitHub SDK.
8
+ class GithubClient
9
+ API = "https://api.github.com"
10
+ TOKEN_URL = "https://github.com/login/oauth/access_token"
11
+ AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
12
+ TIMEOUT_SECONDS = 8
13
+ # 100 per page is GitHub's maximum; five pages bounds a picker at 500.
14
+ PER_PAGE = 100
15
+ MAX_PAGES = 5
16
+
17
+ class Error < StandardError; end
18
+ # The token was revoked or expired: the owner has to connect again.
19
+ class Unauthorized < Error; end
20
+
21
+ def self.authorize_url(redirect_uri:, state:)
22
+ query = {
23
+ client_id: ActionAgent.github_client_id,
24
+ redirect_uri: redirect_uri,
25
+ scope: ActionAgent.github_oauth_scopes,
26
+ state: state,
27
+ allow_signup: "false"
28
+ }.to_query
29
+
30
+ "#{AUTHORIZE_URL}?#{query}"
31
+ end
32
+
33
+ # Trades an OAuth code for { access_token:, scope: }.
34
+ def self.exchange_code(code:, redirect_uri:)
35
+ uri = URI.parse(TOKEN_URL)
36
+ request = Net::HTTP::Post.new(uri, "Accept" => "application/json")
37
+ request.set_form_data(
38
+ client_id: ActionAgent.github_client_id,
39
+ client_secret: ActionAgent.github_client_secret,
40
+ code: code,
41
+ redirect_uri: redirect_uri
42
+ )
43
+ data = perform(uri, request)
44
+ raise Error, data["error_description"].presence || data["error"] if data["error"].present?
45
+ raise Error, "GitHub returned no access token" if data["access_token"].blank?
46
+
47
+ { access_token: data["access_token"], scope: data["scope"].to_s }
48
+ end
49
+
50
+ def self.perform(uri, request)
51
+ response = Net::HTTP.start(
52
+ uri.host, uri.port,
53
+ use_ssl: true, open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS
54
+ ) { |http| http.request(request) }
55
+
56
+ raise Unauthorized, "GitHub rejected the token" if response.code.to_i == 401
57
+ raise Error, "GitHub answered #{response.code}" unless response.code.to_i.between?(200, 299)
58
+
59
+ JSON.parse(response.body.presence || "{}")
60
+ rescue JSON::ParserError
61
+ raise Error, "GitHub answered with something other than JSON"
62
+ rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
63
+ raise Error, "GitHub is unreachable (#{e.class.name})"
64
+ end
65
+
66
+ def initialize(access_token)
67
+ @access_token = access_token
68
+ end
69
+
70
+ def user
71
+ get("/user")
72
+ end
73
+
74
+ # Every repository the token reaches (owned, collaborated on, or through
75
+ # an organization), most recently pushed first, reduced to the fields the
76
+ # dashboard stores.
77
+ def repositories
78
+ (1..MAX_PAGES).each_with_object([]) do |page, all|
79
+ batch = get("/user/repos", per_page: PER_PAGE, page: page, sort: "pushed",
80
+ affiliation: "owner,collaborator,organization_member")
81
+ all.concat(Array(batch).map { |repo| self.class.slice_repository(repo) })
82
+ break all if Array(batch).size < PER_PAGE
83
+ end
84
+ end
85
+
86
+ def self.slice_repository(repo)
87
+ {
88
+ "id" => repo["id"],
89
+ "full_name" => repo["full_name"],
90
+ "private" => repo["private"] == true,
91
+ "default_branch" => repo["default_branch"].presence || "main",
92
+ "description" => repo["description"],
93
+ "html_url" => repo["html_url"],
94
+ "pushed_at" => repo["pushed_at"]
95
+ }
96
+ end
97
+
98
+ private
99
+
100
+ def get(path, params = {})
101
+ uri = URI.parse("#{API}#{path}")
102
+ uri.query = params.to_query if params.any?
103
+ request = Net::HTTP::Get.new(uri,
104
+ "Accept" => "application/vnd.github+json",
105
+ "Authorization" => "Bearer #{@access_token}",
106
+ "X-GitHub-Api-Version" => "2022-11-28",
107
+ "User-Agent" => "activeagent-dashboard")
108
+ self.class.perform(uri, request)
109
+ end
110
+ end
111
+ end