@andromarces/agent-loops 0.2.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 +461 -0
- package/docs/orchestrator-instructions.md +166 -0
- package/package.json +55 -0
- package/src/agents/agy.mjs +46 -0
- package/src/agents/claude.mjs +85 -0
- package/src/agents/codex.mjs +73 -0
- package/src/agents/copilot.mjs +86 -0
- package/src/agents/index.mjs +37 -0
- package/src/agents/opencode.mjs +152 -0
- package/src/cli.mjs +338 -0
- package/src/contracts/orchestrator-action.mjs +74 -0
- package/src/entrypoints/copilot.mjs +57 -0
- package/src/hook/copilot-parent-guard.mjs +35 -0
- package/src/hook/decision.mjs +27 -0
- package/src/hook/parent-guard.mjs +40 -0
- package/src/lib/args.mjs +43 -0
- package/src/lib/entrypoint.mjs +17 -0
- package/src/lib/exec.mjs +100 -0
- package/src/lib/json.mjs +53 -0
- package/src/lib/log.mjs +44 -0
- package/src/lib/report.mjs +89 -0
- package/src/lib/runstate.mjs +207 -0
- package/src/lib/snapshot.mjs +231 -0
- package/src/orchestrator.mjs +54 -0
- package/src/prompts/orchestrator.mjs +73 -0
- package/src/prompts/report.mjs +10 -0
- package/src/prompts/reviewer.mjs +17 -0
- package/src/prompts/worker.mjs +21 -0
- package/src/role.mjs +661 -0
- package/src/runtime.mjs +185 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import { logDebug, logError } from "./log.mjs";
|
|
6
|
+
|
|
7
|
+
export class MutationError extends Error {
|
|
8
|
+
constructor(role, paths) {
|
|
9
|
+
super(`Mutation detected during ${role} turn: ${paths.join(", ")}`);
|
|
10
|
+
this.name = "MutationError";
|
|
11
|
+
this.role = role;
|
|
12
|
+
this.paths = paths;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Snapshot failure: a turn's mutation state could not be determined, so no verdict exists.
|
|
17
|
+
// Precedence note: if the agent turn was canceled (SIGINT) and the post-turn snapshot also fails,
|
|
18
|
+
// SnapshotError wins and the cancellation is not preserved; the caller exits 1, not 130.
|
|
19
|
+
export class SnapshotError extends Error {
|
|
20
|
+
constructor(message, options) {
|
|
21
|
+
super(message, options);
|
|
22
|
+
this.name = "SnapshotError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function assertGitWorkTree(cwd) {
|
|
27
|
+
const result = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
28
|
+
cwd,
|
|
29
|
+
reject: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (result.exitCode !== 0 || result.stdout.trim() !== "true") {
|
|
33
|
+
throw new SnapshotError(`--cwd must be inside a Git work tree: ${cwd}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sha256(data) {
|
|
38
|
+
return createHash("sha256").update(data).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Returns null only for an absent or non-regular file; read errors other than ENOENT propagate.
|
|
42
|
+
export async function sha256File(path) {
|
|
43
|
+
let s;
|
|
44
|
+
try {
|
|
45
|
+
s = await stat(path);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code === "ENOENT" || err.code === "EISDIR") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
if (!s.isFile()) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const content = await readFile(path);
|
|
57
|
+
return sha256(content);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code === "ENOENT") {
|
|
60
|
+
// Raced with deletion between stat and read; treat as absent.
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function gitIn(cwd, args) {
|
|
68
|
+
const result = await execa("git", args, { cwd, reject: false });
|
|
69
|
+
if (result.exitCode !== 0) {
|
|
70
|
+
throw new SnapshotError(
|
|
71
|
+
`git ${args[0]} failed (exit ${result.exitCode}): ${result.stderr.trim()}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function snapshot(cwd) {
|
|
78
|
+
// git status/ls-files emit repository-root-relative paths, so run them at the root.
|
|
79
|
+
const rootResult = await execa("git", ["rev-parse", "--show-toplevel"], {
|
|
80
|
+
cwd,
|
|
81
|
+
reject: false,
|
|
82
|
+
});
|
|
83
|
+
if (rootResult.exitCode !== 0) {
|
|
84
|
+
throw new SnapshotError(`--cwd must be inside a Git work tree: ${cwd}`);
|
|
85
|
+
}
|
|
86
|
+
const root = rootResult.stdout.trim();
|
|
87
|
+
|
|
88
|
+
// 1. Work tree status
|
|
89
|
+
const statusResult = await gitIn(root, [
|
|
90
|
+
"status",
|
|
91
|
+
"--porcelain=v1",
|
|
92
|
+
"-z",
|
|
93
|
+
"--untracked-files=all",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const statusTokens = statusResult.stdout.split("\0");
|
|
97
|
+
const entries = [];
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < statusTokens.length; i++) {
|
|
100
|
+
const token = statusTokens[i];
|
|
101
|
+
if (!token) continue;
|
|
102
|
+
const status = token.slice(0, 2);
|
|
103
|
+
const filePath = token.slice(3);
|
|
104
|
+
|
|
105
|
+
// If rename/copy (status 'R' or 'C'), -z outputs <new-path>\0<old-path>\0.
|
|
106
|
+
// filePath is already <new-path>; consume <old-path> in nextToken so it is not processed as a file.
|
|
107
|
+
if (status.includes("R") || status.includes("C")) {
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fullPath = join(root, filePath);
|
|
112
|
+
let hash;
|
|
113
|
+
try {
|
|
114
|
+
hash = await sha256File(fullPath);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw new SnapshotError(`failed to hash ${filePath}: ${err.message}`, { cause: err });
|
|
117
|
+
}
|
|
118
|
+
entries.push({ path: filePath, status, hash });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
122
|
+
|
|
123
|
+
// 2. Index hash (whole repository, not just cwd)
|
|
124
|
+
const lsResult = await gitIn(root, ["ls-files", "--stage", "-z"]);
|
|
125
|
+
const indexHash = sha256(lsResult.stdout);
|
|
126
|
+
|
|
127
|
+
// 3. HEAD; exit 1 here means a repository without commits, so "unborn" is correct.
|
|
128
|
+
const headResult = await execa("git", ["rev-parse", "--verify", "-q", "HEAD"], {
|
|
129
|
+
cwd: root,
|
|
130
|
+
reject: false,
|
|
131
|
+
});
|
|
132
|
+
const head = headResult.exitCode === 0 ? headResult.stdout.trim() : "unborn";
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
workTree: entries,
|
|
136
|
+
indexHash,
|
|
137
|
+
head,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Diff two snapshots. Returns the sorted changed work-tree paths, plus the
|
|
143
|
+
* sentinel entries `<index>` and `<HEAD>` when the index or HEAD changed.
|
|
144
|
+
* @param {ReturnType<typeof snapshot>} before
|
|
145
|
+
* @param {ReturnType<typeof snapshot>} after
|
|
146
|
+
* @returns {string[]}
|
|
147
|
+
*/
|
|
148
|
+
export function diffSnapshots(before, after) {
|
|
149
|
+
const changed = new Set();
|
|
150
|
+
|
|
151
|
+
const beforeMap = new Map(before.workTree.map((e) => [e.path, e]));
|
|
152
|
+
const afterMap = new Map(after.workTree.map((e) => [e.path, e]));
|
|
153
|
+
|
|
154
|
+
for (const [path, b] of beforeMap.entries()) {
|
|
155
|
+
const a = afterMap.get(path);
|
|
156
|
+
if (!a) {
|
|
157
|
+
// It was dirty and now it's not (e.g. reverted or staged/committed)
|
|
158
|
+
changed.add(path);
|
|
159
|
+
} else if (b.status !== a.status || b.hash !== a.hash) {
|
|
160
|
+
changed.add(path);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const [path] of afterMap.entries()) {
|
|
165
|
+
if (!beforeMap.has(path)) {
|
|
166
|
+
changed.add(path);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = Array.from(changed).sort();
|
|
171
|
+
|
|
172
|
+
if (before.indexHash !== after.indexHash) {
|
|
173
|
+
result.push("<index>");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (before.head !== after.head) {
|
|
177
|
+
result.push("<HEAD>");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Run `fn()` and compare Git snapshots taken before and after.
|
|
185
|
+
* Throws `MutationError` when the diff is non-empty, even when `fn()` already
|
|
186
|
+
* failed: the mutation error wins over the wrapped error, which is discarded.
|
|
187
|
+
* @template T
|
|
188
|
+
* @param {string} cwd
|
|
189
|
+
* @param {string} role
|
|
190
|
+
* @param {() => Promise<T>} fn
|
|
191
|
+
* @returns {Promise<T>}
|
|
192
|
+
*/
|
|
193
|
+
export async function withMutationCheck(cwd, role, fn) {
|
|
194
|
+
const before = await snapshot(cwd);
|
|
195
|
+
logDebug(`snapshot before ${role} turn taken (${before.workTree.length} work tree entries)`);
|
|
196
|
+
let actionError = null;
|
|
197
|
+
let result;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
result = await fn();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
actionError = err;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let after;
|
|
206
|
+
try {
|
|
207
|
+
after = await snapshot(cwd);
|
|
208
|
+
} catch (snapErr) {
|
|
209
|
+
// A failed post-turn snapshot wins over the agent error; the agent error rides as cause.
|
|
210
|
+
if (actionError) {
|
|
211
|
+
throw new SnapshotError(`post-turn snapshot failed during ${role} turn: ${snapErr.message}`, {
|
|
212
|
+
cause: actionError,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
throw snapErr;
|
|
216
|
+
}
|
|
217
|
+
logDebug(`snapshot after ${role} turn taken (${after.workTree.length} work tree entries)`);
|
|
218
|
+
|
|
219
|
+
const diff = diffSnapshots(before, after);
|
|
220
|
+
if (diff.length > 0) {
|
|
221
|
+
const mutationError = new MutationError(role, diff);
|
|
222
|
+
logError(mutationError.message);
|
|
223
|
+
throw mutationError;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (actionError) {
|
|
227
|
+
throw actionError;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { validateAction } from "./contracts/orchestrator-action.mjs";
|
|
2
|
+
import { logWarn } from "./lib/log.mjs";
|
|
3
|
+
import { extractJsonObject } from "./lib/json.mjs";
|
|
4
|
+
import { repairPrompt } from "./prompts/orchestrator.mjs";
|
|
5
|
+
|
|
6
|
+
export class OrchestratorError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "OrchestratorError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function decide({ agent, state, prompt, options = {} }) {
|
|
14
|
+
const { cwd, timeout, signal } = options;
|
|
15
|
+
|
|
16
|
+
async function executeTurn(turnPrompt) {
|
|
17
|
+
return agent.run(state, turnPrompt, {
|
|
18
|
+
cwd,
|
|
19
|
+
readOnly: true,
|
|
20
|
+
timeout,
|
|
21
|
+
signal,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseAndValidate(rawResponse) {
|
|
26
|
+
const extracted = extractJsonObject(rawResponse);
|
|
27
|
+
if (!extracted.ok) {
|
|
28
|
+
return { ok: false, error: extracted.error };
|
|
29
|
+
}
|
|
30
|
+
return validateAction(extracted.value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Attempt 1
|
|
34
|
+
const firstResponse = await executeTurn(prompt);
|
|
35
|
+
const firstValidation = parseAndValidate(firstResponse);
|
|
36
|
+
|
|
37
|
+
if (firstValidation.ok) {
|
|
38
|
+
return firstValidation.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Repair turn
|
|
42
|
+
logWarn(`orchestrator action invalid; taking repair turn: ${firstValidation.error}`);
|
|
43
|
+
const repair = repairPrompt(firstValidation.error);
|
|
44
|
+
const repairResponse = await executeTurn(repair);
|
|
45
|
+
const repairValidation = parseAndValidate(repairResponse);
|
|
46
|
+
|
|
47
|
+
if (repairValidation.ok) {
|
|
48
|
+
return repairValidation.value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new OrchestratorError(
|
|
52
|
+
`Orchestrator returned a malformed action after one repair turn: ${repairValidation.error}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Shared rule source: docs/orchestrator-instructions.md states the role rules
|
|
2
|
+
// for interactive parents (#56); this headless prompt states the same rules in
|
|
3
|
+
// JSON-action form. Keep the two consistent when either changes.
|
|
4
|
+
export function initialPrompt({ task, maxSteps }) {
|
|
5
|
+
return `
|
|
6
|
+
You are the orchestrator in an automated multi-agent coding loop.
|
|
7
|
+
Your role is to direct the workflow to complete the user task.
|
|
8
|
+
You must NOT edit files, and you must NOT run agent CLIs or background processes directly.
|
|
9
|
+
|
|
10
|
+
You have two child roles:
|
|
11
|
+
- worker: Implements changes, runs checks and tests, and reports findings and progress.
|
|
12
|
+
- reviewer: Read-only inspector. Inspects and assesses the repository state and verifications. The reviewer must not edit files.
|
|
13
|
+
|
|
14
|
+
You have a maximum step budget of ${maxSteps} steps.
|
|
15
|
+
A step is consumed only when you dispatch a child role (run_worker or run_reviewer).
|
|
16
|
+
Actions that do NOT consume a step: finish, abort, or repair turns.
|
|
17
|
+
|
|
18
|
+
Respond with one JSON object and nothing else. A \`\`\`json fence is accepted.
|
|
19
|
+
Supported action formats:
|
|
20
|
+
|
|
21
|
+
1. Dispatch worker:
|
|
22
|
+
{"action": "run_worker", "prompt": "<instructions for worker>"}
|
|
23
|
+
|
|
24
|
+
2. Dispatch reviewer:
|
|
25
|
+
{"action": "run_reviewer", "prompt": "<instructions for reviewer>"}
|
|
26
|
+
|
|
27
|
+
3. Finish when work is complete and verified:
|
|
28
|
+
{"action": "finish", "summary": {"changed": "<summary>", "verified": "<summary>", "deferred": "<summary>", "notDone": "<summary>", "open": "<summary>"}}
|
|
29
|
+
|
|
30
|
+
4. Abort if the task cannot proceed:
|
|
31
|
+
{"action": "abort", "reason": "<explanation>"}
|
|
32
|
+
|
|
33
|
+
The finish summary requires non-empty strings for all five keys: changed, verified, deferred, notDone, open.
|
|
34
|
+
|
|
35
|
+
User Task:
|
|
36
|
+
${task}
|
|
37
|
+
`.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resultPrompt({ result, stepsUsed, maxSteps }) {
|
|
41
|
+
const stepsRemaining = Math.max(0, maxSteps - stepsUsed);
|
|
42
|
+
const payload = {
|
|
43
|
+
role: result.role,
|
|
44
|
+
status: result.status,
|
|
45
|
+
...(result.status === "ok" ? { response: result.response } : { error: result.error }),
|
|
46
|
+
stepsUsed,
|
|
47
|
+
stepsRemaining,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return `
|
|
51
|
+
Role execution result:
|
|
52
|
+
${JSON.stringify(payload, null, 2)}
|
|
53
|
+
|
|
54
|
+
Choose the next action.
|
|
55
|
+
Respond with one JSON object and nothing else. A \`\`\`json fence is accepted.
|
|
56
|
+
Supported actions: run_worker, run_reviewer, finish, abort.
|
|
57
|
+
`.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function repairPrompt(error) {
|
|
61
|
+
return `
|
|
62
|
+
Your previous response could not be accepted due to the following validation error:
|
|
63
|
+
${error}
|
|
64
|
+
|
|
65
|
+
Respond with one valid JSON object and nothing else. A \`\`\`json fence is accepted.
|
|
66
|
+
Supported action formats:
|
|
67
|
+
|
|
68
|
+
1. {"action": "run_worker", "prompt": "<string>"}
|
|
69
|
+
2. {"action": "run_reviewer", "prompt": "<string>"}
|
|
70
|
+
3. {"action": "finish", "summary": {"changed": "<string>", "verified": "<string>", "deferred": "<string>", "notDone": "<string>", "open": "<string>"}}
|
|
71
|
+
4. {"action": "abort", "reason": "<string>"}
|
|
72
|
+
`.trim();
|
|
73
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing block every child turn must end with. The orchestrator sees only the child's final
|
|
3
|
+
* response, so the block carries the reasoning and open items it would otherwise re-derive.
|
|
4
|
+
*/
|
|
5
|
+
export const reportBlock = `
|
|
6
|
+
End your response with this block, kept short:
|
|
7
|
+
Conclusion: one or two sentences.
|
|
8
|
+
Why: the decisive evidence behind the conclusion.
|
|
9
|
+
Blockers: anything unresolved that the next turn must know, or "none".
|
|
10
|
+
`.trim();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { reportBlock } from "./report.mjs";
|
|
2
|
+
|
|
3
|
+
// Same closing block as reportBlock, with the verdict line inside it so the
|
|
4
|
+
// model treats it as part of the mandatory block, not an optional extra.
|
|
5
|
+
const reviewerReportBlock = `${reportBlock}
|
|
6
|
+
Verdict: accept or reject. One word, nothing else on the line.`;
|
|
7
|
+
|
|
8
|
+
export function reviewerPrompt(prompt) {
|
|
9
|
+
return `
|
|
10
|
+
Do not implement, fix, edit, or change any file. Review, assess, and verify only. Live probes and read-only queries are authorized.
|
|
11
|
+
|
|
12
|
+
${reviewerReportBlock}
|
|
13
|
+
|
|
14
|
+
Instructions:
|
|
15
|
+
${prompt}
|
|
16
|
+
`.trim();
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { reportBlock } from "./report.mjs";
|
|
2
|
+
|
|
3
|
+
export function workerPrompt(prompt, firstTurn = false) {
|
|
4
|
+
if (!firstTurn) {
|
|
5
|
+
return prompt;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return `
|
|
9
|
+
You are the implementation agent (worker) in an automated loop.
|
|
10
|
+
Your role:
|
|
11
|
+
- Implement the requested changes.
|
|
12
|
+
- Run tests, checks, and verifications to confirm correctness.
|
|
13
|
+
- Report what changed, what was verified, and state any disagreements with evidence.
|
|
14
|
+
- You do NOT decide when the loop ends.
|
|
15
|
+
|
|
16
|
+
${reportBlock}
|
|
17
|
+
|
|
18
|
+
Instructions:
|
|
19
|
+
${prompt}
|
|
20
|
+
`.trim();
|
|
21
|
+
}
|