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.
@@ -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