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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21ef2f8df6b6cb8ff4d37cfa6586cad1a993714a0ca70a39c3354451c131680a
|
|
4
|
+
data.tar.gz: aa2b6cc445855ff76b6a22a10373887257be2e3fcfb9932360bace049b8ad1ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1650882a747d581893f75316cdd5731c4b67cb8596cc6d359b4ae9d5b7dd49daf2bfa255089ebd72eadb96064a5bb16fccc07c8613e92256f08f4f9586e91530
|
|
7
|
+
data.tar.gz: 0647b570ee151fd2fa231f39b9db0a5545ce5c8b3a8a23b33d4c08c25dc14fa17e9edb3c9a5718083c89bb2d4948260aa082be4de54edc91ddac76bb0680551b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,171 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.9.5] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
The `activeagent` command line: the gem's third surface beside the dashboard
|
|
11
|
+
and its API, working with a dashboard over HTTP from a terminal or a CI job,
|
|
12
|
+
and the facade tools it needed. A run, a scenario catalog and a trace each
|
|
13
|
+
move between installs as one file. The framework and the dashboard engine
|
|
14
|
+
move to 1.9.5 together.
|
|
15
|
+
|
|
16
|
+
Upgrading: `rails generate action_agent:install` and `rails db:migrate` add
|
|
17
|
+
the GitHub App webhook deliveries table (below). The CLI needs `actionagent`
|
|
18
|
+
1.9.5 on the dashboard for `catalogs export`, `catalogs import --preview`,
|
|
19
|
+
`agents versions` and `traces export` (the facade's new tools), and 1.9.3
|
|
20
|
+
for `evals report` (the report export); an older dashboard answers those
|
|
21
|
+
commands with exit code 5.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Evaluation runs published while they run** (`activeagent`, `actionagent`).
|
|
26
|
+
`ActiveAgent::Evals::Publisher#progress` sends the results so far as a
|
|
27
|
+
`"status": "running"` envelope, and the collector stores them as a running
|
|
28
|
+
run that the Evaluations page follows live; each later delivery under the
|
|
29
|
+
run_id replaces the results until `#call` completes the run. Only the first
|
|
30
|
+
delivery is admitted and counted. A progress delivery after completion
|
|
31
|
+
changes nothing. The collector lists `"progress"` in the `capabilities` of
|
|
32
|
+
its readiness answer, and `#progress` sends nothing to a collector that does
|
|
33
|
+
not (`#supports_progress?`), since an older one would store the partial
|
|
34
|
+
report as complete.
|
|
35
|
+
- **Imported evaluation runs keep their dates** (`actionagent`). The
|
|
36
|
+
collector dates a run by its report's `metadata.finished`, which an export
|
|
37
|
+
carries, so an exported run imported into another install keeps its place
|
|
38
|
+
in the run history instead of reading as just run. A report naming no
|
|
39
|
+
finish time is dated on arrival, and a malformed one is refused.
|
|
40
|
+
- **`activeagent` command line** (`activeagent`, `actionagent`; refs #619).
|
|
41
|
+
The gem ships an `activeagent` executable (stdlib `OptionParser` and
|
|
42
|
+
`Net::HTTP`, no new dependencies) that works with a dashboard over HTTP:
|
|
43
|
+
`login`/`logout` (named profiles in `~/.config/activeagent/credentials.yml`,
|
|
44
|
+
`0600`; `ACTIVEAGENT_URL`, `ACTIVEAGENT_API_KEY` and
|
|
45
|
+
`ACTIVEAGENT_INGEST_KEY` override them for CI), `status`,
|
|
46
|
+
`evals list|runs|run|report|import`, `catalogs list|import|export`
|
|
47
|
+
(`import --preview` shows what a document would change, `--mode merge`
|
|
48
|
+
keeps what it leaves out), `agents list|versions` and
|
|
49
|
+
`traces list|get|export|import`, with `--json` output and distinct exit
|
|
50
|
+
codes. `evals run --min-pass-rate 0.8` waits for the run and exits 4
|
|
51
|
+
below the threshold, for CI gating. Reads and runs go through the MCP
|
|
52
|
+
facade's dashboard tools with a dashboard API key; the imports and
|
|
53
|
+
`evals report` use the endpoints the framework's own reporters post to,
|
|
54
|
+
with the ingest key. A run, a catalog and a trace each move between
|
|
55
|
+
installs as one file: `evals report RUN --format json` writes the
|
|
56
|
+
version-1 envelope `evals import` posts (`--format html|md` the report
|
|
57
|
+
page or its Markdown, for a pull request comment), `catalogs export KEY`
|
|
58
|
+
the canonical document (YAML, or `--format json`) that `catalogs import`
|
|
59
|
+
reads, and `traces export TRACE` the payload `traces import` sends to
|
|
60
|
+
trace ingest. See `docs/framework/cli.md`.
|
|
61
|
+
|
|
62
|
+
- **Four more dashboard tools on the MCP facade** (`actionagent`), the
|
|
63
|
+
API-key routes the CLI needed: `catalogs_export` (a catalog's canonical
|
|
64
|
+
document as YAML or JSON, whole or one product or set), `catalogs_preview`
|
|
65
|
+
(what `catalogs_import` would change, writing nothing), `agents_versions`
|
|
66
|
+
(an agent's versions with their diffs, as `GET /api/agents/:id/versions`)
|
|
67
|
+
and `traces_export` (one trace, whole and untruncated, as the payload
|
|
68
|
+
`POST <mount>/api/traces` takes, so a trace moves between installs).
|
|
69
|
+
`catalogs_import` takes `mode: replace|merge` as the API does. Every run
|
|
70
|
+
summary (`GET /api/evaluations`, the facade's `evaluations_get`,
|
|
71
|
+
`evaluations_run` and `evaluation_runs_get`) now carries `run_id`, the
|
|
72
|
+
handle `GET <mount>/api/evaluation_reports/:run_id` takes.
|
|
73
|
+
|
|
74
|
+
- **GitHub App webhooks** (`actionagent`). `POST <mount>/api/github_app/webhooks`
|
|
75
|
+
receives the App's deliveries, verified with `X-Hub-Signature-256` under
|
|
76
|
+
`ActionAgent.github_app_webhook_secret` (falling back to
|
|
77
|
+
`GITHUB_APP_WEBHOOK_SECRET`; unset, the endpoint answers 404), and applies
|
|
78
|
+
them in `ActionAgent::GithubWebhookJob`: an uninstall marks the linked
|
|
79
|
+
installation removed, suspend and unsuspend set and clear `suspended_at`,
|
|
80
|
+
accepted permissions are recorded, a repository removed from the
|
|
81
|
+
installation leaves the owner's selection, and a revoked App authorization
|
|
82
|
+
deletes the user's stored connection when connections authorize through the
|
|
83
|
+
App. Each `X-GitHub-Delivery` is recorded for seven days, so a redelivery
|
|
84
|
+
is a no-op. Ships a new migration,
|
|
85
|
+
`create_active_agent_github_webhook_deliveries`; run
|
|
86
|
+
`rails generate action_agent:install` and `rails db:migrate` to add it.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- **Sandbox sign-in recognises Claude Code 2.1's authorization page**
|
|
91
|
+
(`actionagent`). "Sign in with your Claude subscription" on a local sandbox
|
|
92
|
+
stayed at "Preparing Claude's authorization page": the CLI now sends
|
|
93
|
+
sign-ins to `claude.com/cai/oauth/authorize` and prints the link as a
|
|
94
|
+
terminal hyperlink whose text repeats the URL, which the flow neither
|
|
95
|
+
accepted nor cut at the hyperlink's end. Both are read now, and
|
|
96
|
+
`claude.ai` and `platform.claude.com` still are.
|
|
97
|
+
- **Projects accept GitHub App installations** (`actionagent`). A dashboard
|
|
98
|
+
that runs only the GitHub App could link an installation but not create a
|
|
99
|
+
project ("GitHub is not connected"). The New Project checklist now counts a
|
|
100
|
+
usable installation as connected, `GET /api/projects/repositories` lists
|
|
101
|
+
the installations' repositories together with the OAuth connection's, and
|
|
102
|
+
the preflight, secret discovery and creation reach a repository through the
|
|
103
|
+
installation first, as its checkout does, with a token minted for reading
|
|
104
|
+
that one repository. Picking a repository the installation has not
|
|
105
|
+
selected selects it there. A removed or suspended installation answers
|
|
106
|
+
`installation_unavailable`.
|
|
107
|
+
- **Report a missing Members permission when linking an organization's
|
|
108
|
+
GitHub App installation** (`actionagent`). GitHub answers the membership
|
|
109
|
+
check with 403 while the App lacks the organization permission Members
|
|
110
|
+
(read); the callback now reports `members_permission` instead of
|
|
111
|
+
`not_admin`. The dashboard guide documents the permission, and that the
|
|
112
|
+
dashboard's callback URL must be the App's first.
|
|
113
|
+
|
|
114
|
+
- **Boot on Ruby 3.4 and 4.0 with bootsnap 1.24.6 or newer** (`actionagent`).
|
|
115
|
+
`ProviderKey#owner_record` unwrapped delegators with
|
|
116
|
+
`... while defined?(::Delegator) && ...`, which Prism miscompiles when the
|
|
117
|
+
source is compiled from a string ("argument stack underflow"); bootsnap
|
|
118
|
+
compiles files that way on those Rubies as its Ruby bug #22023 workaround,
|
|
119
|
+
so a host on bootsnap 1.26 (and local runs on Ruby 3.4.9 or 4.0.1) failed
|
|
120
|
+
to boot the engine. The `defined?` check now sits outside the loop, and a
|
|
121
|
+
test compiles every shipped Ruby file from a string.
|
|
122
|
+
|
|
123
|
+
## [1.9.4] - 2026-10-25
|
|
124
|
+
|
|
125
|
+
### Added
|
|
126
|
+
|
|
127
|
+
- **Host links in the dashboard's user menu** (`actionagent`).
|
|
128
|
+
`ActionAgent.user_menu_links` takes an Array of `{ label:, href: }` or a
|
|
129
|
+
Proc receiving the signed-in user and the current owner, and the header's
|
|
130
|
+
avatar menu lists them above "Sign out". A hosted platform points them at
|
|
131
|
+
its workspace, members and account settings pages, and shows its admin
|
|
132
|
+
pages to the users who may use them; a self-hosted install leaves it unset
|
|
133
|
+
and sees the menu it had. Malformed entries are dropped and a raising Proc
|
|
134
|
+
is logged, so the menu seam cannot take the dashboard down.
|
|
135
|
+
|
|
136
|
+
### Fixed
|
|
137
|
+
|
|
138
|
+
- **Keep the trace console usable offline** (`actionagent`). The console
|
|
139
|
+
ships its own inline styles and uses native details and ordinary links
|
|
140
|
+
instead of CDN-hosted Tailwind, Turbo and Stimulus. Automatic refresh is
|
|
141
|
+
opt-in (30 or 60 seconds) and uses the browser's refresh mechanism;
|
|
142
|
+
expanding a trace no longer depends on JavaScript or a host asset pipeline.
|
|
143
|
+
- **Discover and call generated agent previews** (`activeagent`). TestUnit
|
|
144
|
+
writes previews under `test/agents/previews`, with current URLs and query
|
|
145
|
+
parameters passed to the agent. Discovery checks for newly added files,
|
|
146
|
+
and the default image interceptor accepts generation objects.
|
|
147
|
+
|
|
148
|
+
- **Restore Rails browser previews** (`activeagent`). `/rails/agents` lists
|
|
149
|
+
previews and renders their prompt requests without executing a provider.
|
|
150
|
+
Namespaced actions and Markdown downloads work, unknown actions return
|
|
151
|
+
404, and all endpoints enforce Rails' local-request restriction. Preview
|
|
152
|
+
directories participate in development reloading, including configured
|
|
153
|
+
paths and the legacy `test/docs/previews` directory. The dashboard gem is
|
|
154
|
+
not needed; the routes remain disabled outside development by default.
|
|
155
|
+
|
|
156
|
+
- **Generate runnable agent tests** (`activeagent`). TestUnit agent tests
|
|
157
|
+
inherit from `ActiveSupport::TestCase` instead of the nonexistent
|
|
158
|
+
`ActiveAgent::TestCase`. Plain and namespaced agents, including agents
|
|
159
|
+
without actions, can load in a Rails host without calling a provider.
|
|
160
|
+
|
|
161
|
+
### Changed
|
|
162
|
+
|
|
163
|
+
- **One badge for every status and message role in the dashboard**
|
|
164
|
+
(`actionagent`). The agent cards' status pill and provider chip, the run
|
|
165
|
+
and sandbox session status pills (Agent Interactions, the sandbox runner)
|
|
166
|
+
and the message role pills in conversation streams all render through the
|
|
167
|
+
shared `Badge` (soft tint, strong mono text, radius 4) with one status
|
|
168
|
+
tone map (`statusTone`) and one role map (`roleBadge`), so an `active`
|
|
169
|
+
agent, a `complete` run and an `Assistant` turn look the same wherever
|
|
170
|
+
they appear and follow the dark theme, which the cards' Tailwind pills
|
|
171
|
+
did not. Two tones join the tokens for the system and developer roles.
|
|
172
|
+
|
|
8
173
|
## [1.9.3] - 2026-10-21
|
|
9
174
|
|
|
10
175
|
Releases `activeagent` and `actionagent` 1.9.3, with the matching browser
|
data/README.md
CHANGED
|
@@ -181,7 +181,12 @@ telemetry:
|
|
|
181
181
|
The generator mounts the engine at `/activeagents` — open it and every
|
|
182
182
|
generation appears as a trace. See
|
|
183
183
|
[docs/framework/dashboard.md](docs/framework/dashboard.md) for
|
|
184
|
-
authentication, remote ingestion, and multi-tenant mode. The
|
|
184
|
+
authentication, remote ingestion, and multi-tenant mode. The gem's
|
|
185
|
+
`activeagent` command line works with that dashboard from a terminal or a
|
|
186
|
+
CI job — `activeagent evals run "Support orders" --min-pass-rate 0.8` fails
|
|
187
|
+
a build below the threshold, and runs, scenario catalogs and traces move
|
|
188
|
+
between installs as JSON files; see
|
|
189
|
+
[docs/framework/cli.md](docs/framework/cli.md). The hosted
|
|
185
190
|
platform at [activeagents.ai](https://activeagents.ai) runs this same
|
|
186
191
|
engine multi-tenant, adding what a hosted product has to have — accounts,
|
|
187
192
|
plans, billing, quotas and managed sandboxes; every workspace starts with
|
|
@@ -198,6 +203,7 @@ a free low-volume trial.
|
|
|
198
203
|
- **Agent-as-Tool Delegation**: Hand work to sub-agents with declared schemas, cost/latency budgets, and swappable backends
|
|
199
204
|
- **Context Management**: Maintain conversation history across interactions
|
|
200
205
|
- **Structured Output**: Define JSON schemas for predictable responses
|
|
206
|
+
- **Command Line**: `activeagent` runs evaluations and gates CI on a pass rate from a terminal, and moves runs, scenario catalogs and traces between dashboards as JSON
|
|
201
207
|
|
|
202
208
|
## Examples
|
|
203
209
|
|
data/exe/activeagent
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
require_relative "../evals/publisher"
|
|
9
|
+
|
|
10
|
+
module ActiveAgent
|
|
11
|
+
class CLI
|
|
12
|
+
# HTTP to one dashboard mount, with the conventions of
|
|
13
|
+
# ActiveAgent::Evals::Publisher: blocking, bounded timeouts, no redirects
|
|
14
|
+
# followed with a bearer key, and errors that never quote a response body
|
|
15
|
+
# beyond its sanitized +error+ string, with the keys filtered out.
|
|
16
|
+
#
|
|
17
|
+
# The dashboard authenticates two kinds of key, and this client sends
|
|
18
|
+
# each where it belongs:
|
|
19
|
+
#
|
|
20
|
+
# - +api_key+, a dashboard API key (Settings -> API Keys, "aa_..."), for
|
|
21
|
+
# the MCP facade at <mount>/mcp, whose dashboard tools back most
|
|
22
|
+
# commands.
|
|
23
|
+
# - +ingest_key+, the install's ingest key (ActionAgent.ingest_api_key,
|
|
24
|
+
# or the tenant's telemetry key), for the endpoints the framework's
|
|
25
|
+
# reporters post to: the report collector at
|
|
26
|
+
# <mount>/api/evaluation_reports and trace ingest at <mount>/api/traces.
|
|
27
|
+
# Defaults to +api_key+.
|
|
28
|
+
class Client
|
|
29
|
+
DETAIL_LIMIT = 200
|
|
30
|
+
JSONRPC_INVALID_PARAMS = -32602
|
|
31
|
+
|
|
32
|
+
attr_reader :url
|
|
33
|
+
|
|
34
|
+
def initialize(url:, api_key: nil, ingest_key: nil, timeout: 30, open_timeout: 10)
|
|
35
|
+
@uri = self.class.parse_url(url)
|
|
36
|
+
@url = @uri.to_s
|
|
37
|
+
@api_key = api_key.to_s.strip
|
|
38
|
+
@ingest_key = ingest_key.to_s.strip.empty? ? @api_key : ingest_key.to_s.strip
|
|
39
|
+
@timeout = timeout
|
|
40
|
+
@open_timeout = open_timeout
|
|
41
|
+
@request_id = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The dashboard mount as a URI, without a trailing slash. HTTPS is
|
|
45
|
+
# required except on loopback hosts, as for the publisher.
|
|
46
|
+
def self.parse_url(url)
|
|
47
|
+
uri = URI.parse(url.to_s.strip.sub(%r{/+\z}, ""))
|
|
48
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
|
|
49
|
+
raise Error.new("The dashboard URL must be an HTTP(S) URL without credentials, query or fragment, " \
|
|
50
|
+
"such as https://ops.example.com/activeagents", exit_code: EXIT_USAGE)
|
|
51
|
+
end
|
|
52
|
+
unless uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
|
|
53
|
+
raise Error.new("The dashboard URL requires HTTPS except on loopback hosts", exit_code: EXIT_USAGE)
|
|
54
|
+
end
|
|
55
|
+
uri
|
|
56
|
+
rescue URI::InvalidURIError
|
|
57
|
+
raise Error.new("The dashboard URL is not a valid URL", exit_code: EXIT_USAGE)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mcp_endpoint
|
|
61
|
+
"#{url}/mcp"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reports_endpoint
|
|
65
|
+
"#{url}/api/evaluation_reports"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def traces_endpoint
|
|
69
|
+
"#{url}/api/traces"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A Publisher for the mount's collector, authenticated with the ingest key.
|
|
73
|
+
def publisher
|
|
74
|
+
require_key!(@ingest_key, "an ingest key")
|
|
75
|
+
ActiveAgent::Evals::Publisher.new(api_key: @ingest_key, endpoint: reports_endpoint, timeout: @timeout, open_timeout: @open_timeout)
|
|
76
|
+
rescue ArgumentError => e
|
|
77
|
+
raise Error.new(e.message, exit_code: EXIT_CONFIG)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Sends one JSON-RPC request to the MCP facade and returns its result.
|
|
81
|
+
def mcp(method, params = {})
|
|
82
|
+
require_key!(@api_key, "a dashboard API key")
|
|
83
|
+
@request_id += 1
|
|
84
|
+
body = JSON.generate(jsonrpc: "2.0", id: @request_id, method: method, params: params)
|
|
85
|
+
response = exchange(:post, URI(mcp_endpoint), @api_key, body: body)
|
|
86
|
+
|
|
87
|
+
case response.code
|
|
88
|
+
when "200" then nil
|
|
89
|
+
when "401" then raise refused("the dashboard refused the API key; create one under Settings -> API Keys", response, @api_key, 401)
|
|
90
|
+
when "404" then raise refused("nothing at #{mcp_endpoint} answers MCP; check the dashboard URL (the engine's mount)", response, @api_key, 404)
|
|
91
|
+
else raise refused("the dashboard's MCP endpoint answered HTTP #{response.code}", response, @api_key, response.code.to_i)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
payload = parse_json(response.body)
|
|
95
|
+
if (error = payload["error"])
|
|
96
|
+
raise Error.new("#{method} failed: #{sanitize(error["message"], @api_key) || "error #{error["code"]}"}", status: error["code"])
|
|
97
|
+
end
|
|
98
|
+
payload["result"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Calls one of the dashboard's MCP tools and returns its structured
|
|
102
|
+
# result. A tool's own refusal (isError) raises with its message; a
|
|
103
|
+
# dashboard tool the facade does not list means the install turned
|
|
104
|
+
# them off (ActionAgent.mcp_dashboard_tools = false) or predates them.
|
|
105
|
+
def tool(name, arguments = {})
|
|
106
|
+
result = mcp("tools/call", name: name, arguments: arguments.compact)
|
|
107
|
+
structured = result.is_a?(Hash) ? result["structuredContent"] : nil
|
|
108
|
+
if result.is_a?(Hash) && result["isError"]
|
|
109
|
+
message = structured.is_a?(Hash) && structured["error"] || Array(result["content"]).filter_map { |part| part["text"] if part.is_a?(Hash) }.join(" ")
|
|
110
|
+
raise Error.new("#{name}: #{sanitize(message, @api_key) || "the tool refused the call"}")
|
|
111
|
+
end
|
|
112
|
+
structured.is_a?(Hash) ? structured : {}
|
|
113
|
+
rescue Error => e
|
|
114
|
+
raise unless e.status == JSONRPC_INVALID_PARAMS && e.message.include?("Unknown tool")
|
|
115
|
+
|
|
116
|
+
raise Error.new("This dashboard does not offer the #{name} tool over MCP: it predates the dashboard tools, or " \
|
|
117
|
+
"the install sets ActionAgent.mcp_dashboard_tools = false", exit_code: EXIT_UNAVAILABLE)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Rails' own 404 body, from a dashboard whose routes have no such path.
|
|
121
|
+
GENERIC_NOT_FOUND = /\Anot found\z/i
|
|
122
|
+
|
|
123
|
+
# GETs +path+ under the mount with the ingest key and returns the
|
|
124
|
+
# response, raising on anything but 200. A 404 is one of two things:
|
|
125
|
+
# the endpoint's own refusal, a JSON error naming what is missing,
|
|
126
|
+
# raised as +missing+ with that detail; or nothing at the path at all
|
|
127
|
+
# (an older engine), raised as +unavailable+ with EXIT_UNAVAILABLE.
|
|
128
|
+
def get(path, accept:, query: nil, missing: nil, unavailable: nil)
|
|
129
|
+
require_key!(@ingest_key, "an ingest key")
|
|
130
|
+
uri = URI("#{url}#{path}")
|
|
131
|
+
uri.query = query if query
|
|
132
|
+
response = exchange(:get, uri, @ingest_key, accept: accept)
|
|
133
|
+
return response if response.code == "200"
|
|
134
|
+
|
|
135
|
+
case response.code
|
|
136
|
+
when "404"
|
|
137
|
+
detail = error_detail(response, @ingest_key)
|
|
138
|
+
raise Error.new("#{missing} (#{detail})", status: 404) if missing && detail && !detail.match?(GENERIC_NOT_FOUND)
|
|
139
|
+
|
|
140
|
+
raise Error.new(unavailable || "Nothing at #{path} answers; check the dashboard URL", status: 404, exit_code: EXIT_UNAVAILABLE)
|
|
141
|
+
when "401"
|
|
142
|
+
raise refused("the dashboard refused the ingest key; use the mount's ingest_api_key (--ingest-key)", response, @ingest_key, 401)
|
|
143
|
+
else
|
|
144
|
+
raise refused("GET #{path} answered HTTP #{response.code}", response, @ingest_key, response.code.to_i)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# POSTs +body+, a JSON string, to +path+ under the mount with the
|
|
149
|
+
# ingest key and returns the response, raising on anything but a 2xx.
|
|
150
|
+
def post(path, body)
|
|
151
|
+
require_key!(@ingest_key, "an ingest key")
|
|
152
|
+
response = exchange(:post, URI("#{url}#{path}"), @ingest_key, body: body)
|
|
153
|
+
return response if response.code.start_with?("2")
|
|
154
|
+
|
|
155
|
+
if response.code == "401"
|
|
156
|
+
raise refused("the dashboard refused the ingest key; use the mount's ingest_api_key (--ingest-key)", response, @ingest_key, 401)
|
|
157
|
+
end
|
|
158
|
+
raise refused("POST #{path} answered HTTP #{response.code}", response, @ingest_key, response.code.to_i)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def require_key!(key, label)
|
|
164
|
+
return unless key.empty?
|
|
165
|
+
|
|
166
|
+
raise Error.new("No #{label}: run `activeagent login`, or set ACTIVEAGENT_API_KEY", exit_code: EXIT_CONFIG)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def exchange(verb, uri, key, body: nil, accept: "application/json")
|
|
170
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
171
|
+
http.use_ssl = uri.scheme == "https"
|
|
172
|
+
http.open_timeout = @open_timeout
|
|
173
|
+
http.read_timeout = @timeout
|
|
174
|
+
http.write_timeout = @timeout
|
|
175
|
+
request = (verb == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri.request_uri)
|
|
176
|
+
request["Authorization"] = "Bearer #{key}"
|
|
177
|
+
request["Accept"] = accept
|
|
178
|
+
request["User-Agent"] = "activeagent-cli/#{ActiveAgent::CLI.version}"
|
|
179
|
+
if body
|
|
180
|
+
request["Content-Type"] = "application/json"
|
|
181
|
+
request.body = body
|
|
182
|
+
end
|
|
183
|
+
http.request(request)
|
|
184
|
+
rescue Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error => e
|
|
185
|
+
# These messages can quote the response's status line, headers or body.
|
|
186
|
+
raise Error.new("The dashboard returned a malformed response (#{e.class})"), cause: nil
|
|
187
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
|
|
188
|
+
raise Error.new("Could not reach #{uri.scheme}://#{uri.host} (#{e.class}); check the URL and that the dashboard is up")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def parse_json(body)
|
|
192
|
+
parsed = JSON.parse(body.to_s)
|
|
193
|
+
raise Error, "The dashboard returned JSON that is not an object" unless parsed.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
parsed
|
|
196
|
+
rescue JSON::ParserError
|
|
197
|
+
# The parser's message quotes the body.
|
|
198
|
+
raise Error.new("The dashboard returned invalid JSON"), cause: nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def refused(message, response, key, status, exit_code: EXIT_FAILURE)
|
|
202
|
+
detail = error_detail(response, key)
|
|
203
|
+
Error.new(detail ? "#{message} (#{detail})" : message, status: status, exit_code: exit_code)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The sanitized `error` string of a JSON error body, or nil.
|
|
207
|
+
def error_detail(response, key)
|
|
208
|
+
parsed = JSON.parse(response.body.to_s)
|
|
209
|
+
sanitize(parsed["error"], key) if parsed.is_a?(Hash)
|
|
210
|
+
rescue JSON::ParserError, EncodingError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Control characters and runs of whitespace become one space, the keys
|
|
215
|
+
# become [FILTERED], and the result is cut to DETAIL_LIMIT characters.
|
|
216
|
+
def sanitize(message, key)
|
|
217
|
+
return unless message.is_a?(String)
|
|
218
|
+
|
|
219
|
+
detail = message.scrub.gsub(/[[:space:]\p{C}]+/, " ").strip
|
|
220
|
+
[ key, @api_key, @ingest_key ].uniq.reject(&:empty?).each { |secret| detail = detail.gsub(secret, "[FILTERED]") }
|
|
221
|
+
return if detail.empty?
|
|
222
|
+
|
|
223
|
+
detail.length > DETAIL_LIMIT ? "#{detail[0, DETAIL_LIMIT - 1].rstrip}…" : detail
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module ActiveAgent
|
|
7
|
+
class CLI
|
|
8
|
+
# Named dashboard profiles in ~/.config/activeagent/credentials.yml
|
|
9
|
+
# ($XDG_CONFIG_HOME when set), written 0600 inside a 0700 directory:
|
|
10
|
+
#
|
|
11
|
+
# default:
|
|
12
|
+
# url: https://ops.example.com/activeagents
|
|
13
|
+
# api_key: aa_...
|
|
14
|
+
# ingest_key: ... # only when it differs from api_key
|
|
15
|
+
#
|
|
16
|
+
# A profile's values are what +login+ stored; the environment
|
|
17
|
+
# (ACTIVEAGENT_URL, ACTIVEAGENT_API_KEY, ACTIVEAGENT_INGEST_KEY) overrides
|
|
18
|
+
# them in CLI#profile, so CI never needs the file.
|
|
19
|
+
class Credentials
|
|
20
|
+
FIELDS = %w[url api_key ingest_key].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :path
|
|
23
|
+
|
|
24
|
+
def self.default_path(env = ENV)
|
|
25
|
+
base = env["XDG_CONFIG_HOME"].to_s.empty? ? File.join(Dir.home, ".config") : env["XDG_CONFIG_HOME"]
|
|
26
|
+
File.join(base, "activeagent", "credentials.yml")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(path)
|
|
30
|
+
@path = path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the profile's stored values, or an empty hash when the file or
|
|
34
|
+
# the profile does not exist.
|
|
35
|
+
def [](name)
|
|
36
|
+
profiles.fetch(name.to_s, {})
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def names
|
|
40
|
+
profiles.keys
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def save(name, values)
|
|
44
|
+
data = profiles.merge(name.to_s => values.transform_keys(&:to_s).slice(*FIELDS).compact)
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
46
|
+
# Written beside the target and renamed over it, so a reader never
|
|
47
|
+
# sees half a file, and created 0600 so the key is never readable by
|
|
48
|
+
# others, even for a moment.
|
|
49
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
50
|
+
File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |file| file.write(YAML.dump(data)) }
|
|
51
|
+
File.chmod(0o600, temp)
|
|
52
|
+
File.rename(temp, path)
|
|
53
|
+
ensure
|
|
54
|
+
FileUtils.rm_f(temp) if temp && File.exist?(temp)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def delete(name)
|
|
58
|
+
data = profiles
|
|
59
|
+
return false unless data.delete(name.to_s)
|
|
60
|
+
|
|
61
|
+
data.empty? ? File.delete(path) : File.open(path, "w", 0o600) { |file| file.write(YAML.dump(data)) }
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def profiles
|
|
68
|
+
return {} unless File.exist?(path)
|
|
69
|
+
|
|
70
|
+
data = YAML.safe_load_file(path) || {}
|
|
71
|
+
raise Error.new("#{path} is not a profile map; fix or remove it", exit_code: EXIT_CONFIG) unless data.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
data.select { |_, values| values.is_a?(Hash) }
|
|
74
|
+
rescue Psych::Exception => e
|
|
75
|
+
raise Error.new("#{path} is not valid YAML (#{e.class}); fix or remove it", exit_code: EXIT_CONFIG)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|