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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +59 -54
- data/app/controllers/action_agent/api/agents_controller.rb +19 -3
- data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
- data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
- data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
- data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
- data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
- data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
- data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
- data/app/jobs/action_agent/code_session_job.rb +171 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
- data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
- data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
- data/app/models/action_agent/agent.rb +49 -6
- data/app/models/action_agent/agent_run.rb +20 -1
- data/app/models/action_agent/code_session.rb +145 -0
- data/app/models/action_agent/evaluation.rb +64 -0
- data/app/models/action_agent/evaluation_run.rb +166 -54
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/github_connection.rb +75 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/provider_key.rb +148 -10
- data/app/models/action_agent/sandbox_session.rb +194 -17
- data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
- data/app/services/action_agent/agent_execution_service.rb +4 -2
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_tool_roster.rb +20 -9
- data/app/services/action_agent/claude_code_auth.rb +86 -0
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +25 -5
- data/app/services/action_agent/evaluation_standing.rb +145 -0
- data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
- data/app/services/action_agent/github_client.rb +111 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
- data/app/services/action_agent/local_sandbox_databases.rb +257 -0
- data/app/services/action_agent/mcp_client.rb +5 -1
- data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
- data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
- data/app/services/action_agent/ollama_host_probe.rb +75 -0
- data/app/services/action_agent/payload_bounds.rb +36 -0
- data/app/services/action_agent/sandbox_manifest.rb +67 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
- data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
- data/app/services/action_agent/secret_scrubber.rb +37 -0
- data/app/services/action_agent/tool_discovery.rb +19 -5
- data/config/routes.rb +23 -4
- data/lib/action_agent/engine.rb +1 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +153 -3
- data/lib/generators/action_agent/install_generator.rb +35 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
- data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
- data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
- data/lib/tasks/claude_code.rake +16 -0
- data/lib/tasks/sandbox.rake +26 -0
- 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
|