@mrrlin-dev/external-agents 0.1.0

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.
package/cli.js ADDED
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ // external-agents CLI — thin argv wrapper over the same primitives the MCP
3
+ // server exposes. Used by shell wrappers (kilo-executor.sh, consensus-reviewer.sh)
4
+ // that need to reach the registry from bash without speaking MCP JSON-RPC.
5
+ //
6
+ // Subcommands:
7
+ // pick [--tier T] [--n N] [--min-distinct-providers M] [--exclude ID,ID]
8
+ // → prints one agent id per line (up to N), or exits 3 if no candidates
9
+ // dispatch <agent-id> [--pro] "<prompt>"
10
+ // → runs the agent, prints stdout of the child, exits with:
11
+ // 0 success | 2 usage | 3 unknown agent | 4 quota exhausted
12
+ // 1 real error
13
+ // → prints a JSON-RPC-style summary trailer to stderr for callers that want it:
14
+ // {"outcome":..., "exit_code":..., "duration_ms":..., "workdir":...}
15
+ // status [--json] → table of every registry entry with state (or JSON)
16
+ // probe <agent-id> → probes one agent, prints new state JSON
17
+ import { loadRegistry } from "./lib/registry.js";
18
+ import { readState, writeState, probeInstalled } from "./lib/state.js";
19
+ import { runAny, resolveEscalation, parseExhaustionSignal, getStats } from "./lib/dispatch.js";
20
+ import { pickAgents } from "./lib/pick.js";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ // bootEnv duplicated from server.js so CLI + server load the same env context.
26
+ function bootEnv() {
27
+ try {
28
+ const kiloAuthPath = path.join(os.homedir(), ".local/share/kilo/auth.json");
29
+ if (fs.existsSync(kiloAuthPath)) {
30
+ const a = JSON.parse(fs.readFileSync(kiloAuthPath, "utf-8"));
31
+ if (!process.env.DEEPSEEK_API_KEY && a.deepseek?.key) process.env.DEEPSEEK_API_KEY = a.deepseek.key;
32
+ }
33
+ const llmKeysPath = path.join(os.homedir(), "Library/Application Support/io.datasette.llm/keys.json");
34
+ if (!process.env.GEMINI_API_KEY || !process.env.GEMINI_API_KEY.startsWith("AIza")) {
35
+ if (fs.existsSync(llmKeysPath)) {
36
+ const k = JSON.parse(fs.readFileSync(llmKeysPath, "utf-8"));
37
+ if (k.gemini) process.env.GEMINI_API_KEY = k.gemini;
38
+ }
39
+ }
40
+ } catch {}
41
+ }
42
+ bootEnv();
43
+
44
+ const REGISTRY_PATH = path.join(path.dirname(new URL(import.meta.url).pathname), "agents.yaml");
45
+ const REGISTRY = loadRegistry(REGISTRY_PATH);
46
+
47
+ // --- argv parsing helpers -----------------------------------------
48
+ function parseArgs(argv) {
49
+ const args = [];
50
+ const flags = {};
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a.startsWith("--")) {
54
+ const key = a.slice(2);
55
+ const nxt = argv[i + 1];
56
+ if (nxt !== undefined && !nxt.startsWith("--")) { flags[key] = nxt; i++; }
57
+ else flags[key] = true;
58
+ } else args.push(a);
59
+ }
60
+ return { args, flags };
61
+ }
62
+ function die(msg, code = 2) { console.error(msg); process.exit(code); }
63
+ function findAgent(id) { return REGISTRY.agents.find((a) => a.id === id); }
64
+
65
+ // --- subcommands --------------------------------------------------
66
+ function cmdPick(flags) {
67
+ const n = parseInt(flags.n || "1", 10);
68
+ const filter = {};
69
+ if (flags.tier) filter.tier = flags.tier;
70
+ if (flags.tags) filter.tags = String(flags.tags).split(",").filter(Boolean);
71
+ if (flags.exclude) filter.exclude_ids = String(flags.exclude).split(",").filter(Boolean);
72
+ if (flags.transport) filter.transport = flags.transport;
73
+ const picked = pickAgents(REGISTRY, readState(), {
74
+ n,
75
+ filter,
76
+ min_distinct_providers: flags["min-distinct-providers"] ? parseInt(flags["min-distinct-providers"], 10) : undefined,
77
+ });
78
+ if (picked.length === 0) process.exit(3);
79
+ for (const id of picked) console.log(id);
80
+ }
81
+
82
+ async function cmdDispatch(args, flags) {
83
+ const [agentId, ...promptParts] = args;
84
+ const prompt = promptParts.join(" ");
85
+ if (!agentId) die("usage: cli.js dispatch <agent-id> [--pro] \"<prompt>\"", 2);
86
+ if (!prompt) die("dispatch: missing prompt", 2);
87
+
88
+ const src = findAgent(agentId);
89
+ if (!src) die(`unknown agent: ${agentId}`, 3);
90
+
91
+ let entry = src;
92
+ let escalatedFrom;
93
+ if (flags.pro) {
94
+ const esc = resolveEscalation(REGISTRY, agentId);
95
+ if (!esc) {
96
+ console.error(JSON.stringify({ outcome: "no_escalation_candidate", requested: agentId }));
97
+ process.exit(4);
98
+ }
99
+ entry = esc;
100
+ escalatedFrom = agentId;
101
+ }
102
+
103
+ const cur = readState();
104
+ writeState({ [entry.id]: { ...(cur[entry.id] || {}), last_used_at: Math.floor(Date.now() / 1000) } });
105
+
106
+ const transport = flags.transport; // "generate_new" | "edit_exists" | undefined
107
+ const result = await runAny(entry, prompt, { transport });
108
+ const now = Math.floor(Date.now() / 1000);
109
+
110
+ let outcome;
111
+ let statePatch;
112
+ if (result.exitCode === 0) {
113
+ statePatch = { [entry.id]: { state: "healthy", checked: now, last_used_at: now } };
114
+ outcome = "success";
115
+ } else {
116
+ const sig = parseExhaustionSignal(result.stderr + "\n" + result.output);
117
+ if (sig.detected) {
118
+ const cooldown_until = sig.reset_at != null ? sig.reset_at : now + 3600;
119
+ statePatch = { [entry.id]: { state: "quota_exhausted", cooldown_until, source: sig.reset_at != null ? "error_body" : "fallback_ttl", checked: now } };
120
+ outcome = "quota_exhausted";
121
+ } else outcome = "error";
122
+ }
123
+ if (statePatch) writeState(statePatch);
124
+
125
+ process.stdout.write(result.output);
126
+ const trailer = { agent_id: entry.id, outcome, exit_code: result.exitCode, duration_ms: result.durationMs, workdir: result.workdir, files: result.files };
127
+ if (escalatedFrom) trailer.escalated_from = escalatedFrom;
128
+ console.error("__EXTERNAL_AGENTS_TRAILER__ " + JSON.stringify(trailer));
129
+
130
+ process.exit(outcome === "success" ? 0 : (outcome === "quota_exhausted" ? 4 : 1));
131
+ }
132
+
133
+ function cmdStatus(flags) {
134
+ const state = readState();
135
+ const rows = REGISTRY.agents.map((e) => ({ ...e, ...(state[e.id] || { state: "healthy" }) }));
136
+ if (flags.json) {
137
+ console.log(JSON.stringify(rows, null, 2));
138
+ return;
139
+ }
140
+ const w = 42;
141
+ console.log(`${"agent".padEnd(w)} ${"state".padEnd(18)} ${"tier".padEnd(7)} tags`);
142
+ console.log("-".repeat(96));
143
+ for (const r of rows) {
144
+ const tagsStr = (r.tags || []).join(",");
145
+ console.log(`${r.id.padEnd(w)} ${(r.state || "?").padEnd(18)} ${(r.tier || "-").padEnd(7)} ${tagsStr}`);
146
+ }
147
+ }
148
+
149
+ function cmdStats(flags) {
150
+ const s = getStats(flags.since);
151
+ if (flags.json) {
152
+ console.log(JSON.stringify(s, null, 2));
153
+ return;
154
+ }
155
+ console.log(`total dispatches: ${s.total}${s.span.first_ts ? ` (from ${new Date(s.span.first_ts*1000).toISOString()} to ${new Date(s.span.last_ts*1000).toISOString()})` : ""}`);
156
+ console.log();
157
+ console.log("by transport:");
158
+ for (const [t, v] of Object.entries(s.by_transport)) {
159
+ console.log(` ${t.padEnd(10)} count=${v.count} tokens_in=${v.tokens_in} tokens_out=${v.tokens_out}`);
160
+ }
161
+ console.log();
162
+ console.log("by agent:");
163
+ const rows = Object.entries(s.by_agent).sort((a,b) => b[1].count - a[1].count);
164
+ for (const [id, v] of rows) {
165
+ const okCount = v.outcomes.success || 0;
166
+ const successRate = v.count ? Math.round(100 * okCount / v.count) : 0;
167
+ console.log(` ${id.padEnd(40)} count=${v.count} success=${successRate}% avg_dur=${Math.round(v.duration_ms/(v.count||1))}ms tokens=${v.tokens_in}/${v.tokens_out}`);
168
+ }
169
+ }
170
+
171
+ function cmdProbe(args) {
172
+ const [agentId] = args;
173
+ if (!agentId) die("usage: cli.js probe <agent-id>", 2);
174
+ const entry = findAgent(agentId);
175
+ if (!entry) die(`unknown agent: ${agentId}`, 3);
176
+ const result = probeInstalled(entry);
177
+ const checked = Math.floor(Date.now() / 1000);
178
+ writeState({ [agentId]: { ...result, checked } });
179
+ console.log(JSON.stringify({ id: agentId, ...result, checked }));
180
+ }
181
+
182
+ // --- entrypoint ---------------------------------------------------
183
+ const [, , subcmd, ...rest] = process.argv;
184
+ const { args, flags } = parseArgs(rest);
185
+
186
+ switch (subcmd) {
187
+ case "pick": cmdPick(flags); break;
188
+ case "dispatch": cmdDispatch(args, flags); break;
189
+ case "status": cmdStatus(flags); break;
190
+ case "probe": cmdProbe(args); break;
191
+ case "stats": cmdStats(flags); break;
192
+ case "help":
193
+ case "--help":
194
+ case undefined:
195
+ console.error(`external-agents CLI — subcommands:
196
+ pick [--tier T] [--n N] [--min-distinct-providers M] [--exclude id,id] [--tags a,b] [--transport generate_new|edit_exists]
197
+ dispatch <agent-id> [--pro] [--transport generate_new|edit_exists] "<prompt>"
198
+ status [--json]
199
+ probe <agent-id>
200
+ stats [--since ISO] [--json]`);
201
+ process.exit(subcmd ? 0 : 2);
202
+ default: die(`unknown subcommand: ${subcmd}`, 2);
203
+ }
@@ -0,0 +1,139 @@
1
+ # Adding a Provider
2
+
3
+ This guide walks you through registering a new LLM provider or model in the `agents.yaml` registry for `@mrrlin-dev/external-agents`. Adding a model allows the local UI, MCP server, and CLI tools to discover it, track its health, and route agentic or generation tasks to it.
4
+
5
+ ## Prereq
6
+
7
+ Before starting, ensure your local environment is set up for the transport you plan to use:
8
+
9
+ - **For the `aider` transport**: This transport runs iterative, multi-file agentic loops. It requires the `aider` CLI to be globally available. Install it using `uv`:
10
+ ```bash
11
+ uv tool install aider-chat
12
+ ```
13
+ - **For the `generate` transport**: No external dependencies are needed. This transport runs direct, lightweight HTTPS requests to any OpenAI-compatible completions endpoint.
14
+
15
+ ## Step 1: Find the provider's endpoint / prefix
16
+
17
+ To configure the model, you need to identify its identifier and connection strings depending on the target transport:
18
+
19
+ - **For `aider`**: Aider utilizes LiteLLM under the hood. Locate your provider's prefix format in the [Aider LLM Documentation](https://aider.chat/docs/llms.html). For example, Groq models use the prefix `groq/`, while OpenRouter uses `openrouter/`.
20
+ - **For `generate`**: Locate the provider's OpenAI-compatible base URL in their developer docs. For example, DeepSeek uses `https://api.deepseek.com/v1/chat/completions`. Note down the exact model ID (e.g., `deepseek-chat`).
21
+
22
+ ## Step 2: Add a registry entry to agents.yaml
23
+
24
+ Open your `agents.yaml` configuration file and append your new agent entry.
25
+
26
+ ```yaml
27
+ - id: aider-groq-llama-3.3-70b
28
+ provider: groq
29
+ model: llama-3.3-70b-versatile
30
+ quota_scope: shared
31
+ tier: weak
32
+ tags: [quick, fast, free]
33
+ auth: "env:GROQ_API_KEY"
34
+ preference_order: 8
35
+ usage_url: "https://console.groq.com/settings/usage"
36
+ transports:
37
+ cli: "aider --model groq/llama-3.3-70b-versatile"
38
+
39
+ - id: gen-deepseek-chat
40
+ provider: deepseek
41
+ model: deepseek-chat
42
+ quota_scope: shared
43
+ tier: strong
44
+ tags: [cheap, code]
45
+ auth: "env:DEEPSEEK_API_KEY"
46
+ preference_order: 9
47
+ transports:
48
+ generate:
49
+ url: "https://api.deepseek.com/v1/chat/completions"
50
+ env: DEEPSEEK_API_KEY
51
+ model: deepseek-chat
52
+ ```
53
+
54
+ ### Registry Fields Definition
55
+
56
+ - **`id`**: Unique identifier for this specific provider-model-transport configuration.
57
+ - **`provider`**: The lowercase name of the hosting provider.
58
+ - **`model`**: The target model ID expected by the provider.
59
+ - **`quota_scope`**: Set to `shared` if the rate limits are shared across all models on that provider account, or `individual` if the model has its own dedicated limit bucket.
60
+ - **`tier`**: Either `strong` (complex reasoning, coding) or `weak` (fast, lightweight tasks).
61
+ - **`tags`**: Metadata array for filtering within the UI and MCP routers.
62
+ - **`auth`**: Specifies the environment variable containing the API key (e.g., `env:GROQ_API_KEY`). For custom or complex setups, you can override variables inline:
63
+ ```yaml
64
+ env:
65
+ OPENAI_API_KEY: "@file:~/.claude/state/zai.key"
66
+ ```
67
+ - **`preference_order`**: Integer representing the fallback priority. Higher numbers are tried first.
68
+ - **`transports`**:
69
+ - `cli`: Command string to launch the agentic loop via aider.
70
+ - `generate`: Mapping configuring direct HTTPS requests to an OpenAI-compatible endpoint.
71
+
72
+ ## Step 3: Set the env var
73
+
74
+ The agent needs the credentials declared in the `auth` or `transports.generate.env` fields. You can export these directly into your environment or apply them via the local UI's settings panel.
75
+
76
+ ```bash
77
+ export GROQ_API_KEY="gsk_y0urS3cr3tKeyH3r3..."
78
+ export DEEPSEEK_API_KEY="sk-d33ps33kKey..."
79
+ ```
80
+
81
+ ## Step 4: Verify
82
+
83
+ Validate that your new registry entry is parsed correctly and that the external-agents daemon can communicate with the provider's endpoint.
84
+
85
+ ```bash
86
+ external-agents probe aider-groq-llama-3.3-70b
87
+ ```
88
+
89
+ If successful, the console will print a confirmation showing `state:healthy` alongside latency statistics.
90
+
91
+ ## Step 5: Test dispatch
92
+
93
+ Run a direct generation test to confirm that the model returns coherent responses over your selected transport.
94
+
95
+ ```bash
96
+ external-agents dispatch gen-deepseek-chat "Reply OK if you can read this."
97
+ ```
98
+
99
+ The terminal should output the raw response text from the LLM.
100
+
101
+ ## Choosing between transports
102
+
103
+ | Feature | `aider` | `generate` |
104
+ | :--- | :--- | :--- |
105
+ | **Iteration** | Excellent (interactive, git-aware loops) | Limited (single shot) |
106
+ | **New-file creation** | Good | Excellent (clean write, low latency) |
107
+ | **Multi-file edits** | Supported out-of-the-box | Manual processing required |
108
+ | **Tool use** | Native via agent command line | No tool calling (pure text) |
109
+
110
+ Choose `aider` when you want an agent to interactively edit codebases, run tests, and fix bugs. Choose `generate` for fast, zero-dependency generation jobs where you simply need text or a single file generated from scratch without overhead.
111
+
112
+ ## Common gotchas
113
+
114
+ - **Model ID Prefixing**: When using `aider` (via LiteLLM), you often need prefixes like `groq/llama-3.3-70b-versatile` in the `cli` execution string. However, for the `generate` transport, you must use the raw model name expected by the OpenAI-compatible endpoint (e.g., `llama-3.3-70b-versatile`) in the `model` parameter, otherwise the API will return a 404.
115
+ - **Quota Scopes**: Setting `quota_scope: shared` tells the router to avoid hammering other models under the same provider if one model returns a `429 Too Many Requests` error. Make sure to set this correctly for providers like Groq or Anthropic where your tier's limits apply across the entire account.
116
+ - **Pricing Fields**: Pricing structures (`input_cost_per_m`, `output_cost_per_m`) are deferred to the v1-deferred schema implementation. Do not manually add price fields to your `agents.yaml` entry; they are fetched dynamically.
117
+ - **Aider's SEARCH/REPLACE limits**: The `aider` transport relies heavily on search-and-replace blocks. If you are generating a brand-new file from scratch, aider may occasionally fail if there is no pre-existing code to target. Use the `generate` transport to bootstrap empty files, then hand them off to `aider` for iterative edits.
118
+
119
+ ## When it's more than one entry
120
+
121
+ Certain providers require multiple entries within `agents.yaml` for what seems like a single model family.
122
+
123
+ For instance, Google Gemini models have distinct quota buckets for Flash vs. Pro tiers, as well as distinct endpoint characteristics. In these scenarios, declare them as individual agent blocks so the router can gracefully fall back from a rate-limited Pro endpoint to a highly-available Flash endpoint:
124
+
125
+ ```yaml
126
+ - id: gen-gemini-2.5-pro
127
+ provider: google
128
+ model: gemini-2.5-pro
129
+ quota_scope: individual
130
+ tier: strong
131
+ # ...
132
+
133
+ - id: gen-gemini-2.5-flash
134
+ provider: google
135
+ model: gemini-2.5-flash
136
+ quota_scope: individual
137
+ tier: weak
138
+ # ...
139
+ ```