ai-agentmanager 1.11.1 → 1.13.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/CHANGELOG.md +20 -0
- package/bin/agentkit.js +10 -0
- package/docs/agentkit.md +175 -0
- package/package.json +7 -3
- package/public/app.js +44 -6
- package/public/built.js +332 -0
- package/public/index.html +22 -0
- package/public/style.css +30 -0
- package/src/agentkit/agent.js +171 -0
- package/src/agentkit/approvals.js +97 -0
- package/src/agentkit/cli.js +153 -0
- package/src/agentkit/engines.js +128 -0
- package/src/agentkit/http.js +131 -0
- package/src/agentkit/package.js +114 -0
- package/src/agentkit/report.js +106 -0
- package/src/agentkit/sea-main.js +25 -0
- package/src/agentkit/service.js +118 -0
- package/src/agentkit/spec.js +109 -0
- package/src/agentkit/stdio.js +95 -0
- package/src/agentkit/triggers.js +60 -0
- package/src/agentkit-reports.js +117 -0
- package/src/builder.js +436 -0
- package/src/manager.js +25 -2
- package/src/server.js +74 -5
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Reporting to AI Agent Manager: the agent says who it is, sends each run (status, cost, result)
|
|
2
|
+
// and its open approvals, and picks up the decisions people made there. It checks in every 30
|
|
3
|
+
// seconds (every 2 while an approval waits), and right away after a run or a new approval.
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
|
|
7
|
+
const EXCERPT = 2000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST JSON over https to a server with a self-signed certificate (AI Agent Manager's remote
|
|
11
|
+
* access), trusting only the certificate whose SHA-256 fingerprint is `pin`. The body is sent only
|
|
12
|
+
* after the fingerprint matches.
|
|
13
|
+
*/
|
|
14
|
+
export function pinnedPost(url, { headers, body, pin, timeoutMs = 15_000 }) {
|
|
15
|
+
const want = String(pin).replace(/:/g, '').toLowerCase();
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const req = https.request(url, { method: 'POST', headers: { ...headers, 'content-length': Buffer.byteLength(body) }, rejectUnauthorized: false, agent: false, timeout: timeoutMs }, (res) => {
|
|
18
|
+
let text = '';
|
|
19
|
+
res.on('data', (d) => (text += d));
|
|
20
|
+
res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: async () => JSON.parse(text) }));
|
|
21
|
+
});
|
|
22
|
+
req.on('socket', (sock) =>
|
|
23
|
+
sock.once('secureConnect', () => {
|
|
24
|
+
const got = sock.getPeerX509Certificate?.()?.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
25
|
+
if (got !== want) return req.destroy(new Error('the certificate does not match AGENTKIT_REPORT_CERT'));
|
|
26
|
+
req.end(body);
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
req.on('timeout', () => req.destroy(new Error('timed out')));
|
|
30
|
+
req.on('error', reject);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function startReporting(agent, { url, token, cert = null, chat = () => null, version = null, log = () => {}, fetchImpl = fetch, everyMs = 30_000, fastMs = 2_000 }) {
|
|
35
|
+
const endpoint = `${url.replace(/\/+$/, '')}/api/agentkit/report`;
|
|
36
|
+
const post = cert && endpoint.startsWith('https:') ? (u, o) => pinnedPost(u, { headers: o.headers, body: o.body, pin: cert }) : fetchImpl;
|
|
37
|
+
const startedAt = new Date().toISOString();
|
|
38
|
+
let unsent = []; // runs not yet acknowledged
|
|
39
|
+
let timer = null;
|
|
40
|
+
let busy = false;
|
|
41
|
+
let again = false;
|
|
42
|
+
let ok = null;
|
|
43
|
+
let stopped = false;
|
|
44
|
+
|
|
45
|
+
const onRun = (r) => {
|
|
46
|
+
unsent = [...unsent, { id: r.id, status: r.status, source: r.source, costUsd: r.costUsd, ms: r.ms, result: String(r.result ?? '').slice(0, EXCERPT), at: new Date().toISOString() }].slice(-50);
|
|
47
|
+
push();
|
|
48
|
+
};
|
|
49
|
+
const onApprovals = () => push();
|
|
50
|
+
agent.on('run', onRun);
|
|
51
|
+
agent.on('approvals', onApprovals);
|
|
52
|
+
|
|
53
|
+
async function push() {
|
|
54
|
+
if (stopped) return;
|
|
55
|
+
if (busy) return void (again = true);
|
|
56
|
+
busy = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
const s = agent.spec;
|
|
59
|
+
const sending = unsent;
|
|
60
|
+
const body = {
|
|
61
|
+
agent: { name: s.name, title: s.title, description: s.description, engine: s.engine, triggers: s.triggers.map((t) => t.label), host: os.hostname(), pid: process.pid, chat: chat(), version, startedAt },
|
|
62
|
+
current: agent.current,
|
|
63
|
+
runs: sending,
|
|
64
|
+
pending: agent.approvals.pending(),
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
const res = await post(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000) });
|
|
68
|
+
if (!res.ok) throw new Error(res.status === 401 ? 'the report token was not accepted' : `HTTP ${res.status}`);
|
|
69
|
+
const reply = await res.json();
|
|
70
|
+
unsent = unsent.filter((r) => !sending.includes(r));
|
|
71
|
+
for (const d of reply.decisions ?? []) agent.approvals.decide(d.id, d.allow, { by: d.by ?? 'AI Agent Manager', note: d.note ?? '' });
|
|
72
|
+
agent.approvals.channels.add('manager');
|
|
73
|
+
if (ok !== true) log(`reporting to AI Agent Manager at ${url}`);
|
|
74
|
+
ok = true;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
agent.approvals.channels.delete('manager');
|
|
77
|
+
if (ok !== false) log(`could not report to AI Agent Manager (${e.message}); trying again`);
|
|
78
|
+
ok = false;
|
|
79
|
+
} finally {
|
|
80
|
+
busy = false;
|
|
81
|
+
if (!stopped) {
|
|
82
|
+
if (again) (again = false), setImmediate(push);
|
|
83
|
+
else timer = setTimeout(push, agent.approvals.pending().length ? fastMs : everyMs);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
push();
|
|
89
|
+
// `push()` checks in now (say, once the chat page is up); `stop()` ends reporting.
|
|
90
|
+
return {
|
|
91
|
+
push,
|
|
92
|
+
/** Wait (a few seconds at most) until every run is sent: before a one-off run exits. */
|
|
93
|
+
flush: async (ms = 5000) => {
|
|
94
|
+
for (const end = Date.now() + ms; (unsent.length || busy) && Date.now() < end; ) {
|
|
95
|
+
if (!busy) push();
|
|
96
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
stop: () => {
|
|
100
|
+
stopped = true;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
agent.off('run', onRun);
|
|
103
|
+
agent.off('approvals', onApprovals);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The entry point of a packaged agent (a single executable made by package.js). Its own files
|
|
2
|
+
// (agent.yaml, instructions, tests) are embedded; the first run unpacks them to ~/.agentkit/<name>/,
|
|
3
|
+
// where its memory and runs live too. Later runs keep that folder, so edits to its agent.yaml stay
|
|
4
|
+
// (until a newer build of the same agent replaces the files it shipped).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import sea from 'node:sea';
|
|
9
|
+
import { main } from './cli.js';
|
|
10
|
+
|
|
11
|
+
const manifest = JSON.parse(sea.getAsset('manifest.json', 'utf8'));
|
|
12
|
+
const dir = process.env.AGENTKIT_HOME ? path.resolve(process.env.AGENTKIT_HOME) : path.join(os.homedir(), '.agentkit', manifest.name);
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
const stamp = path.join(dir, '.build');
|
|
15
|
+
const shipped = fs.existsSync(stamp) ? fs.readFileSync(stamp, 'utf8').trim() : '';
|
|
16
|
+
if (shipped !== manifest.build) {
|
|
17
|
+
for (const f of manifest.files) {
|
|
18
|
+
const target = path.join(dir, f);
|
|
19
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
20
|
+
fs.writeFileSync(target, Buffer.from(sea.getAsset(`files/${f}`)));
|
|
21
|
+
}
|
|
22
|
+
fs.writeFileSync(stamp, manifest.build);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
main(process.argv.slice(2), { name: manifest.name, defaultRef: dir, packaged: true });
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Keep an agent running in the background: a launchd agent (macOS) or a systemd user service
|
|
2
|
+
// (Linux) that starts it at login and restarts it if it crashes. It runs through your login shell,
|
|
3
|
+
// so it finds the engine CLIs and the tokens your terminal has.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const sh = (cmd, args) => new Promise((resolve, reject) => execFile(cmd, args, (e, out, err) => (e ? reject(new Error(String(err || e.message).trim())) : resolve(String(out)))));
|
|
10
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
11
|
+
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
|
|
13
|
+
/** Where the service files go, and what they're called, for an agent. */
|
|
14
|
+
export function servicePaths(name, home = os.homedir()) {
|
|
15
|
+
const label = `dev.agentkit.${name}`;
|
|
16
|
+
return {
|
|
17
|
+
label,
|
|
18
|
+
plist: path.join(home, 'Library', 'LaunchAgents', `${label}.plist`),
|
|
19
|
+
unitName: `agentkit-${name}.service`,
|
|
20
|
+
unit: path.join(home, '.config', 'systemd', 'user', `agentkit-${name}.service`),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the service runs: `argv` starts the agent in the foreground (`<exe> run` for a packaged one,
|
|
26
|
+
* `node agentkit.js run <dir>` otherwise); settings that change where it lives or which engine
|
|
27
|
+
* binaries it uses carry over.
|
|
28
|
+
*/
|
|
29
|
+
export function serviceSpec({ name, dir, argv, env = process.env, shell = process.env.SHELL || '/bin/sh' }) {
|
|
30
|
+
const keep = {};
|
|
31
|
+
for (const [k, v] of Object.entries(env)) if (/^AGENTKIT_/.test(k) && v) keep[k] = v;
|
|
32
|
+
return { name, dir, log: path.join(dir, 'agent.log'), env: keep, command: [shell, '-l', '-c', `exec ${argv.map(q).join(' ')}`] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function plist(s, label = servicePaths(s.name).label) {
|
|
36
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
37
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
38
|
+
<plist version="1.0">
|
|
39
|
+
<dict>
|
|
40
|
+
<key>Label</key><string>${xml(label)}</string>
|
|
41
|
+
<key>ProgramArguments</key>
|
|
42
|
+
<array>
|
|
43
|
+
${s.command.map((a) => ` <string>${xml(a)}</string>`).join('\n')}
|
|
44
|
+
</array>
|
|
45
|
+
<key>RunAtLoad</key><true/>
|
|
46
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
47
|
+
<key>WorkingDirectory</key><string>${xml(s.dir)}</string>
|
|
48
|
+
<key>EnvironmentVariables</key>
|
|
49
|
+
<dict>
|
|
50
|
+
${Object.entries(s.env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join('\n')}
|
|
51
|
+
</dict>
|
|
52
|
+
<key>StandardOutPath</key><string>${xml(s.log)}</string>
|
|
53
|
+
<key>StandardErrorPath</key><string>${xml(s.log)}</string>
|
|
54
|
+
</dict>
|
|
55
|
+
</plist>
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function unit(s) {
|
|
60
|
+
const cmd = s.command.map((a) => `"${a.replace(/(["\\])/g, '\\$1')}"`).join(' ');
|
|
61
|
+
return `[Unit]
|
|
62
|
+
Description=agentkit agent ${s.name}
|
|
63
|
+
|
|
64
|
+
[Service]
|
|
65
|
+
ExecStart=${cmd}
|
|
66
|
+
WorkingDirectory=${s.dir}
|
|
67
|
+
Restart=on-failure
|
|
68
|
+
${Object.entries(s.env).map(([k, v]) => `Environment=${k}=${v}\n`).join('')}StandardOutput=append:${s.log}
|
|
69
|
+
StandardError=append:${s.log}
|
|
70
|
+
|
|
71
|
+
[Install]
|
|
72
|
+
WantedBy=default.target
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const uid = () => process.getuid();
|
|
77
|
+
|
|
78
|
+
export async function install(s) {
|
|
79
|
+
const p = servicePaths(s.name);
|
|
80
|
+
if (process.platform === 'darwin') {
|
|
81
|
+
fs.mkdirSync(path.dirname(p.plist), { recursive: true });
|
|
82
|
+
await sh('launchctl', ['bootout', `gui/${uid()}/${p.label}`]).catch(() => {});
|
|
83
|
+
fs.writeFileSync(p.plist, plist(s, p.label));
|
|
84
|
+
await sh('launchctl', ['bootstrap', `gui/${uid()}`, p.plist]);
|
|
85
|
+
return { file: p.plist, manager: 'launchd' };
|
|
86
|
+
}
|
|
87
|
+
if (process.platform === 'linux') {
|
|
88
|
+
fs.mkdirSync(path.dirname(p.unit), { recursive: true });
|
|
89
|
+
fs.writeFileSync(p.unit, unit(s));
|
|
90
|
+
await sh('systemctl', ['--user', 'daemon-reload']);
|
|
91
|
+
await sh('systemctl', ['--user', 'enable', '--now', p.unitName]);
|
|
92
|
+
return { file: p.unit, manager: 'systemd', note: 'To keep it running when you are logged out: loginctl enable-linger $USER' };
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`Background service is not supported on ${process.platform}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function uninstall(name) {
|
|
98
|
+
const p = servicePaths(name);
|
|
99
|
+
if (process.platform === 'darwin') {
|
|
100
|
+
await sh('launchctl', ['bootout', `gui/${uid()}/${p.label}`]).catch(() => {});
|
|
101
|
+
fs.rmSync(p.plist, { force: true });
|
|
102
|
+
return { file: p.plist };
|
|
103
|
+
}
|
|
104
|
+
if (process.platform === 'linux') {
|
|
105
|
+
await sh('systemctl', ['--user', 'disable', '--now', p.unitName]).catch(() => {});
|
|
106
|
+
fs.rmSync(p.unit, { force: true });
|
|
107
|
+
await sh('systemctl', ['--user', 'daemon-reload']).catch(() => {});
|
|
108
|
+
return { file: p.unit };
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Background service is not supported on ${process.platform}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function status(name) {
|
|
114
|
+
const p = servicePaths(name);
|
|
115
|
+
if (process.platform === 'darwin') return { installed: fs.existsSync(p.plist), running: await sh('launchctl', ['print', `gui/${uid()}/${p.label}`]).then((o) => /state = running/.test(o), () => false), file: p.plist };
|
|
116
|
+
if (process.platform === 'linux') return { installed: fs.existsSync(p.unit), running: await sh('systemctl', ['--user', 'is-active', p.unitName]).then(() => true, () => false), file: p.unit };
|
|
117
|
+
return { installed: false, running: false, file: null };
|
|
118
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// agent.yaml: what an agentkit agent is (docs/agentkit.md). Loaded once at start: paths resolved
|
|
2
|
+
// against the agent's folder, ${VAR} filled from the environment, defaults applied, mistakes
|
|
3
|
+
// reported in one message.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
|
|
8
|
+
export const ENGINES = new Set(['claude', 'codex', 'muse']);
|
|
9
|
+
const DURATION = /^(\d+)\s*(s|m|h|d)$/;
|
|
10
|
+
export const toMs = (d) => {
|
|
11
|
+
const m = DURATION.exec(String(d).trim());
|
|
12
|
+
return m ? Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2]] : null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** ${VAR} from the environment (missing ones become empty, and are reported). */
|
|
16
|
+
function fillEnv(value, env, missing) {
|
|
17
|
+
if (typeof value === 'string') return value.replace(/\$\{(\w+)\}/g, (_, k) => (k in env ? env[k] : (missing.add(k), '')));
|
|
18
|
+
if (Array.isArray(value)) return value.map((v) => fillEnv(v, env, missing));
|
|
19
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, fillEnv(v, env, missing)]));
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read and check an agent: a folder with agent.yaml (or agent.json), or the file itself. */
|
|
24
|
+
export function loadAgent(ref, { env = process.env } = {}) {
|
|
25
|
+
let file = path.resolve(ref);
|
|
26
|
+
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) {
|
|
27
|
+
file = ['agent.yaml', 'agent.yml', 'agent.json'].map((n) => path.join(file, n)).find((f) => fs.existsSync(f)) ?? path.join(file, 'agent.yaml');
|
|
28
|
+
}
|
|
29
|
+
if (!fs.existsSync(file)) throw new Error(`No agent.yaml at ${file}`);
|
|
30
|
+
const dir = path.dirname(file);
|
|
31
|
+
const raw = file.endsWith('.json') ? JSON.parse(fs.readFileSync(file, 'utf8')) : YAML.parse(fs.readFileSync(file, 'utf8'));
|
|
32
|
+
return checkAgent(raw ?? {}, { dir, env, file });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function checkAgent(raw, { dir, env = process.env, file = 'agent.yaml' } = {}) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
const missing = new Set();
|
|
38
|
+
const a = fillEnv(raw, env, missing);
|
|
39
|
+
if (!/^[a-z][a-z0-9-]{1,40}$/.test(a.name ?? '')) errors.push('name: lowercase letters, digits and "-" (2-41 characters), starting with a letter');
|
|
40
|
+
if (!String(a.goal ?? '').trim()) errors.push('goal: say what the agent achieves');
|
|
41
|
+
const engine = String(a.engine ?? 'claude');
|
|
42
|
+
if (!ENGINES.has(engine) && !/^acp:[a-z0-9-]+$/.test(engine)) errors.push(`engine: claude, codex, muse or acp:<name> (not "${engine}")`);
|
|
43
|
+
// Instructions: a file next to agent.yaml, or inline text.
|
|
44
|
+
let instructions = String(a.instructions ?? '');
|
|
45
|
+
if (instructions && !instructions.includes('\n') && /\.(md|txt)$/.test(instructions)) {
|
|
46
|
+
const f = path.resolve(dir, instructions);
|
|
47
|
+
if (fs.existsSync(f)) instructions = fs.readFileSync(f, 'utf8');
|
|
48
|
+
else errors.push(`instructions: ${instructions} not found next to the agent`);
|
|
49
|
+
}
|
|
50
|
+
const triggers = (Array.isArray(a.triggers) ? a.triggers : []).map((t, i) => {
|
|
51
|
+
if (t?.every != null) {
|
|
52
|
+
const ms = toMs(t.every);
|
|
53
|
+
if (!ms || ms < 10_000) errors.push(`triggers[${i}].every: a duration of at least 10s, like 10m or 1h`);
|
|
54
|
+
return { kind: 'every', ms, label: `every ${t.every}` };
|
|
55
|
+
}
|
|
56
|
+
if (t?.cron) {
|
|
57
|
+
if (String(t.cron).trim().split(/\s+/).length !== 5) errors.push(`triggers[${i}].cron: five fields (minute hour day month weekday)`);
|
|
58
|
+
return { kind: 'cron', cron: String(t.cron), label: `cron ${t.cron}` };
|
|
59
|
+
}
|
|
60
|
+
if (t?.webhook) return { kind: 'webhook', path: `/${String(t.webhook).replace(/^\/+/, '')}`, label: `webhook ${t.webhook}` };
|
|
61
|
+
if (t?.watch) return { kind: 'watch', dir: path.resolve(dir, String(t.watch)), label: `watch ${t.watch}` };
|
|
62
|
+
errors.push(`triggers[${i}]: every, cron, webhook or watch`);
|
|
63
|
+
return null;
|
|
64
|
+
}).filter(Boolean);
|
|
65
|
+
const approveTimeoutMs = a.approveTimeout != null ? toMs(a.approveTimeout) : 30 * 60_000;
|
|
66
|
+
if (!approveTimeoutMs) errors.push('approveTimeout: a duration, like 30m or 2h');
|
|
67
|
+
// Where it reports its runs and asks for approvals: AI Agent Manager (the environment wins).
|
|
68
|
+
// cert: the SHA-256 fingerprint of its remote-access certificate (self-signed), when over https.
|
|
69
|
+
const report = { url: env.AGENTKIT_REPORT_URL || a.report?.url || null, token: env.AGENTKIT_REPORT_TOKEN || a.report?.token || null, cert: env.AGENTKIT_REPORT_CERT || a.report?.cert || null };
|
|
70
|
+
if (report.url && !/^https?:\/\//.test(report.url)) errors.push('report.url: an http(s) address of AI Agent Manager');
|
|
71
|
+
const loop = { plan: true, check: true, maxSteps: 4, budgetUsd: null, ...(a.loop ?? {}) };
|
|
72
|
+
loop.maxSteps = Math.max(1, Math.min(20, Number(loop.maxSteps) || 4));
|
|
73
|
+
const iface = a.interfaces ?? {};
|
|
74
|
+
const out = {
|
|
75
|
+
name: a.name,
|
|
76
|
+
title: String(a.title ?? a.name ?? ''),
|
|
77
|
+
description: String(a.description ?? ''),
|
|
78
|
+
goal: String(a.goal ?? '').trim(),
|
|
79
|
+
instructions: instructions.trim(),
|
|
80
|
+
engine,
|
|
81
|
+
model: a.model ? String(a.model) : null,
|
|
82
|
+
dir,
|
|
83
|
+
file,
|
|
84
|
+
workdir: path.resolve(dir, String(a.workdir ?? './work')),
|
|
85
|
+
tools: a.tools && typeof a.tools === 'object' ? a.tools : {},
|
|
86
|
+
allow: [a.allow ?? []].flat().map(String),
|
|
87
|
+
approve: [a.approve ?? []].flat().map(String),
|
|
88
|
+
approveTimeoutMs,
|
|
89
|
+
report: report.url ? report : null,
|
|
90
|
+
triggers,
|
|
91
|
+
loop,
|
|
92
|
+
memory: a.memory !== false,
|
|
93
|
+
interfaces: {
|
|
94
|
+
http: iface.http === false ? null : { port: Number(iface.http?.port ?? 7700), host: String(iface.http?.host ?? '127.0.0.1') },
|
|
95
|
+
a2a: iface.a2a !== false,
|
|
96
|
+
acp: iface.acp !== false,
|
|
97
|
+
mcp: iface.mcp !== false,
|
|
98
|
+
},
|
|
99
|
+
missingEnv: [...missing],
|
|
100
|
+
};
|
|
101
|
+
if (errors.length) throw Object.assign(new Error(`${file}:\n- ${errors.join('\n- ')}`), { errors });
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The agent's own files: memory, run logs and engine sessions per conversation. */
|
|
106
|
+
export function agentPaths(agent) {
|
|
107
|
+
const state = path.join(agent.dir, '.agentkit');
|
|
108
|
+
return { state, memory: path.join(state, 'memory.md'), runs: path.join(state, 'runs'), sessions: path.join(state, 'sessions.json'), mcp: path.join(state, 'mcp.json') };
|
|
109
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The agent on stdio, for clients that start it as a program:
|
|
2
|
+
// ACP (`<agent> acp`): editors and AI Agent Manager's ACP engine drive it like a coding agent.
|
|
3
|
+
// MCP (`<agent> mcp`): other agents get one tool, ask_<name>, that hands it a task.
|
|
4
|
+
// Both are newline-delimited JSON-RPC 2.0. Nothing but protocol goes to stdout.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
|
|
8
|
+
function serve(handle, { input = process.stdin, output = process.stdout } = {}) {
|
|
9
|
+
const send = (m) => output.write(JSON.stringify({ jsonrpc: '2.0', ...m }) + '\n');
|
|
10
|
+
readline.createInterface({ input }).on('line', async (line) => {
|
|
11
|
+
let msg;
|
|
12
|
+
try {
|
|
13
|
+
msg = JSON.parse(line);
|
|
14
|
+
} catch {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!msg.method) return;
|
|
18
|
+
try {
|
|
19
|
+
const result = await handle(msg.method, msg.params ?? {}, send);
|
|
20
|
+
if (msg.id != null) send({ id: msg.id, result: result ?? {} });
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (msg.id != null) send({ id: msg.id, error: { code: err.code ?? -32603, message: err.message } });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** ACP: sessions are conversations; a prompt is a run whose steps stream as updates. */
|
|
28
|
+
export function serveAcp(agent, io) {
|
|
29
|
+
serve(async (method, p, send) => {
|
|
30
|
+
if (method === 'initialize') return { protocolVersion: 1, agentCapabilities: { loadSession: true }, agentInfo: { name: agent.spec.name, title: agent.spec.title, version: '1.0.0' }, authMethods: [] };
|
|
31
|
+
if (method === 'session/new') return { sessionId: `ak-${crypto.randomUUID()}` };
|
|
32
|
+
if (method === 'session/load') return {};
|
|
33
|
+
if (method === 'session/cancel') return {};
|
|
34
|
+
if (method === 'session/prompt') {
|
|
35
|
+
const text = (p.prompt ?? []).map((b) => b.text ?? '').join('').trim();
|
|
36
|
+
const update = (u) => send({ method: 'session/update', params: { sessionId: p.sessionId, update: u } });
|
|
37
|
+
const onStep = (s) => {
|
|
38
|
+
if (s.type === 'plan' || s.type === 'check' || s.type === 'reflect') update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: `[${s.type}] ${s.text}\n` } });
|
|
39
|
+
};
|
|
40
|
+
agent.on('step', onStep);
|
|
41
|
+
try {
|
|
42
|
+
const r = await agent.run(text, { source: 'acp', conversation: `acp:${p.sessionId}` });
|
|
43
|
+
update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: r.result } });
|
|
44
|
+
return { stopReason: r.status === 'failed' ? 'refusal' : 'end_turn' };
|
|
45
|
+
} finally {
|
|
46
|
+
agent.off('step', onStep);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
50
|
+
}, io);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** MCP: one tool, ask_<name>, whose answer is the run's result. */
|
|
54
|
+
export function serveMcp(agent, io) {
|
|
55
|
+
const tool = `ask_${agent.spec.name.replace(/-/g, '_')}`;
|
|
56
|
+
serve(async (method, p) => {
|
|
57
|
+
if (method === 'initialize') return { protocolVersion: p.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: agent.spec.name, version: '1.0.0' } };
|
|
58
|
+
if (method === 'notifications/initialized' || method === 'ping') return {};
|
|
59
|
+
if (method === 'tools/list') {
|
|
60
|
+
return { tools: [{ name: tool, description: `${agent.spec.title}: ${agent.spec.description || agent.spec.goal}`, inputSchema: { type: 'object', properties: { task: { type: 'string', description: 'What you want done' } }, required: ['task'] } }] };
|
|
61
|
+
}
|
|
62
|
+
if (method === 'tools/call') {
|
|
63
|
+
if (p.name !== tool) throw Object.assign(new Error(`Unknown tool ${p.name}`), { code: -32602 });
|
|
64
|
+
const r = await agent.run(String(p.arguments?.task ?? ''), { source: 'mcp' });
|
|
65
|
+
return { content: [{ type: 'text', text: r.result }], isError: r.status === 'failed' };
|
|
66
|
+
}
|
|
67
|
+
throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
68
|
+
}, io);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The approver (`<agent> approver`): the MCP tool Claude asks (--permission-prompt-tool) before a
|
|
73
|
+
* tool that isn't allowed outright. It hands the request to the running agent (AGENTKIT_APPROVAL_URL,
|
|
74
|
+
* with AGENTKIT_APPROVAL_TOKEN), which asks a person, and answers with their decision.
|
|
75
|
+
*/
|
|
76
|
+
export function serveApprover({ url, token, fetchImpl = fetch } = {}, io) {
|
|
77
|
+
serve(async (method, p) => {
|
|
78
|
+
if (method === 'initialize') return { protocolVersion: p.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'agentkit-approver', version: '1.0.0' } };
|
|
79
|
+
if (method === 'notifications/initialized' || method === 'ping') return {};
|
|
80
|
+
if (method === 'tools/list') {
|
|
81
|
+
return { tools: [{ name: 'decide', description: 'Asks a person whether a tool may run', inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name'] } }] };
|
|
82
|
+
}
|
|
83
|
+
if (method === 'tools/call') {
|
|
84
|
+
const a = p.arguments ?? {};
|
|
85
|
+
let d = { allow: false, message: 'No one could be asked (the agent is not reachable)' };
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ tool: a.tool_name, input: a.input ?? {} }) });
|
|
88
|
+
if (res.ok) d = await res.json();
|
|
89
|
+
} catch {}
|
|
90
|
+
const text = d.allow ? JSON.stringify({ behavior: 'allow', updatedInput: a.input ?? {} }) : JSON.stringify({ behavior: 'deny', message: d.message || 'A person declined this' });
|
|
91
|
+
return { content: [{ type: 'text', text }] };
|
|
92
|
+
}
|
|
93
|
+
throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
94
|
+
}, io);
|
|
95
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What starts a run by itself: an interval, a cron schedule, a folder watch. (Webhooks arrive over
|
|
2
|
+
// HTTP, see http.js.) Each fires agent.run with a short description of why.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/** One cron field ("*", "5", "1-5", "*\/15", "1,15") against a value. */
|
|
6
|
+
function fieldMatches(field, value, min, max) {
|
|
7
|
+
return field.split(',').some((part) => {
|
|
8
|
+
const [range, stepRaw] = part.split('/');
|
|
9
|
+
const step = Number(stepRaw ?? 1) || 1;
|
|
10
|
+
let [lo, hi] = range === '*' ? [min, max] : range.split('-').map(Number);
|
|
11
|
+
if (hi == null) hi = stepRaw ? max : lo;
|
|
12
|
+
return value >= lo && value <= hi && (value - lo) % step === 0;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Whether a five-field cron expression (minute hour day month weekday) matches a time. */
|
|
17
|
+
export function cronMatches(expr, d = new Date()) {
|
|
18
|
+
const [mi, h, dom, mon, dow] = String(expr).trim().split(/\s+/);
|
|
19
|
+
return fieldMatches(mi, d.getMinutes(), 0, 59) && fieldMatches(h, d.getHours(), 0, 23) && fieldMatches(dom, d.getDate(), 1, 31) && fieldMatches(mon, d.getMonth() + 1, 1, 12) && fieldMatches(dow, d.getDay(), 0, 6);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Start the agent's triggers; returns a function that stops them. */
|
|
23
|
+
export function startTriggers(agent, { log = () => {} } = {}) {
|
|
24
|
+
const stops = [];
|
|
25
|
+
for (const t of agent.spec.triggers) {
|
|
26
|
+
if (t.kind === 'every') {
|
|
27
|
+
const timer = setInterval(() => agent.run('', { source: t.label }).catch(() => {}), t.ms);
|
|
28
|
+
stops.push(() => clearInterval(timer));
|
|
29
|
+
} else if (t.kind === 'cron') {
|
|
30
|
+
let last = '';
|
|
31
|
+
const timer = setInterval(() => {
|
|
32
|
+
const d = new Date();
|
|
33
|
+
const minute = d.toISOString().slice(0, 16);
|
|
34
|
+
if (minute !== last && cronMatches(t.cron, d)) {
|
|
35
|
+
last = minute;
|
|
36
|
+
agent.run('', { source: t.label }).catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
}, 15_000);
|
|
39
|
+
stops.push(() => clearInterval(timer));
|
|
40
|
+
} else if (t.kind === 'watch') {
|
|
41
|
+
fs.mkdirSync(t.dir, { recursive: true });
|
|
42
|
+
// A burst of changes (an editor saving, a copy) is one run.
|
|
43
|
+
let pending = new Set();
|
|
44
|
+
let timer = null;
|
|
45
|
+
const w = fs.watch(t.dir, (event, file) => {
|
|
46
|
+
if (!file || file.startsWith('.')) return;
|
|
47
|
+
pending.add(file);
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
timer = setTimeout(() => {
|
|
50
|
+
const files = [...pending];
|
|
51
|
+
pending = new Set();
|
|
52
|
+
agent.run(`Files changed in ${t.dir}: ${files.join(', ')}`, { source: t.label }).catch(() => {});
|
|
53
|
+
}, 1500);
|
|
54
|
+
});
|
|
55
|
+
stops.push(() => (clearTimeout(timer), w.close()));
|
|
56
|
+
}
|
|
57
|
+
if (t.kind !== 'webhook') log(`trigger: ${t.label}`);
|
|
58
|
+
}
|
|
59
|
+
return () => stops.forEach((s) => s());
|
|
60
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Agents built with agentkit report here (src/agentkit/report.js): who they are, each run, and the
|
|
2
|
+
// tool approvals they're waiting on. People see them in the Built tab and the Inbox, and their
|
|
3
|
+
// decisions go back with the agent's next check-in. Kept in ~/.agentmanager/agentkit-reports.json.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { paths } from './config.js';
|
|
9
|
+
|
|
10
|
+
const ONLINE_MS = 90_000;
|
|
11
|
+
const file = () => path.join(paths.home, 'agentkit-reports.json');
|
|
12
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
13
|
+
const str = (v, n = 200) => String(v ?? '').slice(0, n);
|
|
14
|
+
|
|
15
|
+
export class AgentkitReports extends EventEmitter {
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
let saved = {};
|
|
19
|
+
try {
|
|
20
|
+
saved = JSON.parse(fs.readFileSync(file(), 'utf8'));
|
|
21
|
+
} catch {}
|
|
22
|
+
this.token = saved.token ?? null;
|
|
23
|
+
this.agents = new Map(Object.entries(saved.agents ?? {}));
|
|
24
|
+
this.decisions = new Map(); // key -> [{ id, allow, by, note }] to hand back on its next check-in
|
|
25
|
+
this.saveTimer = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The token agents report with (made on first use). */
|
|
29
|
+
reportToken() {
|
|
30
|
+
if (!this.token) {
|
|
31
|
+
this.token = crypto.randomBytes(24).toString('base64url');
|
|
32
|
+
this.save(true);
|
|
33
|
+
}
|
|
34
|
+
return this.token;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
save(now = false) {
|
|
38
|
+
clearTimeout(this.saveTimer);
|
|
39
|
+
const write = () => {
|
|
40
|
+
fs.mkdirSync(paths.home, { recursive: true });
|
|
41
|
+
fs.writeFileSync(file(), JSON.stringify({ token: this.token, agents: Object.fromEntries(this.agents) }, null, 2), { mode: 0o600 });
|
|
42
|
+
};
|
|
43
|
+
if (now) write();
|
|
44
|
+
else this.saveTimer = setTimeout(write, 1000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A check-in from an agent. Returns the decisions waiting for it. */
|
|
48
|
+
report(body = {}) {
|
|
49
|
+
const a = body.agent ?? {};
|
|
50
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(String(a.name ?? ''))) throw err(400, 'agent.name: required');
|
|
51
|
+
const key = `${a.name}@${str(a.host, 120) || 'unknown'}`;
|
|
52
|
+
const prev = this.agents.get(key) ?? { runs: [], totals: { runs: 0, failed: 0, costUsd: 0 } };
|
|
53
|
+
const known = new Set(prev.runs.map((r) => r.id));
|
|
54
|
+
const fresh = (Array.isArray(body.runs) ? body.runs : []).filter((r) => r?.id && !known.has(r.id)).map((r) => ({ id: str(r.id, 80), status: str(r.status, 20), source: str(r.source, 80), costUsd: Number(r.costUsd) || null, ms: Number(r.ms) || null, result: str(r.result, 2000), at: str(r.at, 40) }));
|
|
55
|
+
const totals = { ...prev.totals };
|
|
56
|
+
for (const r of fresh) {
|
|
57
|
+
totals.runs++;
|
|
58
|
+
if (r.status === 'failed') totals.failed++;
|
|
59
|
+
totals.costUsd = Math.round((totals.costUsd + (r.costUsd ?? 0)) * 1e6) / 1e6;
|
|
60
|
+
}
|
|
61
|
+
const decided = new Set((this.decisions.get(key) ?? []).map((d) => d.id));
|
|
62
|
+
const next = {
|
|
63
|
+
key,
|
|
64
|
+
name: a.name,
|
|
65
|
+
title: str(a.title) || a.name,
|
|
66
|
+
description: str(a.description, 400),
|
|
67
|
+
engine: str(a.engine, 40),
|
|
68
|
+
triggers: (Array.isArray(a.triggers) ? a.triggers : []).slice(0, 10).map((t) => str(t, 80)),
|
|
69
|
+
host: str(a.host, 120),
|
|
70
|
+
pid: Number(a.pid) || null,
|
|
71
|
+
chat: /^https?:\/\//.test(String(a.chat ?? '')) ? str(a.chat, 300) : null,
|
|
72
|
+
version: str(a.version, 40) || null,
|
|
73
|
+
startedAt: str(a.startedAt, 40) || null,
|
|
74
|
+
lastSeen: new Date().toISOString(),
|
|
75
|
+
current: body.current?.id ? { id: str(body.current.id, 80), source: str(body.current.source, 80), startedAt: str(body.current.startedAt, 40) } : null,
|
|
76
|
+
// Approvals already answered here stay hidden until the agent confirms them.
|
|
77
|
+
pending: (Array.isArray(body.pending) ? body.pending : []).filter((p) => p?.id && !decided.has(p.id)).slice(0, 20).map((p) => ({ id: str(p.id, 80), tool: str(p.tool, 200), input: p.input && typeof p.input === 'object' ? p.input : {}, run: p.run ? str(p.run, 80) : null, at: str(p.at, 40) })),
|
|
78
|
+
runs: [...fresh.reverse(), ...prev.runs].slice(0, 50),
|
|
79
|
+
totals,
|
|
80
|
+
};
|
|
81
|
+
this.agents.set(key, next);
|
|
82
|
+
const decisions = this.decisions.get(key) ?? [];
|
|
83
|
+
this.decisions.delete(key);
|
|
84
|
+
this.save();
|
|
85
|
+
this.emit('change');
|
|
86
|
+
return { ok: true, decisions };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A person's answer to an approval; it goes back on the agent's next check-in. */
|
|
90
|
+
decide(key, id, allow, by = 'you') {
|
|
91
|
+
const a = this.agents.get(key);
|
|
92
|
+
if (!a) throw err(404, 'No such agent');
|
|
93
|
+
const p = a.pending.find((x) => x.id === id);
|
|
94
|
+
if (!p) throw err(409, 'Already decided, or no longer waiting');
|
|
95
|
+
this.decisions.set(key, [...(this.decisions.get(key) ?? []), { id, allow: Boolean(allow), by }]);
|
|
96
|
+
a.pending = a.pending.filter((x) => x.id !== id);
|
|
97
|
+
this.emit('change');
|
|
98
|
+
return this.view(a);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
forget(key) {
|
|
102
|
+
this.agents.delete(key);
|
|
103
|
+
this.decisions.delete(key);
|
|
104
|
+
this.save();
|
|
105
|
+
this.emit('change');
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
view(a) {
|
|
110
|
+
const online = Date.now() - Date.parse(a.lastSeen) < ONLINE_MS;
|
|
111
|
+
return { ...a, online, pending: online ? a.pending : [], runs: a.runs.slice(0, 10) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
list() {
|
|
115
|
+
return [...this.agents.values()].map((a) => this.view(a)).sort((x, y) => String(y.lastSeen).localeCompare(String(x.lastSeen)));
|
|
116
|
+
}
|
|
117
|
+
}
|