@staix/agent-research 0.1.0 → 0.2.1
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 +30 -0
- package/README.md +108 -14
- package/package.json +4 -2
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +18 -5
- package/src/commands-table.ts +6 -3
- package/src/commands.ts +56 -15
- package/src/copilot.ts +91 -0
- package/src/exp/attempts.ts +12 -4
- package/src/exp/investigate.ts +7 -2
- package/src/exp/protocol.ts +4 -1
- package/src/http.ts +46 -2
- package/src/lit/watch.ts +14 -1
- package/src/manifest.ts +3 -2
- package/src/mcp-tools.ts +18 -8
- package/src/mcp.ts +2 -0
- package/src/memory/archive.ts +3 -0
- package/src/paper/build.ts +4 -1
- package/src/paper/check.ts +4 -3
- package/src/paper/placeholders.ts +5 -3
- package/src/paper/review.ts +15 -3
- package/src/plan/controller.ts +42 -12
- package/src/plan/definition.ts +17 -5
- package/src/plan/state.ts +53 -17
- package/src/playbook.ts +14 -2
- package/src/study-export.ts +254 -0
- package/src/study.ts +45 -17
- package/src/ui/panel.js +8 -3
- package/src/ui/plugin.ts +21 -7
package/src/commands.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { validateAutonomyMode, validatePhase, PHASES, ACCEPT_ARTIFACTS, resolveAutonomy, type AcceptArtifact, type Autonomy } from "./autonomy.ts";
|
|
2
|
+
import { copilotBinding, requireCopilotAccept } from "./copilot.ts";
|
|
1
3
|
import { homedir } from "node:os";
|
|
2
4
|
import { addStudy, removeStudy, readRegistry } from "./console/registry.ts";
|
|
3
|
-
import { readWatchSnapshot, setWatch, runWatch, showWatch, type WatchSnapshot } from "./lit/watch.ts";
|
|
5
|
+
import { readWatchSnapshot, setWatch, runWatch, showWatch, recordWatchFailure, watchFailureClass, type WatchSnapshot } from "./lit/watch.ts";
|
|
4
6
|
import { runSetup, type SetupMode } from "./setup.ts";
|
|
5
7
|
import { proposeSkill, decideSkill, listSkills } from "./memory/skills.ts";
|
|
6
8
|
import { rankLessons, type LessonRankOptions } from "./memory/lessons.ts";
|
|
@@ -21,11 +23,12 @@ import { spawnRunner } from "./exp/executor.ts";
|
|
|
21
23
|
import { foldPlan, readPlanState, planGateBinding, prepareControl, type ControlRequest, type ControlResult } from "./plan/state.ts";
|
|
22
24
|
import { setPlan } from "./plan/definition.ts";
|
|
23
25
|
export { manifest } from "./manifest.ts";
|
|
26
|
+
import { exportBundle } from "./study-export.ts";
|
|
24
27
|
import { existsSync, readdirSync, utimesSync } from "node:fs";
|
|
25
28
|
import { join, resolve } from "node:path";
|
|
26
29
|
import type { Http } from "./http.ts";
|
|
27
30
|
import { readScope, scopeHash, setScope } from "./scope.ts";
|
|
28
|
-
import { boundedText, commit, decideGate, gateState, GATES, initStudy, openStudy, readBytesBounded, readLedger, readTextBounded, requireGate, sha256, withLock, readPlanOwner, type Actor, type Binder, type GateName, type Study } from "./study.ts";
|
|
31
|
+
import { boundedText, commit, decideGate, gateState, GATES, initStudy, openStudy, readBytesBounded, readLedger, readTextBounded, requireGate, sha256, withLock, readPlanOwner, type Actor, type Binder, type GateName, type Study, STUDY_BYTES } from "./study.ts";
|
|
29
32
|
import { candidatesFile, includedHash, isVerified, loadCandidates, mergeCandidates, sameWork, verifySubject, type Candidate } from "./lit/candidates.ts";
|
|
30
33
|
import { searchSource, SOURCES, type SourceName } from "./lit/sources.ts";
|
|
31
34
|
import { verifyCandidates } from "./lit/verify.ts";
|
|
@@ -43,10 +46,10 @@ export interface Ctx extends ExperimentRuntime { actor: Actor; http: Http; signa
|
|
|
43
46
|
const MAX_IDS = 200, DRAFT_BYTES = 2 * 1024 * 1024, BIB_BYTES = 16 * 1024 * 1024, CARD_BYTES = 65_536;
|
|
44
47
|
|
|
45
48
|
export function binder(dir: string): Binder {
|
|
46
|
-
return gate => gate === "review" ? reviewBinding(dir) : gate === "final" ? finalGateBinding(dir) : gate.startsWith("plan:") ? planGateBinding(readPlanState(dir, readLedger(dir)), gate) : gate === "scope" ? scopeHash(dir) : gate === "design" ? designBinding(dir) : includedHash(loadCandidates(dir));
|
|
49
|
+
return gate => gate.startsWith("accept:") ? copilotBinding(dir, gate.slice(7) as AcceptArtifact) : gate === "review" ? reviewBinding(dir) : gate === "final" ? finalGateBinding(dir) : gate.startsWith("plan:") ? planGateBinding(readPlanState(dir, readLedger(dir)), gate) : gate === "scope" ? scopeHash(dir) : gate === "design" ? designBinding(dir) : includedHash(loadCandidates(dir));
|
|
47
50
|
}
|
|
48
51
|
function gateName(value: string): GateName {
|
|
49
|
-
if (!GATES.includes(value as GateName) && !value.startsWith("plan:")) throw new Error(
|
|
52
|
+
if (!GATES.includes(value as GateName) && !ACCEPT_ARTIFACTS.some(artifact => value === `accept:${artifact}`) && !value.startsWith("plan:")) throw new Error("unknown gate: unsupported name; run ares gate list <dir> and choose a listed gate before approval or rejection");
|
|
50
53
|
return value as GateName;
|
|
51
54
|
}
|
|
52
55
|
function ids(values: string[]): string[] {
|
|
@@ -65,6 +68,24 @@ export function init(dir: string, options: { question: string; mode?: string; au
|
|
|
65
68
|
return { created: resolve(dir), study };
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
export function autonomyShow(dirArg: string) {
|
|
72
|
+
const { study } = openStudy(dirArg), policy = study.autonomy;
|
|
73
|
+
const effective = Object.fromEntries(PHASES.map(phase => [phase, typeof policy === "string" ? policy : policy.phases[phase] ?? policy.default]));
|
|
74
|
+
return { autonomy: policy, effective };
|
|
75
|
+
}
|
|
76
|
+
export async function autonomySet(dirArg: string, rawMode: string, rawPhase: string | undefined, ctx: Pick<Ctx, "actor">) {
|
|
77
|
+
const mode = validateAutonomyMode(rawMode), phase = rawPhase === undefined ? undefined : validatePhase(rawPhase);
|
|
78
|
+
const { dir } = openStudy(dirArg);
|
|
79
|
+
return withLock(dir, () => {
|
|
80
|
+
const { study } = openStudy(dir), from = study.autonomy;
|
|
81
|
+
const to: Autonomy = phase !== undefined ? { default: typeof from === "string" ? from : from.default, phases: { ...(typeof from === "string" ? {} : from.phases), [phase]: mode } }
|
|
82
|
+
: typeof from === "string" ? mode : { default: mode, phases: { ...from.phases } };
|
|
83
|
+
const v = study.v === 2 || typeof to !== "string" || to === "copilot" || to === "step" ? 2 : 1;
|
|
84
|
+
commit(dir, [[join(dir, "study.json"), JSON.stringify({ ...study, v, autonomy: to }, null, 2) + "\n", STUDY_BYTES]], [{ actor: ctx.actor, type: "autonomy.set", data: { from, to, phase: phase ?? null } }]);
|
|
85
|
+
return autonomyShow(dir);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
export async function protocol(dirArg: string, raw: unknown, ctx: Ctx) {
|
|
69
90
|
const { dir } = openStudy(dirArg);
|
|
70
91
|
return withLock(dir, () => {
|
|
@@ -103,6 +124,8 @@ export function status(dirArg: string) {
|
|
|
103
124
|
};
|
|
104
125
|
}
|
|
105
126
|
|
|
127
|
+
export const studyExport = (dir: string, options: { bundle: string; list?: boolean }) => exportBundle(dir, options);
|
|
128
|
+
|
|
106
129
|
export async function scope(dirArg: string, raw: unknown, ctx: Ctx) {
|
|
107
130
|
const { dir } = openStudy(dirArg);
|
|
108
131
|
return withLock(dir, () => {
|
|
@@ -114,7 +137,8 @@ export async function scope(dirArg: string, raw: unknown, ctx: Ctx) {
|
|
|
114
137
|
export function gateList(dirArg: string) {
|
|
115
138
|
const { dir, study } = openStudy(dirArg), events = readLedger(dir), bind = binder(dir);
|
|
116
139
|
const plan = foldPlan(events);
|
|
117
|
-
const
|
|
140
|
+
const accepts = ACCEPT_ARTIFACTS.filter(artifact => resolveAutonomy(study.autonomy, `accept:${artifact}`) === "copilot").map(artifact => `accept:${artifact}` as GateName);
|
|
141
|
+
const gates: GateName[] = [...accepts, ...GATES.filter(g => (g !== "design" && g !== "final") || study.mode === "experiment"), ...(plan?.nodes.filter(n => n.kind === "gate").map(n => `plan:${n.id}` as GateName) ?? [])];
|
|
118
142
|
return gates.map(g => gateState(g, events, bind));
|
|
119
143
|
}
|
|
120
144
|
|
|
@@ -262,23 +286,26 @@ export async function planControl(dirArg: string, request: ControlRequest, ctx:
|
|
|
262
286
|
const { dir } = openStudy(dirArg);
|
|
263
287
|
const prepared = await withLock(dir, () => {
|
|
264
288
|
const events = readLedger(dir), state = readPlanState(dir, events);
|
|
265
|
-
const prepared = prepareControl(state, events, request, ctx.actor);
|
|
289
|
+
const prepared = prepareControl(state, events, request, ctx.actor, openStudy(dir).study.autonomy);
|
|
266
290
|
if (!prepared.event) return { result: prepared.result };
|
|
267
291
|
const node = state.nodes.find(n => n.id === request.node);
|
|
268
292
|
const attempt = node?.attemptId ? listAttempts(dir).attempts.find(a => a.id === node.attemptId) : undefined;
|
|
269
|
-
if (request.action === "cancel" && attempt) return { attemptId: attempt.id };
|
|
293
|
+
if (request.action === "cancel" && attempt) return { attemptId: attempt.id, dispatchId: node?.dispatchId };
|
|
270
294
|
// A missing record after a crash is uncertain, and explicit retry/cancel may release it.
|
|
271
295
|
if ((request.action === "retry" || request.action === "skip") && attempt?.status === "running") throw new Error(`plan node ${node!.id}: attempt is still running; cancel it or run ares attempt reconcile before retry`);
|
|
272
|
-
commit(dir, [], [prepared.event]);
|
|
296
|
+
commit(dir, [], [prepared.event, ...(prepared.pause ? [prepared.pause] : [])]);
|
|
273
297
|
return { result: prepared.result };
|
|
274
298
|
});
|
|
275
299
|
if (prepared.result) return prepared.result;
|
|
276
300
|
let result: ControlResult | undefined;
|
|
277
301
|
await cancelAttempt(dir, prepared.attemptId!, "plan cancellation", ctx, () => {
|
|
278
302
|
const events = readLedger(dir), state = readPlanState(dir, events);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
303
|
+
// An identical cancel may have committed while this request waited for the attempt control lock.
|
|
304
|
+
const current = prepareControl(state, events, request, ctx.actor, openStudy(dir).study.autonomy);
|
|
305
|
+
if (!current.event) { result = current.result; return []; }
|
|
306
|
+
if (state.nodes.find(node => node.id === request.node)?.dispatchId !== prepared.dispatchId) throw new Error("plan cancel: dispatch changed while cancelling the attempt; inspect ares plan show and retry with a new command-id");
|
|
307
|
+
result = current.result;
|
|
308
|
+
return current.event ? [current.event, ...(current.pause ? [current.pause] : [])] : [];
|
|
282
309
|
});
|
|
283
310
|
return result!;
|
|
284
311
|
}
|
|
@@ -317,6 +344,7 @@ export async function planRun(dirArg: string, options: { parallel?: number }, ct
|
|
|
317
344
|
actor: ctx.actor, ...(ctx.signal ? { signal: ctx.signal } : {}), ...(ctx.sleep ? { sleep: ctx.sleep } : {}),
|
|
318
345
|
prepare: async (node, state) => {
|
|
319
346
|
if (node.command !== "attempt.run") return {};
|
|
347
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
320
348
|
const design = readDesign(dir);
|
|
321
349
|
if (!design) throw new Error("plan attempt: protocol missing; run ares protocol before resume");
|
|
322
350
|
const options = attemptOptions(node, state.retryOf), snapshot = await resolveSnapshot(options.repo, options.commit, ctx.runner ?? spawnRunner);
|
|
@@ -495,19 +523,32 @@ export async function watchRun(dirArg: string, ctx: Ctx) {
|
|
|
495
523
|
}
|
|
496
524
|
export interface WatchStarted { started: true; configSha256: string }
|
|
497
525
|
/** Acknowledge only validated admission; the finite pass keeps the same lock until its receipt is written. */
|
|
498
|
-
export function watchRunDetached(dirArg: string, ctx: Ctx, track?: (completion: Promise<void
|
|
526
|
+
export function watchRunDetached(dirArg: string, ctx: Ctx, track?: (completion: Promise<void>, terminate: () => void) => void): Promise<WatchStarted> {
|
|
499
527
|
const { dir, study } = openStudy(dirArg);
|
|
500
528
|
let accept!: (value: WatchStarted) => void, reject!: (cause: unknown) => void, started = false;
|
|
529
|
+
let terminate = () => {};
|
|
501
530
|
const admission = new Promise<WatchStarted>((resolve, refuse) => { accept = resolve; reject = refuse; });
|
|
502
531
|
const completion = withLock(dir, async () => {
|
|
503
532
|
const snapshot = watchPreflight(dir, study);
|
|
533
|
+
let settled = false;
|
|
534
|
+
terminate = () => {
|
|
535
|
+
if (settled) return; settled = true;
|
|
536
|
+
recordWatchFailure(dir, snapshot, ctx.actor, "terminated");
|
|
537
|
+
console.error("ares watch: terminated; inspect ares watch show and lit list before retrying");
|
|
538
|
+
};
|
|
504
539
|
started = true; accept({ started: true, configSha256: snapshot.sha256 });
|
|
505
|
-
await watchRunLocked(dir, snapshot, ctx);
|
|
540
|
+
try { await watchRunLocked(dir, snapshot, ctx); settled = true; }
|
|
541
|
+
catch (cause) {
|
|
542
|
+
if (settled) return; settled = true;
|
|
543
|
+
const kind = watchFailureClass(cause);
|
|
544
|
+
recordWatchFailure(dir, snapshot, ctx.actor, kind);
|
|
545
|
+
console.error(`ares watch: background pass failed (${kind}); inspect ares watch show and lit list before retrying`);
|
|
546
|
+
}
|
|
506
547
|
}).then(() => {}, cause => {
|
|
507
548
|
if (!started) reject(cause);
|
|
508
|
-
else console.error(
|
|
549
|
+
else console.error(`ares watch: failure receipt could not be written (${watchFailureClass(cause)}); restore study storage, inspect ares watch show and lit list, then retry`);
|
|
509
550
|
});
|
|
510
|
-
track?.(completion);
|
|
551
|
+
track?.(completion, () => terminate());
|
|
511
552
|
return admission;
|
|
512
553
|
}
|
|
513
554
|
export const watchShow = (dir: string) => ({ ...showWatch(dir), newCandidates: list(dir, { status: "new", limit: 500 }) });
|
package/src/copilot.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { lstatSync, opendirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ACCEPT_ARTIFACTS, resolveAutonomy, type AcceptArtifact } from "./autonomy.ts";
|
|
4
|
+
import { canonical } from "./plan/state.ts";
|
|
5
|
+
import { gateState, openStudy, readBytesBounded, readLedger, readTextBounded, sha256 } from "./study.ts";
|
|
6
|
+
|
|
7
|
+
export const COPILOT_HYPOTHESES_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
export const COPILOT_PAPER_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
export const COPILOT_PAPER_FILES = 500, COPILOT_PAPER_DIRECTORIES = 1000, COPILOT_PAPER_DEPTH = 64;
|
|
10
|
+
export class CopilotAcceptError extends Error {}
|
|
11
|
+
class DraftReadError extends CopilotAcceptError {}
|
|
12
|
+
function refuse(artifact: AcceptArtifact, reason: string): never {
|
|
13
|
+
throw new DraftReadError(`copilot draft ${artifact}: ${reason}; restore the last valid bounded regular draft files and ledger, then run ares gate list and ares gate approve <dir> accept:${artifact} before retrying`);
|
|
14
|
+
}
|
|
15
|
+
function draftLedger(dir: string, artifact: AcceptArtifact) {
|
|
16
|
+
try { return readLedger(dir); }
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Error && /^ledger\.jsonl line [0-9]+: unsupported schema version/.test(error.message)) throw new DraftReadError(`copilot draft ${artifact}: unsupported ledger version; keep its bytes unchanged and use a compatible ares version before ares gate approve <dir> accept:${artifact}`);
|
|
19
|
+
refuse(artifact, "acceptance ledger is unreadable, malformed or over-bound");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function metadata(file: string) {
|
|
23
|
+
try { return lstatSync(file); }
|
|
24
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
25
|
+
}
|
|
26
|
+
function paperTree(dir: string): [string, string][] {
|
|
27
|
+
const root = join(dir, "paper"), entries: [string, string][] = [];
|
|
28
|
+
const rootStat = metadata(root);
|
|
29
|
+
if (rootStat === undefined) return entries;
|
|
30
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) refuse("paper", "paper source root must be a directory without symbolic links");
|
|
31
|
+
const derived = metadata(join(root, "build"));
|
|
32
|
+
let bytes = 0, directories = 0;
|
|
33
|
+
function walk(relative: string, depth: number): void {
|
|
34
|
+
// paper/build is derived output; manuscript sources include all other notes and assets.
|
|
35
|
+
if (relative === "build") return;
|
|
36
|
+
const file = relative ? join(root, relative) : root, stat = lstatSync(file);
|
|
37
|
+
// Respect the filesystem's case aliases without excluding a distinct source directory on case-sensitive hosts.
|
|
38
|
+
if (depth === 1 && relative.toLowerCase() === "build" && derived && derived.dev === stat.dev && derived.ino === stat.ino) return;
|
|
39
|
+
if (stat.isSymbolicLink()) refuse("paper", "symbolic links refused");
|
|
40
|
+
if (stat.isDirectory()) {
|
|
41
|
+
if (depth > COPILOT_PAPER_DEPTH || ++directories > COPILOT_PAPER_DIRECTORIES) refuse("paper", `source tree exceeds ${COPILOT_PAPER_DIRECTORIES} directories or ${COPILOT_PAPER_DEPTH} levels`);
|
|
42
|
+
const directory = opendirSync(file);
|
|
43
|
+
try { for (let entry = directory.readSync(); entry; entry = directory.readSync()) walk(relative ? `${relative}/${entry.name}` : entry.name, depth + 1); }
|
|
44
|
+
finally { directory.closeSync(); }
|
|
45
|
+
} else {
|
|
46
|
+
if (!relative || !stat.isFile()) refuse("paper", "regular files within the paper directory required");
|
|
47
|
+
if (entries.length >= COPILOT_PAPER_FILES) refuse("paper", `file count exceeds ${COPILOT_PAPER_FILES}`);
|
|
48
|
+
if (stat.size > COPILOT_PAPER_BYTES - bytes) refuse("paper", `aggregate bytes exceed ${COPILOT_PAPER_BYTES}`);
|
|
49
|
+
const content = readBytesBounded(file, COPILOT_PAPER_BYTES - bytes, { noFollow: true });
|
|
50
|
+
bytes += content.byteLength; entries.push([relative, sha256(content)]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
walk("", 0);
|
|
54
|
+
return entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Only active, present drafts have accept bindings. Irrelevant and acceptance events are excluded. */
|
|
58
|
+
export function copilotBinding(dirArg: string, artifact: AcceptArtifact): string | undefined {
|
|
59
|
+
if (!ACCEPT_ARTIFACTS.includes(artifact)) throw new Error("copilot draft: unknown artifact; select hypotheses, paper or reviews and retry ares gate list");
|
|
60
|
+
const { dir, study } = openStudy(dirArg);
|
|
61
|
+
if (resolveAutonomy(study.autonomy, `accept:${artifact}`) !== "copilot") return undefined;
|
|
62
|
+
try {
|
|
63
|
+
if (artifact === "paper") {
|
|
64
|
+
const files = paperTree(dir);
|
|
65
|
+
return files.length ? sha256(canonical(files)) : undefined;
|
|
66
|
+
}
|
|
67
|
+
const events = draftLedger(dir, artifact).filter(event => artifact === "hypotheses" ? event.type === "idea.added" || event.type === "idea.decided" : event.type === "review.added" || event.type === "review.resolved");
|
|
68
|
+
if (artifact === "reviews") return events.length ? sha256(canonical(events)) : undefined;
|
|
69
|
+
const file = join(dir, "hypotheses.md"), stat = metadata(file);
|
|
70
|
+
let hypotheses: string | undefined;
|
|
71
|
+
if (stat !== undefined) {
|
|
72
|
+
if (stat.isSymbolicLink() || !stat.isFile()) refuse(artifact, "regular hypotheses file required; symbolic links refused");
|
|
73
|
+
if (stat.size > COPILOT_HYPOTHESES_BYTES) refuse(artifact, `hypotheses source exceeds ${COPILOT_HYPOTHESES_BYTES} bytes`);
|
|
74
|
+
hypotheses = readTextBounded(file, COPILOT_HYPOTHESES_BYTES, { noFollow: true });
|
|
75
|
+
}
|
|
76
|
+
return hypotheses !== undefined || events.length ? sha256(canonical([hypotheses === undefined ? null : sha256(hypotheses), events])) : undefined;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof DraftReadError) throw error;
|
|
79
|
+
refuse(artifact, "artifact or ledger is unreadable, non-regular, invalid UTF-8 or over-bound");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Acceptance records the existing gate actor; neither agents nor people receive an implicit approval. */
|
|
84
|
+
export function requireCopilotAccept(dir: string, artifact: AcceptArtifact): void {
|
|
85
|
+
const binding = copilotBinding(dir, artifact);
|
|
86
|
+
if (binding === undefined) return;
|
|
87
|
+
const events = draftLedger(dir, artifact);
|
|
88
|
+
const gate = `accept:${artifact}` as const, state = gateState(gate, events, () => binding);
|
|
89
|
+
if (state.state === "approved") return;
|
|
90
|
+
throw new CopilotAcceptError(`copilot draft ${artifact}: ${state.state} acceptance; run ares gate approve <dir> ${gate} for the current draft before retrying`);
|
|
91
|
+
}
|
package/src/exp/attempts.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
2
|
+
import { resolveAutonomy } from "../autonomy.ts";
|
|
1
3
|
import { readFinalSeeds, authorizeFinalAttempt, finalGateBinding, requirePosthocClosed } from "./final.ts";
|
|
2
4
|
import { closeSync, existsSync, lstatSync, openSync, opendirSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
3
5
|
import { hostname } from "node:os";
|
|
@@ -28,6 +30,8 @@ export interface AttemptContext { actor: Actor; runner?: ProcessRunner; now?: ()
|
|
|
28
30
|
export const ATTEMPT_BYTES = 1024 * 1024;
|
|
29
31
|
// Startup includes bounded image pulls and two 5-minute transfers; claims never survive indefinitely after a crash.
|
|
30
32
|
export const CONTROL_STALE_MS = 30 * 60_000;
|
|
33
|
+
// Filesystem mtime has sub-ms precision; Date.now is integral. Tolerate small future skew on live locks.
|
|
34
|
+
export const CONTROL_FUTURE_SKEW_MS = 60_000;
|
|
31
35
|
const controlScope = new AsyncLocalStorage<{ dir: string; token: string }>();
|
|
32
36
|
const STATUSES: AttemptStatus[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
|
|
33
37
|
const inspectFields = (prefix: string, fields: string[]) => fields.map(field => `${JSON.stringify(field)}:{{json ${prefix}.${field}}}`).join(",");
|
|
@@ -159,7 +163,7 @@ async function withControl<T>(dir: string, fn: () => Promise<T>): Promise<T> {
|
|
|
159
163
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
160
164
|
let age = 0; try { age = Date.now() - statSync(path).mtimeMs; } catch { /* owner removed it */ }
|
|
161
165
|
if (performance.now() - waitingSince >= 2000) throw new Error("attempt control: another operation is active; retry reconcile or cancel when it finishes (abandoned claims expire after 30 minutes)");
|
|
162
|
-
if (age <
|
|
166
|
+
if (age < -CONTROL_FUTURE_SKEW_MS || age >= CONTROL_STALE_MS) {
|
|
163
167
|
try { unlinkSync(path); }
|
|
164
168
|
catch (failure) { if ((failure as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("attempt control: abandoned lock could not be removed; restore study-directory ownership and permissions, then retry"); }
|
|
165
169
|
continue;
|
|
@@ -240,10 +244,12 @@ async function recordVerifiedStage(dir: string, attempt: Attempt, state: Observe
|
|
|
240
244
|
if (current.stage === "candidate") {
|
|
241
245
|
current.sealing.candidateHasNoEvaluator = true;
|
|
242
246
|
if (approveDesign && current.pendingAutoApproval === current.sealBinding && designBinding(dir) === current.sealBinding) {
|
|
243
|
-
|
|
247
|
+
const study = openStudy(dir).study;
|
|
248
|
+
if (resolveAutonomy(study.autonomy, "design") === "auto") approvals.push(...requireGate(dir, study, "design", () => current.sealBinding));
|
|
244
249
|
}
|
|
245
250
|
if (approveDesign && current.pendingFinalApproval === current.finalBinding && current.finalBinding && finalGateBinding(dir) === current.finalBinding) {
|
|
246
|
-
|
|
251
|
+
const study = openStudy(dir).study;
|
|
252
|
+
if (resolveAutonomy(study.autonomy, "final") === "auto") approvals.push(...requireGate(dir, study, "final", () => current.finalBinding));
|
|
247
253
|
}
|
|
248
254
|
delete current.pendingAutoApproval; delete current.pendingFinalApproval;
|
|
249
255
|
} else {
|
|
@@ -319,6 +325,7 @@ async function captureStage(dir: string, attempt: Attempt, ctx: AttemptContext,
|
|
|
319
325
|
export async function runAttempt(dirArg: string, options: AttemptOptions, ctx: AttemptContext): Promise<Attempt> {
|
|
320
326
|
const dir = studyDir(dirArg), { study } = openStudy(dir);
|
|
321
327
|
const frozen = await withLock(dir, () => {
|
|
328
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
322
329
|
const design = readDesign(dir), executor = readExecutor(dir);
|
|
323
330
|
if (!design) throw new Error("protocol: missing; run ares protocol <dir> --file PATH before attempt run");
|
|
324
331
|
if (!executor) throw new Error("executor: missing; run ares executor set <dir> --kind local|ssh --workdir PATH before attempt run");
|
|
@@ -338,13 +345,14 @@ export async function runAttempt(dirArg: string, options: AttemptOptions, ctx: A
|
|
|
338
345
|
if (options.expectedId !== undefined && wanted !== options.expectedId) throw new Error("attempt identity: changed since plan intent; reconcile the plan node before retrying");
|
|
339
346
|
let created = false;
|
|
340
347
|
let attempt = await withLock(dir, () => {
|
|
348
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
341
349
|
if (run === "posthoc") requirePosthocClosed(dir);
|
|
342
350
|
if (finalAuthorization && authorizeFinalAttempt(dir, options.commit, seed).binding !== finalAuthorization.binding) throw new Error("final inputs changed during snapshot resolution; approve the current final gate before retrying");
|
|
343
351
|
const attempts = load(dir), existing = attempts.find(a => a.id === wanted); if (existing) return existing;
|
|
344
352
|
if (designBinding(dir) !== initialBinding) throw new Error("design: changed while resolving the snapshot; approve the current design and retry attempt run");
|
|
345
353
|
if (options.retry && !attempts.some(a => a.id === options.retry)) throw new Error("retry: attempt not found; choose an existing id from ares attempt list");
|
|
346
354
|
if (attempts.some(a => a.status === "running")) throw new Error("attempt: another attempt is running; reconcile or cancel it before starting a new one");
|
|
347
|
-
const binding = designBinding(dir), approval = requireGate(dir, study, "design", () => binding);
|
|
355
|
+
const binding = designBinding(dir), approval = requireGate(dir, openStudy(dir).study, "design", () => binding);
|
|
348
356
|
enforceBudget(protocol, attempts, now(ctx));
|
|
349
357
|
const root = posix.join(executor.workdir, study.id, wanted);
|
|
350
358
|
const record: Attempt = { v: 1, id: wanted, studyId: study.id, run, protocolHash: hash, sealBinding: binding!, ...snapshot, seed, label, ...(options.retry ? { retryOf: options.retry } : {}), executor, protocol, paths: { snapshot: posix.join(root, "snapshot"), output: posix.join(root, "output"), evaluator: posix.join(root, "task", "evaluator"), result: posix.join(root, "result") }, status: "running", stage: "candidate", startedAt: at(ctx), stages: {}, metrics: [], outputs: [], sealing: { separateContainers: false, evaluatorReadOnly: false, candidateHasNoEvaluator: false, verifiedBy: "ares-observed" }, launching: owner(ctx) };
|
package/src/exp/investigate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
1
2
|
import { readFinalSeeds, finalSeedsHash } from "./final.ts";
|
|
2
3
|
import { foldIdeas } from "./ideas.ts";
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
@@ -162,7 +163,9 @@ export function showInvestigation(dirArg: string): InvestigateResult {
|
|
|
162
163
|
}
|
|
163
164
|
/** Caller holds the study lock; every refusal precedes the single file/event commit. */
|
|
164
165
|
export function closeInvestigation(dirArg: string, actor: Actor): InvestigateResult {
|
|
165
|
-
const input = inputs(dirArg)
|
|
166
|
+
const input = inputs(dirArg);
|
|
167
|
+
requireCopilotAccept(input.dir, "hypotheses");
|
|
168
|
+
const file = investigateResultPath(input.dir, input.rule.run), previous = previousResult(file);
|
|
166
169
|
if (input.attempts.some(a => a.status === "running")) throw new Error("investigate close: an attempt in the selected run is running; run ares attempt reconcile before closing");
|
|
167
170
|
const result = calculate(input), text = JSON.stringify(result, null, 2) + "\n";
|
|
168
171
|
if (previous !== text) commit(input.dir, [[file, text, INVESTIGATE_BYTES]], [{ actor, type: "investigate.closed", data: { run: input.rule.run, sha256: sha256(text), ruleSha256: input.ruleSha, winner: result.winner?.commit ?? null } }]);
|
|
@@ -172,7 +175,9 @@ export function closeInvestigation(dirArg: string, actor: Actor): InvestigateRes
|
|
|
172
175
|
|
|
173
176
|
/** Read only a current, committed investigation result, never a hand-edited or stale winner. */
|
|
174
177
|
export function readClosedInvestigation(dirArg: string): { result: InvestigateResult; sha256: string } {
|
|
175
|
-
const input = inputs(dirArg)
|
|
178
|
+
const input = inputs(dirArg);
|
|
179
|
+
requireCopilotAccept(input.dir, "hypotheses");
|
|
180
|
+
const text = previousResult(investigateResultPath(input.dir, input.rule.run));
|
|
176
181
|
if (text === undefined) throw new Error("investigate result: not closed; run ares investigate close before the next phase");
|
|
177
182
|
const hash = sha256(text), receipt = input.events.findLast(e => e.type === "investigate.closed" && e.data.run === input.rule.run);
|
|
178
183
|
if (!receipt || receipt.data.sha256 !== hash) throw new Error("investigate result: bytes do not match the latest close receipt; run ares investigate close to restore the committed result");
|
package/src/exp/protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
1
2
|
import { finalSeedsHash } from "./final.ts";
|
|
2
3
|
import { selectionHash } from "./selection.ts";
|
|
3
4
|
import { existsSync, lstatSync, opendirSync } from "node:fs";
|
|
@@ -136,7 +137,9 @@ export function readDesign(dir: string): { protocol: Protocol; treeHash: string;
|
|
|
136
137
|
/** Caller holds the study lock, as with setScope. Validate everything before any write. */
|
|
137
138
|
export function setProtocol(dir: string, raw: unknown, actor: Actor): Protocol {
|
|
138
139
|
requireExperiment(openStudy(dir).study);
|
|
139
|
-
const protocol = validateProtocol(raw)
|
|
140
|
+
const protocol = validateProtocol(raw);
|
|
141
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
142
|
+
const treeHash = taskTreeHash(dir), text = JSON.stringify(protocol, null, 2) + "\n";
|
|
140
143
|
// Never commit a protocol that makes its existing selection unreadable.
|
|
141
144
|
selectionHash(dir, protocol);
|
|
142
145
|
finalSeedsHash(dir, protocol);
|
package/src/http.ts
CHANGED
|
@@ -15,6 +15,47 @@ export interface Http {
|
|
|
15
15
|
const TIMEOUT_MS = 20_000, MAX_BYTES = 5 * 1024 * 1024;
|
|
16
16
|
const EMAIL = /^[^\s@<>()]{1,64}@[^\s@<>()]{1,190}$/;
|
|
17
17
|
|
|
18
|
+
export const S2_START_INTERVAL_MS = 1_100;
|
|
19
|
+
export interface HttpPacingClock {
|
|
20
|
+
now(): number;
|
|
21
|
+
sleep(ms: number): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface HttpPacing {
|
|
24
|
+
start<T>(request: () => Promise<T>): Promise<T>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Serialize admissions only: an in-flight response never holds the next start. */
|
|
28
|
+
export function createHttpPacing(clock: HttpPacingClock = {
|
|
29
|
+
now: () => performance.now(),
|
|
30
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
31
|
+
}): HttpPacing {
|
|
32
|
+
let tail = Promise.resolve(), lastStart: number | undefined;
|
|
33
|
+
return {
|
|
34
|
+
start<T>(request: () => Promise<T>): Promise<T> {
|
|
35
|
+
const admitted = tail.then(async () => {
|
|
36
|
+
if (lastStart !== undefined) {
|
|
37
|
+
let remaining = S2_START_INTERVAL_MS - (clock.now() - lastStart);
|
|
38
|
+
while (remaining > 0) {
|
|
39
|
+
await clock.sleep(remaining);
|
|
40
|
+
remaining = S2_START_INTERVAL_MS - (clock.now() - lastStart);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Measure after invocation, conservatively spacing actual fetch starts.
|
|
44
|
+
let response: Promise<T>;
|
|
45
|
+
try { response = request(); } finally { lastStart = clock.now(); }
|
|
46
|
+
// Box the promise so admission releases without waiting for the response.
|
|
47
|
+
// A later admission must not delay handling a rejected response.
|
|
48
|
+
void response.catch(() => {});
|
|
49
|
+
return { response };
|
|
50
|
+
});
|
|
51
|
+
tail = admitted.then(() => {}, () => {});
|
|
52
|
+
return admitted.then(({ response }) => response);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const processS2Pacing = createHttpPacing();
|
|
58
|
+
|
|
18
59
|
/** Read the body while counting bytes; past the limit the stream is cancelled, never buffered whole. */
|
|
19
60
|
async function readBounded(response: Response, host: string): Promise<string> {
|
|
20
61
|
if (!response.body) return "";
|
|
@@ -38,11 +79,14 @@ export function userAgent(env: NodeJS.ProcessEnv): string {
|
|
|
38
79
|
}
|
|
39
80
|
|
|
40
81
|
/** 200 returns the body, 404 returns `body: undefined`; 429, 5xx, other statuses and network failures throw HttpError. */
|
|
41
|
-
export function createHttp(env: NodeJS.ProcessEnv = process.env, fetchImpl: FetchLike = fetch as FetchLike): Http {
|
|
82
|
+
export function createHttp(env: NodeJS.ProcessEnv = process.env, fetchImpl: FetchLike = fetch as FetchLike, pacing: HttpPacing = processS2Pacing): Http {
|
|
42
83
|
async function get(url: string, headers: Record<string, string> = {}): Promise<HttpResult<string>> {
|
|
43
84
|
const host = new URL(url).host;
|
|
44
85
|
let response: Response;
|
|
45
|
-
try {
|
|
86
|
+
try {
|
|
87
|
+
const request = () => fetchImpl(url, { headers: { "user-agent": userAgent(env), ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
88
|
+
response = await (new URL(url).hostname === "api.semanticscholar.org" ? pacing.start(request) : request());
|
|
89
|
+
}
|
|
46
90
|
catch { throw new HttpError("network", host); }
|
|
47
91
|
if (response.status === 404) return { status: 404, body: undefined };
|
|
48
92
|
if (response.status === 429) throw new HttpError("rate", host, 429);
|
package/src/lit/watch.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HttpError } from "../http.ts";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import { assertWritable, boundedText, commit, onlyKeys, openStudy, plain, readLedger, readBytesBounded, sha256, type Actor } from "../study.ts";
|
|
3
4
|
import { loadCandidates } from "./candidates.ts";
|
|
@@ -10,7 +11,8 @@ export interface WatchSnapshot { config: WatchConfig; sha256: string }
|
|
|
10
11
|
export interface SearchResult { added: number; merged: number; dropped: number; linked: number; sources: { source: SourceName; ok: boolean; returned: number; error?: string }[] }
|
|
11
12
|
export interface WatchError { host: string; status: number | null }
|
|
12
13
|
export interface WatchQueryResult { id: string; added: number; merged: number; dropped: number; linked: number; sources: { source: SourceName; ok: boolean; returned: number }[]; errors: WatchError[] }
|
|
13
|
-
export
|
|
14
|
+
export type WatchFailureClass = "terminated" | "filesystem" | "network" | "rate" | "server" | "client" | "format" | "internal";
|
|
15
|
+
export interface WatchRun { cause?: WatchFailureClass; configSha256: string; queries: WatchQueryResult[]; added: number; addedIds: string[]; addedIdsTotal: number; ok: boolean }
|
|
14
16
|
const HOSTS: Record<SourceName, string> = { openalex: "api.openalex.org", s2: "api.semanticscholar.org", arxiv: "export.arxiv.org" };
|
|
15
17
|
class WatchInputError extends Error {}
|
|
16
18
|
class WatchVersionError extends WatchInputError {}
|
|
@@ -80,6 +82,17 @@ function sourceErrors(sources: SearchResult["sources"]): WatchError[] {
|
|
|
80
82
|
return { host, status: match?.[1] === undefined ? null : Number(match[1]) };
|
|
81
83
|
});
|
|
82
84
|
}
|
|
85
|
+
/** A closed vocabulary only: thrown messages can contain request URLs, keys or response bodies. */
|
|
86
|
+
export function watchFailureClass(error: unknown): WatchFailureClass {
|
|
87
|
+
if (error instanceof HttpError) return error.kind;
|
|
88
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
89
|
+
return ["ENOENT", "EACCES", "EPERM", "ENOSPC", "EIO", "EROFS", "ENOTDIR", "EISDIR"].includes(String(code)) ? "filesystem" : "internal";
|
|
90
|
+
}
|
|
91
|
+
/** Caller still holds the study lock; no partial query details or diagnostic text are copied. */
|
|
92
|
+
export function recordWatchFailure(dir: string, snapshot: WatchSnapshot, actor: Actor, cause: WatchFailureClass): void {
|
|
93
|
+
const run: WatchRun = { configSha256: snapshot.sha256, queries: [], added: 0, addedIds: [], addedIdsTotal: 0, ok: false, cause };
|
|
94
|
+
commit(dir, [], [{ actor, type: "watch.ran", data: { ...run } }]);
|
|
95
|
+
}
|
|
83
96
|
/** One finite pass, using the caller's existing locked lit-search operation. No timer or screening. */
|
|
84
97
|
export async function runWatch(dir: string, snapshot: WatchSnapshot, actor: Actor, search: (query: WatchQuery) => Promise<SearchResult>): Promise<WatchRun> {
|
|
85
98
|
const { config } = snapshot;
|
package/src/manifest.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { ARES_TOOLS } from "./mcp-tools.ts";
|
|
|
5
5
|
/** Implemented family-readable records only; private inputs and future integrations are not contracts. */
|
|
6
6
|
export const FORMAT_VERSIONS = {
|
|
7
7
|
manifest: 1,
|
|
8
|
-
study:
|
|
8
|
+
study: 2,
|
|
9
9
|
paperEvidence: 1,
|
|
10
10
|
skillProposal: 1,
|
|
11
11
|
studyRegistry: 1,
|
|
@@ -20,10 +20,11 @@ export const FORMAT_VERSIONS = {
|
|
|
20
20
|
investigateResult: 1,
|
|
21
21
|
executor: 1,
|
|
22
22
|
experimentAttempt: 1,
|
|
23
|
+
studyBundle: 1,
|
|
23
24
|
} as const;
|
|
24
25
|
export const MEMORY_LEDGER_EVENTS = ["archive.exported", "skill.proposed", "skill.decided"] as const;
|
|
25
26
|
export const WATCH_LEDGER_EVENTS = ["watch.set", "watch.ran"] as const;
|
|
26
|
-
export const LEDGER_EVENTS = [...MEMORY_LEDGER_EVENTS, ...WATCH_LEDGER_EVENTS] as const;
|
|
27
|
+
export const LEDGER_EVENTS = [...MEMORY_LEDGER_EVENTS, ...WATCH_LEDGER_EVENTS, "autonomy.set"] as const;
|
|
27
28
|
export interface Manifest {
|
|
28
29
|
v: 1;
|
|
29
30
|
name: "ares";
|
package/src/mcp-tools.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PHASES, AUTONOMY_MODES } from "./autonomy.ts";
|
|
1
2
|
import { SETUP_TARGETS, type SetupMode } from "./setup.ts";
|
|
2
3
|
import { isAbsolute } from "node:path";
|
|
3
4
|
import * as ops from "./commands.ts";
|
|
@@ -30,8 +31,8 @@ const SKILL = obj({ name: text(64), summary: text(16384), steps: { ...texts(1638
|
|
|
30
31
|
const SCOPE = obj({ question: text(2000), subquestions: texts(30, 1000), include: texts(50, 500), exclude: texts(50, 500), yearFrom: year, yearTo: year, maxCandidates: { type: "integer", minimum: 1, maximum: 5000 } }, ["question"]);
|
|
31
32
|
const CARD = obj({ summary: text(4000), claims: texts(50, 1000), methods: text(4000), findings: text(4000), limitations: text(4000), relevance: text(4000) }, ["summary"]);
|
|
32
33
|
const integer = (minimum: number, maximum: number) => ({ type: "integer", minimum, maximum });
|
|
33
|
-
const WATCH = obj({ v: {
|
|
34
|
-
items: obj({ id: text(64), query: text(4096), source: { type: "string", enum: ["openalex", "s2", "arxiv", "all"] }, yearFrom: year, limit: integer(1, 50) }, ["id", "query"]) }, rubric: text(8000) }, ["
|
|
34
|
+
const WATCH = obj({ v: {}, queries: { type: "array", minItems: 1, maxItems: 20,
|
|
35
|
+
items: obj({ id: text(64), query: text(4096), source: { type: "string", enum: ["openalex", "s2", "arxiv", "all"] }, yearFrom: year, limit: integer(1, 50) }, ["id", "query"]) }, rubric: text(8000) }, ["queries"]);
|
|
35
36
|
const argv = { type: "array", minItems: 1, maxItems: 128, items: { type: "string", maxLength: 4096 } };
|
|
36
37
|
const image = { ...text(512), description: "Digest-pinned image: name@sha256:<64 lowercase hex digits>" };
|
|
37
38
|
const stage = { type: "string", enum: ["candidate", "evaluator"] };
|
|
@@ -53,7 +54,7 @@ const planId = text(PLAN_ID_BYTES);
|
|
|
53
54
|
const PLAN = obj({
|
|
54
55
|
v: { type: "integer", enum: [1] }, revision: integer(1, Number.MAX_SAFE_INTEGER),
|
|
55
56
|
nodes: { type: "array", maxItems: 128, items: obj({
|
|
56
|
-
id: planId, kind: { type: "string", enum: ["step", "hub", "gate"] },
|
|
57
|
+
id: planId, kind: { type: "string", enum: ["step", "hub", "gate"] }, phase: { type: "string", enum: [...PHASES] },
|
|
57
58
|
needs: { type: "array", maxItems: 128, items: planId },
|
|
58
59
|
command: { type: "string", enum: [...STEP_COMMANDS] },
|
|
59
60
|
args: { type: "object", maxProperties: 32, properties: {}, additionalProperties: { type: ["string", "number", "boolean", "array"], maxLength: 4096, maxItems: 500, items: text(300) } },
|
|
@@ -72,6 +73,8 @@ const SELECTION = obj({
|
|
|
72
73
|
const IDEA = obj({ id: text(64), title: text(500), hypothesis: text(4000), parent: text(64), commit: { type: "string", minLength: 40, maxLength: 40 } }, ["id", "title", "hypothesis"]);
|
|
73
74
|
|
|
74
75
|
export const ARES_TOOLS: readonly McpTool[] = [
|
|
76
|
+
{ name: "ares_autonomy_show", description: "Read the study autonomy policy and effective mode for each phase; no writes.", inputSchema: obj({ study }, ["study"]), annotations: read },
|
|
77
|
+
{ name: "ares_autonomy_set", description: `Set the default or one phase autonomy mode (${AUTONOMY_MODES.join("|")}) and record from/to/phase. Only auto approves pending gates; existing gate decisions remain unchanged. Copilot draft acceptance and step pausing are separate features.`, inputSchema: obj({ study, mode: text(16), phase: text(32) }, ["study", "mode"]), annotations: write },
|
|
75
78
|
{ name: "ares_watch_set", description: "Set bounded v1 literature queries and an optional display-only rubric. At most 20 queries; identical config is a no-op. Does not search, screen or start a timer. Survey and experiment studies.", inputSchema: obj({ study, watch: WATCH }, ["study", "watch"]), annotations: { ...write, idempotentHint: true } },
|
|
76
79
|
{ name: "ares_watch_run", description: "Start one finite watch pass and return {started:true,configSha256} after config, scope gate and year preflight. Always detached: this ack is not completion. Inspect ares_watch_show for the watch.ran outcome. The background pass holds the study lock for up to about 20 minutes; duplicate starts refuse. Adds only new candidates, never screens. No timer or daemon is started.", inputSchema: obj({ study }, ["study"]), annotations: net },
|
|
77
80
|
{ name: "ares_watch_show", description: "Read watch config, recent event-timed run receipts and new candidates. No lock, network or write.", inputSchema: obj({ study }, ["study"]), annotations: read },
|
|
@@ -89,8 +92,9 @@ export const ARES_TOOLS: readonly McpTool[] = [
|
|
|
89
92
|
{ name: "ares_plan_control", description: "Pause/resume admission or retry/cancel/skip a node. Requires a durable command id and expected definition revision: identical replay returns its original receipt; changed payloads/stale new requests refuse. Does not start a controller; run ares run separately. Cancelling an attempt may contact its executor; hub cancellation stays local.", inputSchema: obj({ study, action: { type: "string", enum: ["pause", "resume", "retry", "cancel", "skip"] }, node: text(128), commandId: text(128), expectedRevision: integer(1, Number.MAX_SAFE_INTEGER) }, ["study", "action", "commandId", "expectedRevision"]), annotations: net },
|
|
90
93
|
{ name: "ares_plan_show", description: "Read the definition revision, ledger-derived node states and last observations, gate decisions, uncertain nodes, controller owner and resolved hubProject without taking a lock or contacting an executor/hub.", inputSchema: obj({ study }, ["study"]), annotations: read },
|
|
91
94
|
{ name: "ares_status", description: "Study summary: gates, candidate counts, verification counts, bibliography.", inputSchema: obj({ study }, ["study"]), annotations: read },
|
|
95
|
+
{ name: "ares_study_export", description: "Write a content-free study bundle (manifest, reduced ledger, attempt rows, closed results, review counts, evidence hashes) from committed study files into a new or empty directory. Refuses the whole export if a path, URL, env value or key-like token would be written. list=true only names the files. No executor or network.", inputSchema: obj({ study, bundle: text(4096), list: { type: "boolean" } }, ["study", "bundle"]), annotations: write },
|
|
92
96
|
{ name: "ares_scope_set", description: "Set scope.json (question, subquestions, include, exclude, yearFrom, yearTo, maxCandidates). A change returns the scope gate to pending.", inputSchema: obj({ study, scope: SCOPE }, ["study", "scope"]), annotations: write },
|
|
93
|
-
{ name: "ares_gate", description: "List gates, or approve/reject scope, screen, design, final, review
|
|
97
|
+
{ name: "ares_gate", description: "List gates, or approve/reject scope, screen, design, final, review, plan:node-id or accept:hypotheses|paper|reviews. Copilot accepts bind current draft hashes and are never automatic. Plan approvals bind all upstream output references and need complete upstream outputs; skip never bypasses them. The ledger records the actor; approval authority is workspace policy.", inputSchema: obj({ study, action: { type: "string", enum: ["list", "approve", "reject"] }, gate: text(PLAN_GATE_BYTES), reason }, ["study", "action"]), annotations: write },
|
|
94
98
|
{ name: "ares_idea_add", description: "Add an experiment idea to the ledger, with optional existing parent and frozen commit. Unique run-name ids and commit ownership; at most 1000 ideas. Starts no work.", inputSchema: obj({ study, idea: IDEA }, ["study", "idea"]), annotations: write },
|
|
95
99
|
{ name: "ares_idea_decide", description: "Record refine/pivot with a reason of at most 2000 bytes. Ideas with commits require terminal own-commit attempts (at most 200). Untested ideas omit attempts to attach a first commit or pivot. Refine requires --commit with unique idea ownership; Pivot accepts optional cause/retryWhen (2000 bytes each); refine refuses them. Pivot rejects commit and closes further decisions. Starts no work.", inputSchema: obj({ study, id: text(64), decision: { type: "string", enum: ["refine", "pivot"] }, reason: text(2000), cause: text(2000), retryWhen: text(2000), attempts: { type: "array", maxItems: 200, items: attemptId }, commit: { type: "string", minLength: 40, maxLength: 40 } }, ["study", "id", "decision", "reason"]), annotations: write },
|
|
96
100
|
{ name: "ares_investigate_show", description: "Read investigation candidate/baseline paired-seed scores, winner or no-winner reason and all run attempt totals/exclusions. No executor or network calls.", inputSchema: obj({ study }, ["study"]), annotations: read },
|
|
@@ -132,13 +136,15 @@ function absolute(value: unknown, what: string): string {
|
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
/** MCP calls always come from an agent; the actor id is the detected agent or `mcp`. */
|
|
135
|
-
export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(env), runtime: ops.ExperimentRuntime = {}): { tools: readonly McpTool[]; handlers: Record<string, McpHandler>; waitForIdle(): Promise<void
|
|
139
|
+
export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(env), runtime: ops.ExperimentRuntime = {}): { tools: readonly McpTool[]; handlers: Record<string, McpHandler>; waitForIdle(): Promise<void>; terminate(): void } {
|
|
136
140
|
const detected = detectActor(env), actor: Actor = { kind: "agent", id: detected.kind === "agent" ? detected.id! : "mcp" };
|
|
137
141
|
const ctx = { ...runtime, actor, http }, memoryEnv = { ...env, AGENTHUB_PEER_ID: actor.id };
|
|
138
142
|
const dir = (a: Record<string, unknown>) => absolute(a.study, "study");
|
|
139
|
-
const background = new
|
|
140
|
-
const track = (completion: Promise<void
|
|
143
|
+
const background = new Map<Promise<void>, () => void>();
|
|
144
|
+
const track = (completion: Promise<void>, terminate: () => void) => { background.set(completion, terminate); void completion.then(() => { background.delete(completion); }); };
|
|
141
145
|
const handlers: Record<string, McpHandler> = {
|
|
146
|
+
ares_autonomy_show: a => ops.autonomyShow(dir(a)),
|
|
147
|
+
ares_autonomy_set: a => ops.autonomySet(dir(a), a.mode as string, a.phase as string | undefined, ctx),
|
|
142
148
|
ares_watch_set: a => ops.watchSet(dir(a), a.watch, ctx),
|
|
143
149
|
ares_watch_run: a => ops.watchRunDetached(dir(a), ctx, track),
|
|
144
150
|
ares_watch_show: a => ops.watchShow(dir(a)),
|
|
@@ -156,6 +162,7 @@ export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(en
|
|
|
156
162
|
ares_plan_control: a => ops.planControl(dir(a), { action: a.action as Parameters<typeof ops.planControl>[1]["action"], commandId: a.commandId as string, expectedRevision: a.expectedRevision as number, ...(a.node !== undefined ? { node: a.node as string } : {}) }, ctx),
|
|
157
163
|
ares_plan_show: a => ops.planShow(dir(a)),
|
|
158
164
|
ares_status: a => ops.status(dir(a)),
|
|
165
|
+
ares_study_export: a => ops.studyExport(dir(a), { bundle: absolute(a.bundle, "bundle"), ...(a.list === true ? { list: true } : {}) }),
|
|
159
166
|
ares_scope_set: a => ops.scope(dir(a), a.scope, ctx),
|
|
160
167
|
ares_idea_add: a => ops.ideaAdd(dir(a), a.idea, ctx),
|
|
161
168
|
ares_idea_decide: a => ops.ideaDecide(dir(a), a.id as string, { decision: a.decision, reason: a.reason, ...(a.cause !== undefined ? { cause: a.cause } : {}), ...(a.retryWhen !== undefined ? { retryWhen: a.retryWhen } : {}), ...(a.attempts !== undefined ? { attempts: a.attempts } : {}), ...(a.commit !== undefined ? { commit: a.commit } : {}) }, ctx),
|
|
@@ -196,5 +203,8 @@ export function createAresMcp(env: NodeJS.ProcessEnv, http: Http = createHttp(en
|
|
|
196
203
|
ares_cite_check: a => ops.cite(dir(a), absolute(a.draft, "draft")),
|
|
197
204
|
ares_playbook: () => ({ playbook: PLAYBOOK }),
|
|
198
205
|
};
|
|
199
|
-
return { tools: ARES_TOOLS, handlers,
|
|
206
|
+
return { tools: ARES_TOOLS, handlers,
|
|
207
|
+
// Only for the entrypoint immediately before process exit; pending async work must not resume.
|
|
208
|
+
terminate: () => { for (const stop of background.values()) { try { stop(); } catch { console.error("ares watch: termination receipt failed (storage); restore study storage and inspect ares watch show before retrying"); } } },
|
|
209
|
+
waitForIdle: async () => { await Promise.all([...background.keys()]); } };
|
|
200
210
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -12,6 +12,8 @@ const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v ===
|
|
|
12
12
|
|
|
13
13
|
/** Runtime validation for the small schema vocabulary the tools use. Unknown keywords never widen acceptance. */
|
|
14
14
|
export function validArguments(value: unknown, raw: unknown): boolean {
|
|
15
|
+
// An empty JSON schema delegates a frame-bounded field (watch version) to the shared validator.
|
|
16
|
+
if (plain(raw) && Object.keys(raw).length === 0) return true;
|
|
15
17
|
const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
16
18
|
const types: unknown[] = Array.isArray(s.type) ? s.type : [s.type];
|
|
17
19
|
if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return false;
|
package/src/memory/archive.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ACCEPT_ARTIFACTS } from "../autonomy.ts";
|
|
2
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
1
3
|
import { commit, prepareCommit, openStudy, plain, sha256, type Actor, type LedgerEvent } from "../study.ts";
|
|
2
4
|
import { canonical } from "../plan/state.ts";
|
|
3
5
|
import { createAmemClient, type AmemNote } from "./amem.ts";
|
|
@@ -35,6 +37,7 @@ function exported(events: LedgerEvent[]): Set<string> {
|
|
|
35
37
|
}
|
|
36
38
|
/** No lock, subprocess or write: confirmation binds the full list, receipts only narrow pending writes. */
|
|
37
39
|
export function previewArchive(dir: string): ArchivePreview {
|
|
40
|
+
for (const artifact of ACCEPT_ARTIFACTS) requireCopilotAccept(dir, artifact);
|
|
38
41
|
const sources = readLessonSources(dir), notes = deriveLessons(sources), known = exported(sources.events);
|
|
39
42
|
const unique = new Map(notes.map(note => [sha256(canonical(note)), note]));
|
|
40
43
|
const pending = [...unique].filter(([hash]) => !known.has(hash)).map(([sha256, note]) => ({ sha256, note }));
|
package/src/paper/build.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { assertWritable, commit, openStudy, plain, readLedger, readTextBounded, sha256, type Actor } from "../study.ts";
|
|
@@ -50,7 +51,9 @@ function calculatePaper(dir: string) {
|
|
|
50
51
|
}
|
|
51
52
|
/** Caller holds the study lock. Refusals precede the single output-and-receipt commit. */
|
|
52
53
|
export function buildPaper(dirArg: string, actor: Actor) {
|
|
53
|
-
const { dir } = openStudy(dirArg)
|
|
54
|
+
const { dir } = openStudy(dirArg);
|
|
55
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
56
|
+
const paths = paperBuildPaths(dir), oldEvidence = evidenceBytes(dir);
|
|
54
57
|
const { report, text, manuscriptSha256, evidenceSha256 } = calculatePaper(dir), oldManuscript = previous(paths.manuscript, MANUSCRIPT_BYTES);
|
|
55
58
|
const receipt = readLedger(dir).findLast(event => event.type === "paper.built");
|
|
56
59
|
const changed = oldManuscript !== report.rendered || oldEvidence !== text || receipt?.data.manuscriptSha256 !== manuscriptSha256 || receipt.data.evidenceSha256 !== evidenceSha256;
|