agents-relay 1.0.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/.github/workflows/publish.yml +91 -0
- package/AGENTS.md +16 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/adapters.js +311 -0
- package/dist/cli.js +455 -0
- package/dist/continuation.js +21 -0
- package/dist/dashboard.js +446 -0
- package/dist/events.js +36 -0
- package/dist/github-auth.js +34 -0
- package/dist/github-webhook.js +47 -0
- package/dist/markers.js +42 -0
- package/dist/planner.js +172 -0
- package/dist/pool.js +98 -0
- package/dist/reconciler.js +434 -0
- package/dist/registry.js +27 -0
- package/dist/relayd.js +177 -0
- package/dist/scheduler.js +49 -0
- package/dist/store.js +586 -0
- package/dist/types.js +6 -0
- package/dist/usage.js +370 -0
- package/dist/workspace.js +76 -0
- package/docs/agent-network.md +34 -0
- package/docs/architecture.md +120 -0
- package/docs/autonomous-objective-jobs.md +121 -0
- package/docs/example.md +30 -0
- package/docs/github-app-rate-limit.md +124 -0
- package/docs/service.md +43 -0
- package/pack.json +326 -0
- package/package.json +14 -0
- package/scripts/npm-version.mjs +11 -0
- package/skills/agents-relay/SKILL.md +77 -0
- package/skills/agents-relay/agents/planner.agent.md +28 -0
- package/src/adapters.ts +231 -0
- package/src/cli.ts +324 -0
- package/src/continuation.ts +6 -0
- package/src/dashboard.ts +421 -0
- package/src/events.ts +25 -0
- package/src/github-auth.ts +35 -0
- package/src/github-webhook.ts +37 -0
- package/src/markers.ts +33 -0
- package/src/planner.ts +150 -0
- package/src/pool.ts +87 -0
- package/src/reconciler.ts +235 -0
- package/src/registry.ts +35 -0
- package/src/relayd.ts +137 -0
- package/src/scheduler.ts +27 -0
- package/src/store.ts +526 -0
- package/src/types.ts +45 -0
- package/src/usage.ts +385 -0
- package/src/workspace.ts +62 -0
- package/test/adapters.test.js +303 -0
- package/test/autonomous.test.js +119 -0
- package/test/core.test.js +363 -0
- package/test/dashboard.test.js +178 -0
- package/test/github-auth.test.js +51 -0
- package/test/github-webhook.test.js +21 -0
- package/test/service.test.js +116 -0
- package/test/store.test.js +390 -0
- package/test/usage.test.js +88 -0
- package/test/workspace.test.js +95 -0
- package/tsconfig.json +4 -0
package/dist/planner.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
function record(value) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
7
|
+
throw new Error('Planner result must be an object');
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function optionalString(value, field) {
|
|
11
|
+
if (value === undefined || value === null)
|
|
12
|
+
return undefined;
|
|
13
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
14
|
+
throw new Error(`Planner ${field} must be a non-empty string`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function stringArray(value, field, fallback = []) {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return fallback;
|
|
20
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length === 0))
|
|
21
|
+
throw new Error(`Planner ${field} must be an array of non-empty strings`);
|
|
22
|
+
return [...value];
|
|
23
|
+
}
|
|
24
|
+
function routing(value, field) {
|
|
25
|
+
if (value === undefined || value === null)
|
|
26
|
+
return undefined;
|
|
27
|
+
const route = record(value);
|
|
28
|
+
for (const key of ['provider', 'model', 'decidedBy', 'decidedAt'])
|
|
29
|
+
if (typeof route[key] !== 'string' || String(route[key]).length === 0)
|
|
30
|
+
throw new Error(`Planner ${field}.${key} is required`);
|
|
31
|
+
return { provider: route.provider, model: route.model, profile: optionalString(route.profile, `${field}.profile`), reasoning: optionalString(route.reasoning, `${field}.reasoning`), cwd: optionalString(route.cwd, `${field}.cwd`), projectId: optionalString(route.projectId, `${field}.projectId`), decidedBy: route.decidedBy, decidedAt: route.decidedAt };
|
|
32
|
+
}
|
|
33
|
+
export function parsePlannerResult(value) {
|
|
34
|
+
const root = record(value);
|
|
35
|
+
const status = root.objective_status;
|
|
36
|
+
if (status !== 'in_progress' && status !== 'satisfied')
|
|
37
|
+
throw new Error('Planner objective_status must be in_progress or satisfied');
|
|
38
|
+
if (typeof root.assessment !== 'string')
|
|
39
|
+
throw new Error('Planner assessment must be a string');
|
|
40
|
+
if (!Array.isArray(root.next_tasks))
|
|
41
|
+
throw new Error('Planner next_tasks must be an array');
|
|
42
|
+
const ids = new Set();
|
|
43
|
+
const next_tasks = root.next_tasks.map((item, index) => {
|
|
44
|
+
const task = record(item);
|
|
45
|
+
const id = task.id;
|
|
46
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
47
|
+
throw new Error(`Planner next_tasks[${index}].id is required`);
|
|
48
|
+
if (ids.has(id))
|
|
49
|
+
throw new Error(`Planner next_tasks contains duplicate id ${id}`);
|
|
50
|
+
ids.add(id);
|
|
51
|
+
if (typeof task.input !== 'string' || task.input.length === 0)
|
|
52
|
+
throw new Error(`Planner next_tasks[${index}].input is required`);
|
|
53
|
+
const adapter = task.adapter === undefined ? 'shell' : task.adapter;
|
|
54
|
+
if (!['shell', 'codex', 'chatgpt', 'orchestrator'].includes(String(adapter)))
|
|
55
|
+
throw new Error(`Planner next_tasks[${index}].adapter is invalid`);
|
|
56
|
+
if (task.priority !== undefined && !['P0', 'P1', 'P2', 'P3'].includes(String(task.priority)))
|
|
57
|
+
throw new Error(`Planner next_tasks[${index}].priority is invalid`);
|
|
58
|
+
const maxAttempts = task.maxAttempts === undefined ? 3 : task.maxAttempts;
|
|
59
|
+
const timeoutMs = task.timeoutMs === undefined ? 300000 : task.timeoutMs;
|
|
60
|
+
if (typeof maxAttempts !== 'number' || !Number.isInteger(maxAttempts) || maxAttempts < 1)
|
|
61
|
+
throw new Error(`Planner next_tasks[${index}].maxAttempts is invalid`);
|
|
62
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
63
|
+
throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
|
|
64
|
+
return {
|
|
65
|
+
id, input: task.input, priority: task.priority,
|
|
66
|
+
projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'),
|
|
67
|
+
dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'),
|
|
68
|
+
adapter: adapter, routing: routing(task.routing, `next_tasks[${index}].routing`),
|
|
69
|
+
continuation: task.continuation, maxAttempts, timeoutMs
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return { objective_status: status, assessment: root.assessment, next_tasks };
|
|
73
|
+
}
|
|
74
|
+
export function parsePlannerOutput(output) {
|
|
75
|
+
const trimmed = output.trim();
|
|
76
|
+
const candidate = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
|
|
77
|
+
return parsePlannerResult(JSON.parse(candidate));
|
|
78
|
+
}
|
|
79
|
+
export function plannerInput(context) {
|
|
80
|
+
return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
|
|
81
|
+
}
|
|
82
|
+
export class CommandObjectivePlanner {
|
|
83
|
+
command;
|
|
84
|
+
constructor(command) {
|
|
85
|
+
this.command = command;
|
|
86
|
+
if (!command)
|
|
87
|
+
throw new Error('Planner command is required');
|
|
88
|
+
}
|
|
89
|
+
async plan(context, signal) {
|
|
90
|
+
return await new Promise((resolve, reject) => {
|
|
91
|
+
const child = spawn(this.command, [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
92
|
+
let stdout = '';
|
|
93
|
+
let stderr = '';
|
|
94
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
95
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
96
|
+
child.on('error', reject);
|
|
97
|
+
child.on('close', code => code === 0 ? (() => { try {
|
|
98
|
+
resolve(parsePlannerOutput(stdout));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
reject(error);
|
|
102
|
+
} })() : reject(new Error(stderr.trim() || `Planner exited ${code}`)));
|
|
103
|
+
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
104
|
+
child.stdin.end(plannerInput(context));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const DEFAULT_PLANNER_AGENT = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'agents-relay', 'agents', 'planner.agent.md');
|
|
109
|
+
function parsePlannerAgentMessage(line) {
|
|
110
|
+
try {
|
|
111
|
+
const value = JSON.parse(line);
|
|
112
|
+
const item = value.item;
|
|
113
|
+
if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
114
|
+
return item.text.trim() || undefined;
|
|
115
|
+
const payload = value.payload;
|
|
116
|
+
if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string')
|
|
117
|
+
return payload.last_agent_message.trim() || undefined;
|
|
118
|
+
}
|
|
119
|
+
catch { /* ignore non-JSON runtime diagnostics */ }
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
export class AgentObjectivePlanner {
|
|
123
|
+
command;
|
|
124
|
+
agentFile;
|
|
125
|
+
constructor(command = 'codex', agentFile = DEFAULT_PLANNER_AGENT) {
|
|
126
|
+
this.command = command;
|
|
127
|
+
this.agentFile = agentFile;
|
|
128
|
+
}
|
|
129
|
+
async plan(context, signal) {
|
|
130
|
+
const instructions = await readFile(this.agentFile, 'utf8');
|
|
131
|
+
const prompt = instructions.trim() + '\n\n## Durable planner input\n\n' + plannerInput(context);
|
|
132
|
+
return await new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn(this.command, ['exec', '--json', '--', prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
134
|
+
let stdoutBuffer = '';
|
|
135
|
+
let finalMessage = '';
|
|
136
|
+
let stderrTail = '';
|
|
137
|
+
const consume = (line) => {
|
|
138
|
+
const message = parsePlannerAgentMessage(line);
|
|
139
|
+
if (message)
|
|
140
|
+
finalMessage = message;
|
|
141
|
+
};
|
|
142
|
+
child.stdout.on('data', (chunk) => {
|
|
143
|
+
stdoutBuffer += chunk.toString();
|
|
144
|
+
const lines = stdoutBuffer.split(/\r?\n/);
|
|
145
|
+
stdoutBuffer = lines.pop() ?? '';
|
|
146
|
+
for (const line of lines)
|
|
147
|
+
consume(line);
|
|
148
|
+
});
|
|
149
|
+
child.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-16384); });
|
|
150
|
+
child.on('error', reject);
|
|
151
|
+
child.on('close', code => {
|
|
152
|
+
if (stdoutBuffer)
|
|
153
|
+
consume(stdoutBuffer);
|
|
154
|
+
if (code !== 0) {
|
|
155
|
+
reject(new Error(stderrTail.trim() || 'Planner agent runtime exited ' + code));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!finalMessage) {
|
|
159
|
+
reject(new Error('Planner agent returned no final message'));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
resolve(parsePlannerOutput(finalMessage));
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
reject(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
package/dist/pool.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { GitHubStore, listManagedGitHubJobs } from './store.js';
|
|
2
|
+
import { eventFor } from './events.js';
|
|
3
|
+
const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
4
|
+
export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
5
|
+
export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' && !item.draft && !['COMPLETED', 'CANCELLED'].includes(item.job.state)).map(item => item.job).sort(compareJobPriority); }
|
|
6
|
+
export function needsLifecycleReconciliation(job, now = Date.now()) {
|
|
7
|
+
if (job.tasks.some(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)))
|
|
8
|
+
return true;
|
|
9
|
+
return job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
|
|
10
|
+
}
|
|
11
|
+
export function terminalManagedJobs(items) { return items.filter(item => item.githubState !== 'OPEN' && ((item.githubState === 'MERGED' && item.job.state !== 'COMPLETED') || (item.githubState === 'CLOSED' && item.job.state !== 'CANCELLED'))).map(item => item.job).sort(compareJobPriority); }
|
|
12
|
+
export async function aggregateManagedGitHubJobs(client, repositories, trustedAuthors) {
|
|
13
|
+
const uniqueRepositories = [...new Set(repositories)];
|
|
14
|
+
const groups = await Promise.all(uniqueRepositories.map(repository => listManagedGitHubJobs(client, repository, trustedAuthors)));
|
|
15
|
+
return groups.flat().sort((a, b) => b.job.updatedAt.localeCompare(a.job.updatedAt) || b.job.prNumber - a.job.prNumber);
|
|
16
|
+
}
|
|
17
|
+
export class RepositoryWorkerPool {
|
|
18
|
+
client;
|
|
19
|
+
trustedAuthors;
|
|
20
|
+
concurrency;
|
|
21
|
+
makeReconciler;
|
|
22
|
+
running = false;
|
|
23
|
+
pending = false;
|
|
24
|
+
constructor(client, repository, trustedAuthors, concurrency, makeReconciler) {
|
|
25
|
+
this.client = client;
|
|
26
|
+
this.trustedAuthors = trustedAuthors;
|
|
27
|
+
this.concurrency = concurrency;
|
|
28
|
+
this.makeReconciler = makeReconciler;
|
|
29
|
+
this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)];
|
|
30
|
+
}
|
|
31
|
+
repositories;
|
|
32
|
+
async reconcileDiscovered(discovered) {
|
|
33
|
+
// Terminal PRs must be reconciled before OPEN-job scheduling. Otherwise the
|
|
34
|
+
// OPEN-only runnable filter can strand durable jobs after GitHub merges/closes them.
|
|
35
|
+
for (const job of terminalManagedJobs(discovered)) {
|
|
36
|
+
const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
|
|
37
|
+
await this.makeReconciler(store, 0).reconcile(job.id);
|
|
38
|
+
}
|
|
39
|
+
const jobs = runnableManagedJobs(discovered);
|
|
40
|
+
let capacity = Math.max(1, this.concurrency);
|
|
41
|
+
for (const job of jobs) {
|
|
42
|
+
if (capacity <= 0)
|
|
43
|
+
break;
|
|
44
|
+
const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
|
|
45
|
+
const ready = job.tasks.filter(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)).length;
|
|
46
|
+
const expiredRunning = job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
|
|
47
|
+
if (!needsLifecycleReconciliation(job) && job.tasks.length > 0)
|
|
48
|
+
continue;
|
|
49
|
+
const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
|
|
50
|
+
await this.makeReconciler(store, allowance).reconcile(job.id);
|
|
51
|
+
capacity -= allowance;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async reconcileKnown(discovered) { await this.reconcileDiscovered(discovered); }
|
|
55
|
+
async reconcile() {
|
|
56
|
+
if (this.running) {
|
|
57
|
+
this.pending = true;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.running = true;
|
|
61
|
+
try {
|
|
62
|
+
do {
|
|
63
|
+
this.pending = false;
|
|
64
|
+
await this.reconcileDiscovered(await aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors));
|
|
65
|
+
} while (this.pending);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
this.running = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async reconcileTarget(repository, prNumber, jobId) {
|
|
72
|
+
if (!this.repositories.includes(repository))
|
|
73
|
+
return;
|
|
74
|
+
const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
|
|
75
|
+
await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
|
|
76
|
+
}
|
|
77
|
+
async watch(bus) {
|
|
78
|
+
return bus.subscribeAll(async (event) => {
|
|
79
|
+
if (event.type === 'job.wake' || event.type === 'github.webhook') {
|
|
80
|
+
const repository = typeof event.data?.repository === 'string' ? event.data.repository : '';
|
|
81
|
+
const prNumber = Number(event.data?.prNumber);
|
|
82
|
+
if (repository && Number.isInteger(prNumber) && prNumber > 0) {
|
|
83
|
+
await this.reconcileTarget(repository, prNumber, event.job_id);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (event.type === 'job.wake' || event.type === 'github.webhook')
|
|
88
|
+
await this.reconcile();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async webhookWake(bus, job, deliveryId, event, action) {
|
|
92
|
+
if (bus) {
|
|
93
|
+
await bus.publish(eventFor(job.id, 'github', null, 'github.webhook', 'queued', `GitHub webhook ${event}${action ? ':' + action : ''}`, 'orchestrator', { deliveryId, event, action, repository: job.repository, prNumber: job.prNumber }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await this.reconcileTarget(job.repository, job.prNumber, job.id);
|
|
97
|
+
}
|
|
98
|
+
}
|