@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/LICENSE +21 -0
- package/README.md +237 -0
- package/agents.yaml +357 -0
- package/cli.js +203 -0
- package/docs/adding-a-provider.md +139 -0
- package/lib/dispatch.js +380 -0
- package/lib/pick.js +52 -0
- package/lib/registry.js +29 -0
- package/lib/state.js +113 -0
- package/package.json +58 -0
- package/server.js +384 -0
- package/ui.js +449 -0
package/server.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { loadRegistry } from "./lib/registry.js";
|
|
13
|
+
import { readState, writeState, probeInstalled } from "./lib/state.js";
|
|
14
|
+
import { runAny, parseExhaustionSignal, resolveEscalation, getStats } from "./lib/dispatch.js";
|
|
15
|
+
import { pickAgents } from "./lib/pick.js";
|
|
16
|
+
|
|
17
|
+
const REGISTRY = loadRegistry("./agents.yaml");
|
|
18
|
+
|
|
19
|
+
// Env-var boot injection. Look for credentials in known local stores and populate
|
|
20
|
+
// process.env for aider/other CLIs. Never overrides an already-set var.
|
|
21
|
+
// UI-set persisted keys — loaded FIRST (highest precedence) so a value the
|
|
22
|
+
// operator saved via /api/set_credential wins over whatever legacy stores
|
|
23
|
+
// contain. File format: one KEY=value per line, no quotes. Mode 0600.
|
|
24
|
+
const KEYS_FILE = path.join(os.homedir(), ".local/state/external-agents/keys.env");
|
|
25
|
+
|
|
26
|
+
function loadKeysFile() {
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(KEYS_FILE)) return {};
|
|
29
|
+
const raw = fs.readFileSync(KEYS_FILE, "utf-8");
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
32
|
+
const t = line.trim();
|
|
33
|
+
if (!t || t.startsWith("#")) continue;
|
|
34
|
+
const eq = t.indexOf("=");
|
|
35
|
+
if (eq < 0) continue;
|
|
36
|
+
const k = t.slice(0, eq).trim();
|
|
37
|
+
const v = t.slice(eq + 1);
|
|
38
|
+
if (k) out[k] = v;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.error(`external-agents-spike: WARN — keys.env unreadable: ${e.message}`);
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveKeysFile(kv) {
|
|
48
|
+
const dir = path.dirname(KEYS_FILE);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
const body = Object.entries(kv)
|
|
51
|
+
.filter(([k, v]) => k && typeof v === "string")
|
|
52
|
+
.map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
53
|
+
const tmp = KEYS_FILE + ".tmp." + process.pid + "." + Date.now();
|
|
54
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
55
|
+
fs.renameSync(tmp, KEYS_FILE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bootEnv() {
|
|
59
|
+
try {
|
|
60
|
+
// 1. UI-persisted keys (highest priority — operator explicitly set them)
|
|
61
|
+
const persisted = loadKeysFile();
|
|
62
|
+
for (const [k, v] of Object.entries(persisted)) {
|
|
63
|
+
if (!process.env[k]) process.env[k] = v;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Kilo auth store (legacy — DeepSeek key only; the Google proxy token
|
|
67
|
+
// in Kilo does NOT work against direct Google API)
|
|
68
|
+
const kiloAuthPath = path.join(os.homedir(), ".local/share/kilo/auth.json");
|
|
69
|
+
if (fs.existsSync(kiloAuthPath)) {
|
|
70
|
+
const kiloAuth = JSON.parse(fs.readFileSync(kiloAuthPath, "utf-8"));
|
|
71
|
+
if (!process.env.DEEPSEEK_API_KEY && kiloAuth.deepseek?.key) {
|
|
72
|
+
process.env.DEEPSEEK_API_KEY = kiloAuth.deepseek.key;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 3. llm-key store (AI Studio Gemini key, `AIza...` format)
|
|
76
|
+
const llmKeysPath = path.join(os.homedir(), "Library/Application Support/io.datasette.llm/keys.json");
|
|
77
|
+
if (!process.env.GEMINI_API_KEY || !process.env.GEMINI_API_KEY.startsWith("AIza")) {
|
|
78
|
+
if (fs.existsSync(llmKeysPath)) {
|
|
79
|
+
const llmKeys = JSON.parse(fs.readFileSync(llmKeysPath, "utf-8"));
|
|
80
|
+
if (llmKeys.gemini) process.env.GEMINI_API_KEY = llmKeys.gemini;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error(`external-agents-spike: WARN — bootEnv failed: ${e.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
bootEnv();
|
|
88
|
+
|
|
89
|
+
// Exposed for the set_credential MCP tool + /api/set_credential UI route.
|
|
90
|
+
export function persistCredential(envName, value) {
|
|
91
|
+
if (!envName || !/^[A-Z_][A-Z0-9_]*$/.test(envName)) throw new Error("invalid env var name");
|
|
92
|
+
const persisted = loadKeysFile();
|
|
93
|
+
persisted[envName] = value;
|
|
94
|
+
saveKeysFile(persisted);
|
|
95
|
+
// Also inject into the running process env so THIS session sees it immediately.
|
|
96
|
+
process.env[envName] = value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findAgent(id) {
|
|
100
|
+
return REGISTRY.agents.find((a) => a.id === id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const server = new Server(
|
|
104
|
+
{
|
|
105
|
+
name: "external-agents-spike",
|
|
106
|
+
version: "0.0.1",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
capabilities: {
|
|
110
|
+
tools: {},
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
116
|
+
return {
|
|
117
|
+
tools: [
|
|
118
|
+
{
|
|
119
|
+
name: "ping",
|
|
120
|
+
description: "Ping the server",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "list_agents",
|
|
128
|
+
description: "List configured agents merged with their current state",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "get_state",
|
|
136
|
+
description: "Return the current external-agents state file (per-agent healthy/not_installed/needs_auth/quota_exhausted/errored_transient with metadata)",
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "probe_agent",
|
|
144
|
+
description: "Probe a specific agent by id; runs an install-check and updates the state file. Returns the new state.",
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: { agent_id: { type: "string" } },
|
|
148
|
+
required: ["agent_id"],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "set_credential",
|
|
153
|
+
description: "Persist an API-key env variable so the next dispatch (and future sessions) see it. Writes to ~/.local/state/external-agents/keys.env (mode 0600).",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
env_name: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" },
|
|
158
|
+
value: { type: "string" },
|
|
159
|
+
},
|
|
160
|
+
required: ["env_name", "value"],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "pick_agents",
|
|
165
|
+
description: "Pick up to N distinct healthy candidates by round-robin (preference_order + last_used_at). Optional min_distinct_providers enforces cross-provider diversity.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: {
|
|
169
|
+
n: { type: "integer", minimum: 1 },
|
|
170
|
+
filter: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: {
|
|
173
|
+
tier: { type: "string" },
|
|
174
|
+
tags: { type: "array", items: { type: "string" } },
|
|
175
|
+
exclude_ids: { type: "array", items: { type: "string" } },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
min_distinct_providers: { type: "integer", minimum: 1 },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "dispatch",
|
|
184
|
+
description: "Run a specific agent by id with a prompt. transport ('generate' | 'cli') overrides the default (generate preferred when entry declares it). escalate_to_pro=true uses the same-provider strong-tier entry instead.",
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: {
|
|
188
|
+
agent_id: { type: "string" },
|
|
189
|
+
prompt: { type: "string" },
|
|
190
|
+
transport: { type: "string", enum: ["generate_new", "edit_exists"] },
|
|
191
|
+
escalate_to_pro: { type: "boolean" },
|
|
192
|
+
},
|
|
193
|
+
required: ["agent_id", "prompt"],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "get_stats",
|
|
198
|
+
description: "Aggregate dispatch telemetry from ~/.local/state/external-agents/dispatch-log.jsonl. Returns per-agent counts, tokens, outcomes; per-transport totals.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: { since: { type: "string", description: "ISO 8601 datetime; only rows with ts >= since included" } },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
209
|
+
const { name } = request.params;
|
|
210
|
+
|
|
211
|
+
if (name === "ping") {
|
|
212
|
+
return {
|
|
213
|
+
content: [
|
|
214
|
+
{
|
|
215
|
+
type: "text",
|
|
216
|
+
text: "pong from external-agents-spike v0.0.1",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (name === "list_agents") {
|
|
223
|
+
const state = readState();
|
|
224
|
+
const merged = REGISTRY.agents.map((entry) => ({
|
|
225
|
+
...entry,
|
|
226
|
+
...(state[entry.id] || { state: "healthy" }),
|
|
227
|
+
}));
|
|
228
|
+
return {
|
|
229
|
+
content: [
|
|
230
|
+
{ type: "text", text: JSON.stringify(merged) },
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (name === "get_state") {
|
|
236
|
+
return {
|
|
237
|
+
content: [
|
|
238
|
+
{ type: "text", text: JSON.stringify(readState()) },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (name === "set_credential") {
|
|
244
|
+
const { env_name, value } = request.params.arguments || {};
|
|
245
|
+
if (!env_name || !value) throw new Error("set_credential: env_name and value required");
|
|
246
|
+
persistCredential(env_name, value);
|
|
247
|
+
return {
|
|
248
|
+
content: [
|
|
249
|
+
{ type: "text", text: JSON.stringify({ ok: true, env_name, persisted_to: KEYS_FILE, chars: value.length }) },
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (name === "probe_agent") {
|
|
255
|
+
const id = request.params.arguments?.agent_id;
|
|
256
|
+
if (!id || typeof id !== "string") {
|
|
257
|
+
throw new Error("probe_agent: missing agent_id");
|
|
258
|
+
}
|
|
259
|
+
const entry = findAgent(id);
|
|
260
|
+
if (!entry) {
|
|
261
|
+
throw new Error(`unknown agent: ${id}`);
|
|
262
|
+
}
|
|
263
|
+
const result = probeInstalled(entry);
|
|
264
|
+
const checked = Math.floor(Date.now() / 1000);
|
|
265
|
+
writeState({ [id]: { ...result, checked } });
|
|
266
|
+
return {
|
|
267
|
+
content: [
|
|
268
|
+
{ type: "text", text: JSON.stringify({ id, ...result, checked }) },
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (name === "pick_agents") {
|
|
274
|
+
const args = request.params.arguments || {};
|
|
275
|
+
const picked = pickAgents(REGISTRY, readState(), {
|
|
276
|
+
n: args.n ?? 1,
|
|
277
|
+
filter: args.filter,
|
|
278
|
+
min_distinct_providers: args.min_distinct_providers,
|
|
279
|
+
});
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{ type: "text", text: JSON.stringify({ picked }) },
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (name === "get_stats") {
|
|
288
|
+
const { since } = request.params.arguments || {};
|
|
289
|
+
return {
|
|
290
|
+
content: [
|
|
291
|
+
{ type: "text", text: JSON.stringify(getStats(since)) },
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (name === "dispatch") {
|
|
297
|
+
const { agent_id, prompt, transport, escalate_to_pro } = request.params.arguments;
|
|
298
|
+
if (!agent_id || !prompt) {
|
|
299
|
+
throw new Error("dispatch: missing agent_id or prompt");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const sourceEntry = findAgent(agent_id);
|
|
303
|
+
if (!sourceEntry) {
|
|
304
|
+
throw new Error(`unknown agent: ${agent_id}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let entry = sourceEntry;
|
|
308
|
+
let escalatedFrom;
|
|
309
|
+
if (escalate_to_pro) {
|
|
310
|
+
const escalation = resolveEscalation(REGISTRY, agent_id);
|
|
311
|
+
if (!escalation) {
|
|
312
|
+
return {
|
|
313
|
+
content: [
|
|
314
|
+
{ type: "text", text: JSON.stringify({ outcome: "no_escalation_candidate", requested: agent_id }) },
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
entry = escalation;
|
|
319
|
+
escalatedFrom = agent_id;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const state = readState();
|
|
323
|
+
writeState({
|
|
324
|
+
[entry.id]: { ...(state[entry.id] || {}), last_used_at: Math.floor(Date.now() / 1000) },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const result = await runAny(entry, prompt, { transport });
|
|
328
|
+
const now = Math.floor(Date.now() / 1000);
|
|
329
|
+
|
|
330
|
+
let outcome;
|
|
331
|
+
let statePatch;
|
|
332
|
+
|
|
333
|
+
if (result.exitCode === 0) {
|
|
334
|
+
statePatch = { [entry.id]: { state: "healthy", checked: now, last_used_at: now } };
|
|
335
|
+
outcome = "success";
|
|
336
|
+
} else if (result.exitCode !== 0) {
|
|
337
|
+
const signal = parseExhaustionSignal(result.stderr + "\n" + result.output);
|
|
338
|
+
if (signal.detected) {
|
|
339
|
+
const cooldown_until = signal.reset_at != null ? signal.reset_at : now + 3600;
|
|
340
|
+
statePatch = {
|
|
341
|
+
[entry.id]: {
|
|
342
|
+
state: "quota_exhausted",
|
|
343
|
+
cooldown_until,
|
|
344
|
+
source: signal.reset_at != null ? "error_body" : "fallback_ttl",
|
|
345
|
+
checked: now,
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
outcome = "quota_exhausted";
|
|
349
|
+
} else {
|
|
350
|
+
outcome = "error";
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (statePatch) writeState(statePatch);
|
|
355
|
+
|
|
356
|
+
const response = {
|
|
357
|
+
agent_id: entry.id,
|
|
358
|
+
outcome,
|
|
359
|
+
exit_code: result.exitCode,
|
|
360
|
+
duration_ms: result.durationMs,
|
|
361
|
+
output: result.output,
|
|
362
|
+
workdir: result.workdir,
|
|
363
|
+
files: result.files,
|
|
364
|
+
};
|
|
365
|
+
if (escalatedFrom) response.escalated_from = escalatedFrom;
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
content: [{ type: "text", text: JSON.stringify(response) }],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
async function run() {
|
|
376
|
+
const transport = new StdioServerTransport();
|
|
377
|
+
await server.connect(transport);
|
|
378
|
+
console.error("external-agents-spike MCP server running on stdio");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
run().catch((error) => {
|
|
382
|
+
console.error("Fatal error:", error);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
});
|