ai-agentmanager 1.11.0 → 1.12.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.
@@ -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
+ }