activeagent 1.9.3 → 1.9.5
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/CHANGELOG.md +165 -0
- data/README.md +7 -1
- data/exe/activeagent +8 -0
- data/lib/active_agent/cli/client.rb +227 -0
- data/lib/active_agent/cli/credentials.rb +79 -0
- data/lib/active_agent/cli/eval_commands.rb +264 -0
- data/lib/active_agent/cli/resource_commands.rb +154 -0
- data/lib/active_agent/cli/session_commands.rb +106 -0
- data/lib/active_agent/cli/trace_commands.rb +129 -0
- data/lib/active_agent/cli.rb +300 -0
- data/lib/active_agent/concerns/preview.rb +2 -2
- data/lib/active_agent/evals/design_tokens.rb +12 -1
- data/lib/active_agent/evals/publisher.rb +72 -17
- data/lib/active_agent/inline_preview_interceptor.rb +1 -0
- data/lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb +17 -0
- data/lib/active_agent/preview/templates/rails/agents/index.html.erb +14 -0
- data/lib/active_agent/preview/templates/rails/agents/preview.html.erb +3 -0
- data/lib/active_agent/railtie.rb +11 -5
- data/lib/active_agent/version.rb +1 -1
- data/lib/generators/test_unit/agent_generator.rb +1 -1
- data/lib/generators/test_unit/templates/functional_test.rb.tt +1 -1
- data/lib/generators/test_unit/templates/preview.rb.tt +3 -3
- data/lib/rails/agents_controller.rb +43 -0
- metadata +17 -4
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
class CLI
|
|
7
|
+
# `activeagent evals ...`. list, runs and run go through the MCP facade's
|
|
8
|
+
# evaluation tools; import posts a version-1 report to the collector with
|
|
9
|
+
# ActiveAgent::Evals::Publisher; report reads one back from the
|
|
10
|
+
# collector's export endpoint.
|
|
11
|
+
module EvalCommands
|
|
12
|
+
# How `evals report` asks GET <mount>/api/evaluation_reports/:run_id
|
|
13
|
+
# for each format. The JSON form is the version-1 envelope the
|
|
14
|
+
# collector takes, so `evals report --format json` then `evals import`
|
|
15
|
+
# copies a run between installs.
|
|
16
|
+
REPORT_FORMATS = {
|
|
17
|
+
"json" => { accept: "application/json", query: nil },
|
|
18
|
+
"html" => { accept: "text/html", query: "format=html" },
|
|
19
|
+
# GitHub-flavored Markdown, for a pull request comment:
|
|
20
|
+
# `activeagent evals report RUN --format md | gh pr comment 12 --body-file -`
|
|
21
|
+
"md" => { accept: "text/markdown", query: "format=md" }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
RUN_FINISHED = %w[complete failed].freeze
|
|
25
|
+
DEFAULT_WAIT_TIMEOUT = 30 * 60
|
|
26
|
+
DEFAULT_POLL_INTERVAL = 5
|
|
27
|
+
FAILURES_SHOWN = 20
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def evals_list(argv)
|
|
32
|
+
arguments = {}
|
|
33
|
+
argv = parse(argv, "evals list [--agent SLUG] [--limit N]") do |p|
|
|
34
|
+
p.on("--agent AGENT", "Only this agent's evaluations, by slug or id") { |v| arguments[:agent] = v }
|
|
35
|
+
p.on("--limit N", "At most N evaluations (max 50)") { |v| arguments[:limit] = positive_integer(v, "--limit") }
|
|
36
|
+
end
|
|
37
|
+
return EXIT_SUCCESS unless argv
|
|
38
|
+
|
|
39
|
+
evaluations = client.tool("evaluations_list", arguments).fetch("evaluations", [])
|
|
40
|
+
emit(evaluations: evaluations) do
|
|
41
|
+
table(evaluations, {
|
|
42
|
+
"ID" => ->(e) { e["id"] },
|
|
43
|
+
"NAME" => ->(e) { e["name"] },
|
|
44
|
+
"AGENT" => ->(e) { e.dig("agent", "slug") },
|
|
45
|
+
"SCENARIOS" => ->(e) { e["scenario_count"] },
|
|
46
|
+
"RUNS" => ->(e) { e["run_count"] },
|
|
47
|
+
"LATEST" => ->(e) { e.dig("latest_run", "status") },
|
|
48
|
+
"PASSED" => ->(e) { passed(e["latest_run"]) }
|
|
49
|
+
}, empty: "No evaluations.")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def evals_runs(argv)
|
|
54
|
+
argv = parse(argv, "evals runs EVALUATION")
|
|
55
|
+
return EXIT_SUCCESS unless argv
|
|
56
|
+
|
|
57
|
+
evaluation_id = resolve_evaluation(require_argument(argv, "EVALUATION (an id or name)", "evals runs EVALUATION"))
|
|
58
|
+
payload = client.tool("evaluations_get", evaluation_id: evaluation_id)
|
|
59
|
+
runs = payload.fetch("runs", [])
|
|
60
|
+
emit(evaluation: payload["evaluation"], runs: runs) do
|
|
61
|
+
@out.puts "#{payload.dig("evaluation", "name")} (evaluation #{evaluation_id})"
|
|
62
|
+
table(runs, {
|
|
63
|
+
"RUN" => ->(r) { r["id"] },
|
|
64
|
+
"#" => ->(r) { r["number"] },
|
|
65
|
+
"STATUS" => ->(r) { r["status"] },
|
|
66
|
+
"PASSED" => ->(r) { passed(r) },
|
|
67
|
+
"SCORE" => ->(r) { r["average_score"] },
|
|
68
|
+
"CREATED" => ->(r) { r["created_at"] },
|
|
69
|
+
# The handle `evals report` takes.
|
|
70
|
+
"RUN_ID" => ->(r) { r["run_id"] }
|
|
71
|
+
}, empty: "No runs yet.")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Starts a run. With --wait (implied by --min-pass-rate), polls it to
|
|
76
|
+
# the end and exits EXIT_GATE when it failed or passed fewer scenarios
|
|
77
|
+
# than the threshold, so a CI step fails the build.
|
|
78
|
+
def evals_run(argv)
|
|
79
|
+
arguments = {}
|
|
80
|
+
wait = false
|
|
81
|
+
min_pass_rate = nil
|
|
82
|
+
timeout = DEFAULT_WAIT_TIMEOUT
|
|
83
|
+
interval = DEFAULT_POLL_INTERVAL
|
|
84
|
+
argv = parse(argv, "evals run EVALUATION [--models a,b] [--wait] [--min-pass-rate 0.8]") do |p|
|
|
85
|
+
p.on("--models LIST", "Candidate models to compare, comma-separated") { |v| arguments[:models] = list_option(v) }
|
|
86
|
+
p.on("--keys LIST", "Replay only these scenario keys, comma-separated") { |v| arguments[:keys] = list_option(v) }
|
|
87
|
+
p.on("--group GROUP", "Replay only this scenario group") { |v| arguments[:group] = v }
|
|
88
|
+
p.on("--sandbox ID", "Replay against this ready checkout sandbox") { |v| arguments[:sandbox_id] = v }
|
|
89
|
+
p.on("--wait", "Wait for the run to finish") { wait = true }
|
|
90
|
+
p.on("--min-pass-rate RATE", "Fail (exit #{EXIT_GATE}) below this pass rate: 0.8 or 80%; implies --wait") do |v|
|
|
91
|
+
min_pass_rate = pass_rate_option(v)
|
|
92
|
+
wait = true
|
|
93
|
+
end
|
|
94
|
+
p.on("--timeout SECONDS", "Give up waiting after this long (default #{DEFAULT_WAIT_TIMEOUT})") { |v| timeout = positive_integer(v, "--timeout") }
|
|
95
|
+
p.on("--interval SECONDS", "Poll this often (default #{DEFAULT_POLL_INTERVAL})") { |v| interval = positive_integer(v, "--interval") }
|
|
96
|
+
end
|
|
97
|
+
return EXIT_SUCCESS unless argv
|
|
98
|
+
|
|
99
|
+
evaluation_id = resolve_evaluation(require_argument(argv, "EVALUATION (an id or name)", "evals run EVALUATION"))
|
|
100
|
+
started = client.tool("evaluations_run", arguments.merge(evaluation_id: evaluation_id))
|
|
101
|
+
run = started.fetch("run", {})
|
|
102
|
+
@err.puts "Started run #{run["id"]} of #{started.dig("evaluation", "name")} (evaluation #{evaluation_id})." unless @options[:json]
|
|
103
|
+
unless wait
|
|
104
|
+
return emit(started) { @out.puts "Status: #{run["status"]}. Follow it with `activeagent evals runs #{evaluation_id}`." }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
finished = wait_for_run(evaluation_id, run["id"], timeout: timeout, interval: interval)
|
|
108
|
+
gate(finished, min_pass_rate)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Without --format, a terminal gets Markdown to read and anything else
|
|
112
|
+
# (a pipe, a file, -o) gets the JSON envelope, so scripts see JSON.
|
|
113
|
+
# RUN is the run's run_id: the RUN_ID column of `evals runs`, which is
|
|
114
|
+
# a published run's own run_id and a dashboard run's
|
|
115
|
+
# dashboard-run-<id>-<created_at>.
|
|
116
|
+
def evals_report(argv)
|
|
117
|
+
format = nil
|
|
118
|
+
output = nil
|
|
119
|
+
argv = parse(argv, "evals report RUN [--format json|html|md] [-o FILE]") do |p|
|
|
120
|
+
p.on("--format FORMAT", REPORT_FORMATS.keys,
|
|
121
|
+
"json (a version-1 envelope), html or md; default md on a terminal, json otherwise") { |v| format = v }
|
|
122
|
+
p.on("-o", "--output FILE", "Write to FILE instead of standard output") { |v| output = v }
|
|
123
|
+
end
|
|
124
|
+
return EXIT_SUCCESS unless argv
|
|
125
|
+
|
|
126
|
+
run_id = require_argument(argv, "RUN (the run's run_id, the RUN_ID of `evals runs`)", "evals report RUN")
|
|
127
|
+
format ||= !output && @out.respond_to?(:tty?) && @out.tty? ? "md" : "json"
|
|
128
|
+
request = REPORT_FORMATS.fetch(format)
|
|
129
|
+
response = client.get("/api/evaluation_reports/#{URI.encode_www_form_component(run_id).gsub("+", "%20")}",
|
|
130
|
+
accept: request[:accept], query: request[:query],
|
|
131
|
+
missing: "No report for run #{run_id}; `activeagent evals runs EVALUATION` shows each run's RUN_ID",
|
|
132
|
+
unavailable: "This dashboard has no report export (GET <mount>/api/evaluation_reports/:run_id); " \
|
|
133
|
+
"it predates actionagent 1.9.3")
|
|
134
|
+
body = response.body.to_s
|
|
135
|
+
body = JSON.pretty_generate(report_envelope(body)) if format == "json"
|
|
136
|
+
write_output(body, output) { "Wrote #{format} report for run #{run_id} to #{output}." }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Posts a saved report to the collector. FILE is a version-1 envelope
|
|
140
|
+
# (what `evals report --format json` writes) or a bare Report#to_h
|
|
141
|
+
# hash, whose identities then come from the options.
|
|
142
|
+
def evals_import(argv)
|
|
143
|
+
identities = {}
|
|
144
|
+
argv = parse(argv, "evals import FILE [--run-id ID --source NAME --agent NAME --suite NAME]") do |p|
|
|
145
|
+
p.on("--run-id ID", "The report's run_id (kept across retries)") { |v| identities["run_id"] = v }
|
|
146
|
+
p.on("--source NAME", "The application that ran it") { |v| identities["source"] = v }
|
|
147
|
+
p.on("--agent NAME", "The agent's name") { |v| identities["agent_name"] = v }
|
|
148
|
+
p.on("--suite NAME", "The suite's name") { |v| identities["suite"] = v }
|
|
149
|
+
end
|
|
150
|
+
return EXIT_SUCCESS unless argv
|
|
151
|
+
|
|
152
|
+
path = require_argument(argv, "FILE", "evals import FILE")
|
|
153
|
+
document = read_json_file(path)
|
|
154
|
+
envelope = document.key?("version") && document.key?("report") ? document : { "report" => document }
|
|
155
|
+
if envelope.key?("version") && envelope["version"] != 1
|
|
156
|
+
raise Error.new("#{path} is a version #{envelope["version"].inspect} envelope; this CLI sends version 1", exit_code: EXIT_USAGE)
|
|
157
|
+
end
|
|
158
|
+
envelope = envelope.merge(identities)
|
|
159
|
+
envelope["run_id"] ||= envelope["report"].is_a?(Hash) ? envelope["report"].dig("metadata", "run_id") : nil
|
|
160
|
+
|
|
161
|
+
receipt = client.publisher.call(report: envelope["report"], run_id: envelope["run_id"], source: envelope["source"],
|
|
162
|
+
agent_name: envelope["agent_name"], suite: envelope["suite"])
|
|
163
|
+
emit(receipt) do
|
|
164
|
+
@out.puts "#{receipt["duplicate"] ? "Already stored" : "Imported"} run #{receipt["run_id"]} " \
|
|
165
|
+
"(evaluation #{receipt["evaluation_id"]}, run #{receipt["id"]})."
|
|
166
|
+
end
|
|
167
|
+
rescue ArgumentError => e
|
|
168
|
+
raise Error.new("#{e.message}; give it with --run-id, --source, --agent or --suite", exit_code: EXIT_USAGE)
|
|
169
|
+
rescue ActiveAgent::Evals::Publisher::Error => e
|
|
170
|
+
raise Error.new(e.message, status: e.status)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# An evaluation's id, from an id or its exact name among the first 50
|
|
174
|
+
# evaluations the key can reach.
|
|
175
|
+
def resolve_evaluation(value)
|
|
176
|
+
return Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
177
|
+
|
|
178
|
+
matches = client.tool("evaluations_list", limit: 50).fetch("evaluations", []).select { |e| e["name"] == value }
|
|
179
|
+
case matches.size
|
|
180
|
+
when 1 then matches.first["id"]
|
|
181
|
+
when 0 then raise Error, "No evaluation named #{value.inspect}; `activeagent evals list` shows them"
|
|
182
|
+
else
|
|
183
|
+
raise Error.new("#{matches.size} evaluations are named #{value.inspect} (ids #{matches.map { |e| e["id"] }.join(", ")}); " \
|
|
184
|
+
"pass the id", exit_code: EXIT_USAGE)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def wait_for_run(evaluation_id, run_id, timeout:, interval:)
|
|
189
|
+
deadline = @clock.call + timeout
|
|
190
|
+
loop do
|
|
191
|
+
payload = client.tool("evaluation_runs_get", evaluation_id: evaluation_id, run_id: run_id, failed_only: true, limit: FAILURES_SHOWN)
|
|
192
|
+
return payload if RUN_FINISHED.include?(payload.dig("run", "status"))
|
|
193
|
+
|
|
194
|
+
if @clock.call >= deadline
|
|
195
|
+
raise Error, "Run #{run_id} is still #{payload.dig("run", "status")} after #{timeout}s; " \
|
|
196
|
+
"raise --timeout or follow it with `activeagent evals runs #{evaluation_id}`"
|
|
197
|
+
end
|
|
198
|
+
@sleeper.call(interval)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def gate(payload, min_pass_rate)
|
|
203
|
+
run = payload.fetch("run", {})
|
|
204
|
+
rate = pass_rate(run)
|
|
205
|
+
failed = run["status"] == "failed"
|
|
206
|
+
below = min_pass_rate && (rate.nil? || rate < min_pass_rate)
|
|
207
|
+
verdict = failed || below ? "fail" : "pass"
|
|
208
|
+
failures = payload.fetch("results", [])
|
|
209
|
+
|
|
210
|
+
emit(payload.merge("pass_rate" => rate, "min_pass_rate" => min_pass_rate, "verdict" => verdict)) do
|
|
211
|
+
@out.puts "Run #{run["id"]} #{run["status"]}: #{passed(run) || "no results"}" \
|
|
212
|
+
"#{rate ? format(" (%.1f%%)", rate * 100) : ""}#{min_pass_rate ? format(", threshold %.1f%%", min_pass_rate * 100) : ""}."
|
|
213
|
+
@out.puts "Error: #{run["error_message"]}" if run["error_message"]
|
|
214
|
+
unless failures.empty?
|
|
215
|
+
@out.puts "Failed results:"
|
|
216
|
+
table(failures, {
|
|
217
|
+
"SCENARIO" => ->(r) { r["scenario_key"] },
|
|
218
|
+
"MODEL" => ->(r) { r["model"] },
|
|
219
|
+
"STATUS" => ->(r) { r["status"] },
|
|
220
|
+
"FAULT" => ->(r) { r["fault"] },
|
|
221
|
+
"TRACE" => ->(r) { r["trace_id"] }
|
|
222
|
+
})
|
|
223
|
+
end
|
|
224
|
+
@out.puts "Report: activeagent evals report #{run["run_id"]}" if run["run_id"]
|
|
225
|
+
@out.puts(verdict == "pass" ? "PASS" : "FAIL")
|
|
226
|
+
end
|
|
227
|
+
verdict == "pass" ? EXIT_SUCCESS : EXIT_GATE
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def pass_rate(run)
|
|
231
|
+
evaluated = run["samples_evaluated"].to_i
|
|
232
|
+
evaluated.positive? ? run["samples_passed"].to_f / evaluated : nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def passed(run)
|
|
236
|
+
return unless run.is_a?(Hash) && run["samples_evaluated"]
|
|
237
|
+
|
|
238
|
+
"#{run["samples_passed"].to_i}/#{run["samples_evaluated"]}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def pass_rate_option(value)
|
|
242
|
+
rate = value.end_with?("%") ? Float(value.delete_suffix("%")) / 100 : Float(value)
|
|
243
|
+
raise ArgumentError unless rate.between?(0, 1)
|
|
244
|
+
|
|
245
|
+
rate
|
|
246
|
+
rescue ArgumentError, TypeError
|
|
247
|
+
raise Error.new("--min-pass-rate must be a rate between 0 and 1, or a percentage such as 80%", exit_code: EXIT_USAGE)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The collector's export must be the envelope `evals import` sends back.
|
|
251
|
+
def report_envelope(body)
|
|
252
|
+
envelope = JSON.parse(body)
|
|
253
|
+
unless envelope.is_a?(Hash) && envelope["version"] == 1 && envelope["report"].is_a?(Hash)
|
|
254
|
+
raise Error, "The dashboard's report is not a version-1 envelope"
|
|
255
|
+
end
|
|
256
|
+
envelope
|
|
257
|
+
rescue JSON::ParserError
|
|
258
|
+
raise Error.new("The dashboard returned invalid JSON for the report"), cause: nil
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
include EvalCommands
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
class CLI
|
|
5
|
+
# `activeagent catalogs|agents ...`, through the MCP facade's dashboard
|
|
6
|
+
# tools: scenario catalogs are listed, imported (or the import
|
|
7
|
+
# previewed) and exported as the dashboard's Catalogs page does it, and
|
|
8
|
+
# agents are listed with their versions.
|
|
9
|
+
module ResourceCommands
|
|
10
|
+
CATALOG_FORMATS = %w[yaml json].freeze
|
|
11
|
+
CATALOG_MODES = %w[replace merge].freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def catalogs_list(argv)
|
|
16
|
+
argv = parse(argv, "catalogs list")
|
|
17
|
+
return EXIT_SUCCESS unless argv
|
|
18
|
+
|
|
19
|
+
catalogs = client.tool("catalogs_list").fetch("catalogs", [])
|
|
20
|
+
emit(catalogs: catalogs) do
|
|
21
|
+
table(catalogs, {
|
|
22
|
+
"KEY" => ->(c) { c["key"] },
|
|
23
|
+
"NAME" => ->(c) { c["name"] },
|
|
24
|
+
"PRODUCTS" => ->(c) { c["product_count"] },
|
|
25
|
+
"SCENARIOS" => ->(c) { c["scenario_count"] },
|
|
26
|
+
"SOURCE" => ->(c) { c["source_path"] || c["source_kind"] },
|
|
27
|
+
"UPDATED" => ->(c) { c["updated_at"] }
|
|
28
|
+
}, empty: "No scenario catalogs.")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Imports a catalog's document (YAML, or the JSON `catalogs export
|
|
33
|
+
# --format json` writes, which YAML reads). Products, sets and
|
|
34
|
+
# scenarios are matched by key, so importing the same file twice
|
|
35
|
+
# changes nothing; --mode merge keeps what the document leaves out.
|
|
36
|
+
# --preview shows what the import would change and writes nothing.
|
|
37
|
+
def catalogs_import(argv)
|
|
38
|
+
arguments = {}
|
|
39
|
+
preview = false
|
|
40
|
+
argv = parse(argv, "catalogs import FILE [--name KEY] [--mode replace|merge] [--preview]") do |p|
|
|
41
|
+
p.on("--name KEY", "The catalog key when the document sets no `catalog:`") { |v| arguments[:name] = v }
|
|
42
|
+
p.on("--mode MODE", CATALOG_MODES, "replace (the default) makes the catalog the document; merge keeps what it leaves out") do |v|
|
|
43
|
+
arguments[:mode] = v
|
|
44
|
+
end
|
|
45
|
+
p.on("--preview", "Show what the import would change, without applying it") { preview = true }
|
|
46
|
+
end
|
|
47
|
+
return EXIT_SUCCESS unless argv
|
|
48
|
+
|
|
49
|
+
path = require_argument(argv, "FILE", "catalogs import FILE")
|
|
50
|
+
arguments[:document] = read_text_file(path)
|
|
51
|
+
return catalogs_preview(arguments) if preview
|
|
52
|
+
|
|
53
|
+
payload = client.tool("catalogs_import", arguments)
|
|
54
|
+
emit(payload) do
|
|
55
|
+
catalog = payload.fetch("catalog", {})
|
|
56
|
+
@out.puts "Imported catalog #{catalog["key"]} (#{catalog["product_count"]} products, #{catalog["scenario_count"]} scenarios)."
|
|
57
|
+
Array(payload["products"]).each do |product|
|
|
58
|
+
sets = Array(product["sets"]).map { |set| "#{set["key"]} (#{set["scenario_count"]})" }
|
|
59
|
+
@out.puts " #{product["key"]}#{" -> #{product["agent"]}" if product["agent"]}: #{sets.join(", ")}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def catalogs_preview(arguments)
|
|
65
|
+
payload = client.tool("catalogs_preview", arguments)
|
|
66
|
+
emit(payload) do
|
|
67
|
+
catalog = payload.fetch("catalog", {})
|
|
68
|
+
# The catalog's own action is about its record (name, description);
|
|
69
|
+
# what its products, sets and scenarios would do is the change list.
|
|
70
|
+
verdict = { "create" => "created", "update" => "updated" }.fetch(catalog["action"], "kept as it is")
|
|
71
|
+
@out.puts "Preview (#{payload["mode"]}): catalog #{catalog["key"]} would be #{verdict}; " \
|
|
72
|
+
"#{payload["scenario_count"]} scenarios in the document."
|
|
73
|
+
counts = payload.fetch("counts", {}).reject { |action, _| action == "unchanged" }
|
|
74
|
+
@out.puts "Changes: #{counts.empty? ? "none" : counts.map { |action, count| "#{count} #{action}" }.join(", ")}"
|
|
75
|
+
table(Array(payload["changes"]), {
|
|
76
|
+
"ACTION" => ->(c) { c["action"] },
|
|
77
|
+
"LEVEL" => ->(c) { c["level"] },
|
|
78
|
+
"PATH" => ->(c) { c["path"] }
|
|
79
|
+
}, empty: "Nothing would change.")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Writes a catalog's canonical document from the dashboard's records:
|
|
84
|
+
# the whole catalog, or one product or set of it. The same document
|
|
85
|
+
# the Catalogs page's Export YAML downloads; --format json writes it
|
|
86
|
+
# as JSON instead.
|
|
87
|
+
def catalogs_export(argv)
|
|
88
|
+
arguments = {}
|
|
89
|
+
output = nil
|
|
90
|
+
argv = parse(argv, "catalogs export KEY [--product KEY [--set KEY]] [--format yaml|json] [-o FILE]") do |p|
|
|
91
|
+
p.on("--product KEY", "Only this product") { |v| arguments[:product] = v }
|
|
92
|
+
p.on("--set KEY", "Only this set of --product") { |v| arguments[:set] = v }
|
|
93
|
+
p.on("--format FORMAT", CATALOG_FORMATS, "yaml (the default; json when FILE ends in .json) or json") { |v| arguments[:format] = v }
|
|
94
|
+
p.on("-o", "--output FILE", "Write to FILE instead of standard output") { |v| output = v }
|
|
95
|
+
end
|
|
96
|
+
return EXIT_SUCCESS unless argv
|
|
97
|
+
|
|
98
|
+
arguments[:catalog] = require_argument(argv, "KEY", "catalogs export KEY")
|
|
99
|
+
arguments[:format] ||= output.to_s.end_with?(".json") ? "json" : "yaml"
|
|
100
|
+
payload = client.tool("catalogs_export", arguments)
|
|
101
|
+
if @options[:json]
|
|
102
|
+
@out.puts JSON.pretty_generate(payload)
|
|
103
|
+
return EXIT_SUCCESS
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
write_output(payload["document"].to_s, output) do
|
|
107
|
+
"Wrote catalog #{payload.dig("catalog", "key")} (#{payload["format"]}) to #{output}."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The agents the key reaches, from the facade's agent:// resources.
|
|
112
|
+
def agents_list(argv)
|
|
113
|
+
argv = parse(argv, "agents list")
|
|
114
|
+
return EXIT_SUCCESS unless argv
|
|
115
|
+
|
|
116
|
+
resources = Array(client.mcp("resources/list")&.fetch("resources", []))
|
|
117
|
+
agents = resources.map do |resource|
|
|
118
|
+
{ "slug" => resource["uri"].to_s.delete_prefix("agent://"), "name" => resource["name"], "description" => resource["description"] }
|
|
119
|
+
end
|
|
120
|
+
emit(agents: agents) do
|
|
121
|
+
table(agents, { "SLUG" => ->(a) { a["slug"] }, "NAME" => ->(a) { a["name"] }, "DESCRIPTION" => ->(a) { a["description"] } },
|
|
122
|
+
empty: "No agents.")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# An agent's versions, newest first, with what each changed.
|
|
127
|
+
def agents_versions(argv)
|
|
128
|
+
arguments = {}
|
|
129
|
+
argv = parse(argv, "agents versions AGENT [--limit N]") do |p|
|
|
130
|
+
p.on("--limit N", "At most N versions (max 50)") { |v| arguments[:limit] = positive_integer(v, "--limit") }
|
|
131
|
+
end
|
|
132
|
+
return EXIT_SUCCESS unless argv
|
|
133
|
+
|
|
134
|
+
arguments[:agent] = require_argument(argv, "AGENT (a slug or id)", "agents versions AGENT")
|
|
135
|
+
payload = client.tool("agents_versions", arguments)
|
|
136
|
+
emit(payload) do
|
|
137
|
+
agent = payload.fetch("agent", {})
|
|
138
|
+
@out.puts "#{agent["name"]} (#{agent["slug"]}): #{agent["version_count"]} versions"
|
|
139
|
+
table(payload.fetch("versions", []), {
|
|
140
|
+
"VERSION" => ->(v) { v["version_number"] },
|
|
141
|
+
"CREATED" => ->(v) { v["created_at"] },
|
|
142
|
+
"BY" => ->(v) { v["created_by"] },
|
|
143
|
+
"RELEASE" => ->(v) { v["release"] ? (v["revision"] || v["release_digest"].to_s[0, 12]) : nil },
|
|
144
|
+
"SUMMARY" => ->(v) { v["change_summary"] },
|
|
145
|
+
"CHANGED" => ->(v) { v["diff"].is_a?(Hash) ? v["diff"].keys.join(", ") : nil }
|
|
146
|
+
}, empty: "No versions.")
|
|
147
|
+
@out.puts "(more versions; raise --limit)" if payload["truncated"]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
include ResourceCommands
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
class CLI
|
|
5
|
+
# login, logout and status: the profile a command runs as, and whether
|
|
6
|
+
# the dashboard accepts it.
|
|
7
|
+
module SessionCommands
|
|
8
|
+
MCP_PROTOCOL_VERSION = "2025-03-26"
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Checks the key against the dashboard before storing it, unless
|
|
13
|
+
# --no-verify. The key comes from --key, ACTIVEAGENT_API_KEY, or a
|
|
14
|
+
# prompt (echo off on a terminal), so it need not sit in shell history.
|
|
15
|
+
def login(argv)
|
|
16
|
+
values = {}
|
|
17
|
+
verify = true
|
|
18
|
+
argv = parse(argv, "login --url URL [--key KEY] [--ingest-key KEY] [--profile NAME]") do |p|
|
|
19
|
+
p.on("--key KEY", "A dashboard API key (Settings -> API Keys); prompted for when omitted") { |v| values["api_key"] = v }
|
|
20
|
+
p.on("--ingest-key KEY", "The mount's ingest key, for evals import/report, when it differs from --key") { |v| values["ingest_key"] = v }
|
|
21
|
+
p.on("--[no-]verify", "Check the key against the dashboard first (default: on)") { |v| verify = v }
|
|
22
|
+
end
|
|
23
|
+
return EXIT_SUCCESS unless argv
|
|
24
|
+
raise Error.new("unexpected arguments: #{argv.join(" ")}", exit_code: EXIT_USAGE) unless argv.empty?
|
|
25
|
+
|
|
26
|
+
stored = @credentials[profile_name]
|
|
27
|
+
url = @options[:url] || env_value("ACTIVEAGENT_URL") || stored["url"]
|
|
28
|
+
raise Error.new("missing --url, the dashboard's mount (e.g. https://ops.example.com/activeagents)", exit_code: EXIT_USAGE) if url.to_s.empty?
|
|
29
|
+
|
|
30
|
+
values["url"] = Client.parse_url(url).to_s
|
|
31
|
+
values["api_key"] ||= env_value("ACTIVEAGENT_API_KEY") || prompt_for_key
|
|
32
|
+
values["ingest_key"] ||= env_value("ACTIVEAGENT_INGEST_KEY") || stored["ingest_key"]
|
|
33
|
+
raise Error.new("an API key is required", exit_code: EXIT_USAGE) if values["api_key"].to_s.strip.empty?
|
|
34
|
+
values["api_key"] = values["api_key"].strip
|
|
35
|
+
|
|
36
|
+
server = verify ? mcp_initialize(Client.new(url: values["url"], api_key: values["api_key"])) : nil
|
|
37
|
+
@credentials.save(profile_name, values)
|
|
38
|
+
|
|
39
|
+
emit(profile: profile_name, url: values["url"], verified: verify, server: server, credentials: @credentials.path) do
|
|
40
|
+
@out.puts "Logged in to #{values["url"]} as profile '#{profile_name}'#{" (not verified)" unless verify}."
|
|
41
|
+
@out.puts "Saved to #{@credentials.path}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def logout(argv)
|
|
46
|
+
argv = parse(argv, "logout [--profile NAME]")
|
|
47
|
+
return EXIT_SUCCESS unless argv
|
|
48
|
+
|
|
49
|
+
removed = @credentials.delete(profile_name)
|
|
50
|
+
emit(profile: profile_name, removed: removed) do
|
|
51
|
+
@out.puts(removed ? "Removed profile '#{profile_name}'." : "No profile '#{profile_name}' was stored.")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Two checks, one per key: the MCP facade's initialize with the API key,
|
|
56
|
+
# and Publisher#verify! against the report collector with the ingest
|
|
57
|
+
# key. Exits EXIT_FAILURE when either fails.
|
|
58
|
+
def status(argv)
|
|
59
|
+
argv = parse(argv, "status [--profile NAME]")
|
|
60
|
+
return EXIT_SUCCESS unless argv
|
|
61
|
+
|
|
62
|
+
client
|
|
63
|
+
checks = {
|
|
64
|
+
"dashboard" => check { { "server" => mcp_initialize(client) } },
|
|
65
|
+
"collector" => check { client.publisher.verify! && { "endpoint" => client.reports_endpoint } }
|
|
66
|
+
}
|
|
67
|
+
ok = checks.values.all? { |result| result["ok"] }
|
|
68
|
+
|
|
69
|
+
emit(profile: profile_name, url: client.url, ok: ok, checks: checks) do
|
|
70
|
+
@out.puts "Profile: #{profile_name}"
|
|
71
|
+
@out.puts "Dashboard: #{client.url}"
|
|
72
|
+
@out.puts "API key: #{checks["dashboard"]["ok"] ? "accepted (#{client.mcp_endpoint})" : "FAILED: #{checks["dashboard"]["error"]}"}"
|
|
73
|
+
@out.puts "Collector: #{checks["collector"]["ok"] ? "accepted (#{client.reports_endpoint})" : "FAILED: #{checks["collector"]["error"]}"}"
|
|
74
|
+
end
|
|
75
|
+
ok ? EXIT_SUCCESS : EXIT_FAILURE
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def check
|
|
79
|
+
{ "ok" => true }.merge(yield || {})
|
|
80
|
+
rescue Error, ActiveAgent::Evals::Publisher::Error => e
|
|
81
|
+
{ "ok" => false, "error" => e.message }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The facade's serverInfo, which an accepted key gets back.
|
|
85
|
+
def mcp_initialize(client)
|
|
86
|
+
result = client.mcp("initialize", protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {},
|
|
87
|
+
clientInfo: { name: "activeagent-cli", version: self.class.version })
|
|
88
|
+
result.is_a?(Hash) ? result["serverInfo"] : nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def prompt_for_key
|
|
92
|
+
if @input.respond_to?(:tty?) && @input.tty?
|
|
93
|
+
require "io/console"
|
|
94
|
+
@err.print "Dashboard API key: "
|
|
95
|
+
key = @input.noecho(&:gets)
|
|
96
|
+
@err.puts
|
|
97
|
+
key
|
|
98
|
+
else
|
|
99
|
+
@input.gets
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
include SessionCommands
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
class CLI
|
|
5
|
+
# `activeagent traces ...`. list and get read through the MCP facade's
|
|
6
|
+
# trace tools; export reads one trace whole as the payload trace ingest
|
|
7
|
+
# takes (traces_export), and import posts such a payload to
|
|
8
|
+
# <mount>/api/traces with the ingest key, so a trace moves between
|
|
9
|
+
# dashboards the way a run report does with `evals report` and `evals
|
|
10
|
+
# import`.
|
|
11
|
+
module TraceCommands
|
|
12
|
+
DURATION = /\A(\d+)([smhd]?)\z/
|
|
13
|
+
DURATION_MINUTES = { "" => 1, "m" => 1, "h" => 60, "d" => 24 * 60 }.freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def traces_list(argv)
|
|
18
|
+
arguments = {}
|
|
19
|
+
argv = parse(argv, "traces list [--agent NAME] [--since 24h] [--status error|ok] [--limit N]") do |p|
|
|
20
|
+
p.on("--agent AGENT", "An agent class name or a dashboard agent's slug") { |v| arguments[:agent] = v }
|
|
21
|
+
p.on("--since DURATION", "How far back: minutes, or 30m, 6h, 7d (default 24h)") { |v| arguments[:since_minutes] = since_minutes(v) }
|
|
22
|
+
p.on("--status STATUS", %w[error ok], "Only failed (error) or successful (ok) traces") { |v| arguments[:status] = v }
|
|
23
|
+
p.on("--service NAME", "Only this reporting service") { |v| arguments[:service] = v }
|
|
24
|
+
p.on("--limit N", "At most N traces (max 100)") { |v| arguments[:limit] = positive_integer(v, "--limit") }
|
|
25
|
+
end
|
|
26
|
+
return EXIT_SUCCESS unless argv
|
|
27
|
+
raise Error.new("unexpected arguments: #{argv.join(" ")}", exit_code: EXIT_USAGE) unless argv.empty?
|
|
28
|
+
|
|
29
|
+
payload = client.tool("traces_search", arguments)
|
|
30
|
+
traces = payload.fetch("traces", [])
|
|
31
|
+
emit(payload) do
|
|
32
|
+
table(traces, {
|
|
33
|
+
"TRACE" => ->(t) { t["short_id"] },
|
|
34
|
+
"TIME" => ->(t) { t["timestamp"] },
|
|
35
|
+
"AGENT" => ->(t) { [ t["agent"], t["action"] ].compact.join("#") },
|
|
36
|
+
"MODEL" => ->(t) { t["model"] },
|
|
37
|
+
"STATUS" => ->(t) { t["status"] },
|
|
38
|
+
"MS" => ->(t) { t["duration_ms"] },
|
|
39
|
+
"TOKENS" => ->(t) { t.dig("tokens", "total") }
|
|
40
|
+
}, empty: "No traces in the last #{payload["window_minutes"]} minutes.")
|
|
41
|
+
@out.puts "(more traces match; raise --limit or narrow the filters)" if payload["truncated"]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One trace as the dashboard's trace page shows it: its spans, tool
|
|
46
|
+
# calls and errors, with long values cut (traces_get).
|
|
47
|
+
def traces_get(argv)
|
|
48
|
+
argv = parse(argv, "traces get TRACE")
|
|
49
|
+
return EXIT_SUCCESS unless argv
|
|
50
|
+
|
|
51
|
+
trace_id = require_argument(argv, "TRACE (a trace id, or its first 8 characters)", "traces get TRACE")
|
|
52
|
+
payload = client.tool("traces_get", trace_id: trace_id)
|
|
53
|
+
emit(payload) do
|
|
54
|
+
@out.puts "Trace #{payload["trace_id"]}: #{[ payload["agent"], payload["action"] ].compact.join("#")} " \
|
|
55
|
+
"on #{payload["model"] || "-"} (#{payload["status"]}, #{payload["duration_ms"]} ms, " \
|
|
56
|
+
"#{payload.dig("tokens", "total")} tokens)"
|
|
57
|
+
@out.puts "Error: #{payload["error"]}" if payload["error"]
|
|
58
|
+
table(Array(payload["spans"]), {
|
|
59
|
+
"SPAN" => ->(s) { s["span_id"] },
|
|
60
|
+
"TYPE" => ->(s) { s["type"] },
|
|
61
|
+
"NAME" => ->(s) { s["name"] },
|
|
62
|
+
"START" => ->(s) { s["start"] },
|
|
63
|
+
"MS" => ->(s) { s["duration"] },
|
|
64
|
+
"STATUS" => ->(s) { s["status"] }
|
|
65
|
+
}, empty: "No spans.")
|
|
66
|
+
omitted = payload["spans_omitted"].to_i
|
|
67
|
+
@out.puts "(#{omitted} more spans; `activeagent traces export #{payload["trace_id"]}` has them all)" if omitted.positive?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Writes the trace, whole, as the payload POST <mount>/api/traces
|
|
72
|
+
# ingests ({ traces: [trace], sdk }), which `traces import` sends.
|
|
73
|
+
def traces_export(argv)
|
|
74
|
+
output = nil
|
|
75
|
+
argv = parse(argv, "traces export TRACE [-o FILE]") do |p|
|
|
76
|
+
p.on("-o", "--output FILE", "Write to FILE instead of standard output") { |v| output = v }
|
|
77
|
+
end
|
|
78
|
+
return EXIT_SUCCESS unless argv
|
|
79
|
+
|
|
80
|
+
trace_id = require_argument(argv, "TRACE (a trace id, or its first 8 characters)", "traces export TRACE")
|
|
81
|
+
payload = client.tool("traces_export", trace_id: trace_id)
|
|
82
|
+
write_output(JSON.pretty_generate(payload), output) do
|
|
83
|
+
"Wrote trace #{payload.dig("traces", 0, "trace_id")} to #{output}."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Posts a saved trace payload to the dashboard's trace ingest with the
|
|
88
|
+
# ingest key. FILE is what `traces export` writes, or one trace object,
|
|
89
|
+
# or the batch a reporter would send. A trace_id the dashboard already
|
|
90
|
+
# holds is skipped there, so importing the same file twice stores
|
|
91
|
+
# nothing more.
|
|
92
|
+
def traces_import(argv)
|
|
93
|
+
argv = parse(argv, "traces import FILE")
|
|
94
|
+
return EXIT_SUCCESS unless argv
|
|
95
|
+
|
|
96
|
+
path = require_argument(argv, "FILE", "traces import FILE")
|
|
97
|
+
document = read_json_file(path)
|
|
98
|
+
payload = document.key?("traces") ? document : { "traces" => [ document ] }
|
|
99
|
+
traces = payload["traces"]
|
|
100
|
+
unless traces.is_a?(Array) && traces.any? && traces.all? { |trace| trace.is_a?(Hash) && trace["trace_id"].is_a?(String) }
|
|
101
|
+
raise Error.new("#{path} holds no traces: it is the { \"traces\": [...] } payload `traces export` writes, " \
|
|
102
|
+
"or one trace with a trace_id", exit_code: EXIT_USAGE)
|
|
103
|
+
end
|
|
104
|
+
payload["sdk"] = { "name" => "activeagent-cli", "version" => self.class.version } unless payload["sdk"].is_a?(Hash) && !payload["sdk"].empty?
|
|
105
|
+
|
|
106
|
+
client.post("/api/traces", JSON.generate(payload))
|
|
107
|
+
trace_ids = traces.map { |trace| trace["trace_id"] }
|
|
108
|
+
emit(accepted: true, trace_ids: trace_ids, endpoint: client.traces_endpoint) do
|
|
109
|
+
@out.puts "Sent #{trace_ids.size} trace#{"s" unless trace_ids.size == 1} to #{client.traces_endpoint}: #{trace_ids.join(", ")}."
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Minutes in a duration such as 90, 30m, 6h or 7d; seconds round up
|
|
114
|
+
# to a whole minute.
|
|
115
|
+
def since_minutes(value)
|
|
116
|
+
match = DURATION.match(value.to_s.strip.downcase)
|
|
117
|
+
raise Error.new("--since must be minutes or a duration such as 30m, 6h or 7d", exit_code: EXIT_USAGE) unless match
|
|
118
|
+
|
|
119
|
+
amount = match[1].to_i
|
|
120
|
+
minutes = match[2] == "s" ? (amount / 60.0).ceil : amount * DURATION_MINUTES.fetch(match[2])
|
|
121
|
+
raise Error.new("--since must be at least a minute", exit_code: EXIT_USAGE) unless minutes.positive?
|
|
122
|
+
|
|
123
|
+
minutes
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
include TraceCommands
|
|
128
|
+
end
|
|
129
|
+
end
|