@tiangong-ai/cli 0.0.20 → 0.0.22
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/AGENTS.md +3 -2
- package/README.md +158 -13
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +11 -1
- package/dist/cli.js.map +1 -1
- package/dist/research/orchestration.js +166 -11
- package/dist/research/orchestration.js.map +1 -1
- package/dist/research/workspace/broker.js +407 -31
- package/dist/research/workspace/broker.js.map +1 -1
- package/dist/research/workspace/capabilities.js +65 -0
- package/dist/research/workspace/capabilities.js.map +1 -1
- package/dist/research/workspace/constants.d.ts +1 -1
- package/dist/research/workspace/constants.js +4 -0
- package/dist/research/workspace/constants.js.map +1 -1
- package/dist/research/workspace/evidence.d.ts +32 -0
- package/dist/research/workspace/evidence.js +235 -0
- package/dist/research/workspace/evidence.js.map +1 -0
- package/dist/research/workspace/executor.d.ts +11 -1
- package/dist/research/workspace/executor.js +678 -105
- package/dist/research/workspace/executor.js.map +1 -1
- package/dist/research/workspace/input-plan.d.ts +5 -0
- package/dist/research/workspace/input-plan.js +319 -0
- package/dist/research/workspace/input-plan.js.map +1 -0
- package/dist/research/workspace/journal.js +2 -1
- package/dist/research/workspace/journal.js.map +1 -1
- package/dist/research/workspace/preflight.d.ts +108 -0
- package/dist/research/workspace/preflight.js +261 -0
- package/dist/research/workspace/preflight.js.map +1 -0
- package/dist/research/workspace/projects.d.ts +5 -2
- package/dist/research/workspace/projects.js +290 -6
- package/dist/research/workspace/projects.js.map +1 -1
- package/dist/research/workspace/runtime.d.ts +5 -1
- package/dist/research/workspace/runtime.js +1314 -196
- package/dist/research/workspace/runtime.js.map +1 -1
- package/dist/research/workspace/sanitization.d.ts +5 -0
- package/dist/research/workspace/sanitization.js +72 -0
- package/dist/research/workspace/sanitization.js.map +1 -0
- package/dist/research/workspace/schemas.d.ts +17 -0
- package/dist/research/workspace/schemas.js +342 -0
- package/dist/research/workspace/schemas.js.map +1 -0
- package/dist/research/workspace/storage.js +4 -0
- package/dist/research/workspace/storage.js.map +1 -1
- package/dist/research/workspace/types.d.ts +160 -0
- package/dist/research/workspace/workspace.d.ts +14 -3
- package/dist/research/workspace/workspace.js +374 -12
- package/dist/research/workspace/workspace.js.map +1 -1
- package/package.json +2 -1
|
@@ -1,26 +1,29 @@
|
|
|
1
|
-
import { cp, readFile, rm } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, join, relative } from "node:path";
|
|
3
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, lstat, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
4
4
|
import { CliError } from "../../errors.js";
|
|
5
5
|
import { stageLockedCapabilities, verifyCapabilities } from "./capabilities.js";
|
|
6
6
|
import { startCapabilityBroker } from "./broker.js";
|
|
7
|
+
import { loadProjectEvidenceReceipts, stageProjectEvidence } from "./evidence.js";
|
|
7
8
|
import { executeAgent } from "./executor.js";
|
|
8
|
-
import {
|
|
9
|
+
import { renderInputLineContext } from "./input-plan.js";
|
|
10
|
+
import { appendJournalEvent, verifyJournal } from "./journal.js";
|
|
11
|
+
import { RESEARCH_AGENT_PROTOCOL_OVERHEAD_TOKENS, RESEARCH_BROKER_MAX_TURNS, RESEARCH_ESTIMATED_BYTES_PER_TOKEN, RESEARCH_MAX_REPAIR_SOURCE_BYTES, RESEARCH_REPAIR_MAX_TURNS, RESEARCH_STRUCTURED_OUTPUT_MAX_TURNS, reservedAgentPackageCost, } from "./preflight.js";
|
|
9
12
|
import { listProjects, loadProject, nextReadyPackage, packageById, refreshProject, saveProject, } from "./projects.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
13
|
+
import { configuredResearchSecrets, sanitizeResearchRecord, sanitizeResearchText, } from "./sanitization.js";
|
|
14
|
+
import { parseStructuredStageOutput, schemaForStage, StructuredOutputError } from "./schemas.js";
|
|
15
|
+
import { canonicalJson, ensureDirectory, fileRecord, isObject, pathExists, readJsonFile, regularTreeFiles, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
|
|
16
|
+
import { loadWorkspaceConfig, verifyDoctorAttestation, withWorkspaceLock } from "./workspace.js";
|
|
12
17
|
export async function runResearchWorkspace(root, options, packageExecutor = executeAgent) {
|
|
13
18
|
validateRunOptions(options);
|
|
19
|
+
const requestId = randomUUID();
|
|
14
20
|
if (options.dryRun)
|
|
15
|
-
return dryRunResult(root);
|
|
21
|
+
return dryRunResult(root, requestId, options.projectId);
|
|
16
22
|
return withWorkspaceLock(root, "research.run", async () => {
|
|
23
|
+
await verifyJournal(workspacePaths(root).journal);
|
|
17
24
|
const config = await loadWorkspaceConfig(root);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
code: "RESEARCH_REVIEW_ROUTE_INVALID",
|
|
21
|
-
exitCode: 3,
|
|
22
|
-
});
|
|
23
|
-
}
|
|
25
|
+
assertExecutionConfiguration(config);
|
|
26
|
+
let doctorAttestation = null;
|
|
24
27
|
const capabilities = await verifyCapabilities(root);
|
|
25
28
|
if (capabilities.status !== "verified") {
|
|
26
29
|
throw new CliError("Research capabilities are not locked and verified.", {
|
|
@@ -29,10 +32,31 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
|
|
|
29
32
|
details: capabilities,
|
|
30
33
|
});
|
|
31
34
|
}
|
|
35
|
+
if (config.mode === "production-research") {
|
|
36
|
+
const verification = await verifyDoctorAttestation(root);
|
|
37
|
+
if (verification.status !== "verified" || !verification.attestation) {
|
|
38
|
+
throw new CliError("Production research requires a current successful producer/reviewer doctor smoke.", {
|
|
39
|
+
code: "RESEARCH_DOCTOR_ATTESTATION_REQUIRED",
|
|
40
|
+
exitCode: 3,
|
|
41
|
+
details: { status: verification.status, errors: verification.errors },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
doctorAttestation = verification.attestation;
|
|
45
|
+
const unconfirmed = (await projectsForRun(root, options.projectId)).filter((project) => config.budget.maxCostUsd > config.budget.confirmationCostUsd &&
|
|
46
|
+
!project.budgetConfirmedAt);
|
|
47
|
+
if (unconfirmed.length) {
|
|
48
|
+
throw new CliError("Production research budget has not been explicitly confirmed.", {
|
|
49
|
+
code: "RESEARCH_BUDGET_CONFIRMATION_REQUIRED",
|
|
50
|
+
exitCode: 3,
|
|
51
|
+
details: { projects: unconfirmed.map((project) => project.id) },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
emitProgress(options, progressEvent("run.started", requestId, options.projectId ?? null, null, null));
|
|
32
56
|
const executed = [];
|
|
33
57
|
let cycles = 0;
|
|
34
58
|
while (cycles < options.maxCycles) {
|
|
35
|
-
const projects = await
|
|
59
|
+
const projects = await projectsForRun(root, options.projectId);
|
|
36
60
|
const selected = projects
|
|
37
61
|
.map((project) => ({ project, workPackage: nextReadyPackage(project) }))
|
|
38
62
|
.filter((item) => Boolean(item.workPackage) &&
|
|
@@ -42,78 +66,147 @@ export async function runResearchWorkspace(root, options, packageExecutor = exec
|
|
|
42
66
|
if (!selected.length)
|
|
43
67
|
break;
|
|
44
68
|
cycles += 1;
|
|
45
|
-
const results = await Promise.all(selected.map(({ project, workPackage }) => executeWorkPackage(root, project.id, workPackage.id, config, options
|
|
69
|
+
const results = await Promise.all(selected.map(({ project, workPackage }) => executeWorkPackage(root, project.id, workPackage.id, config, options, requestId, packageExecutor, doctorAttestation)));
|
|
46
70
|
executed.push(...results);
|
|
47
71
|
}
|
|
48
|
-
|
|
72
|
+
const result = await summarizeRun(root, requestId, cycles, executed, options.maxCycles, options.projectId);
|
|
73
|
+
emitProgress(options, progressEvent("run.completed", requestId, options.projectId ?? null, null, null, {
|
|
74
|
+
status: result.status,
|
|
75
|
+
stopReason: result.stopReason,
|
|
76
|
+
}));
|
|
77
|
+
return result;
|
|
49
78
|
});
|
|
50
79
|
}
|
|
51
|
-
async function executeWorkPackage(root, projectId, packageId, config,
|
|
80
|
+
async function executeWorkPackage(root, projectId, packageId, config, options, requestId, packageExecutor, doctorAttestation) {
|
|
52
81
|
const project = await loadProject(root, projectId);
|
|
53
82
|
const workPackage = packageById(project, packageId);
|
|
54
83
|
const now = new Date().toISOString();
|
|
55
84
|
workPackage.status = "running";
|
|
56
85
|
workPackage.attempts += 1;
|
|
57
86
|
workPackage.startedAt = now;
|
|
87
|
+
workPackage.completedAt = null;
|
|
58
88
|
workPackage.lastError = null;
|
|
89
|
+
workPackage.lastFailureKind = null;
|
|
90
|
+
workPackage.retryNotBefore = null;
|
|
59
91
|
project.status = "running";
|
|
60
92
|
project.updatedAt = now;
|
|
61
93
|
await saveProject(root, project);
|
|
62
94
|
await appendJournalEvent(workspacePaths(root).journal, "package.started", projectId, {
|
|
95
|
+
requestId,
|
|
63
96
|
projectId,
|
|
64
97
|
packageId,
|
|
65
98
|
attempt: workPackage.attempts,
|
|
66
99
|
});
|
|
100
|
+
emitProgress(options, progressEvent("package.started", requestId, projectId, packageId, remainingBudget(project, config), { attempt: workPackage.attempts }));
|
|
67
101
|
const runId = randomUUID();
|
|
68
102
|
const startedAt = new Date().toISOString();
|
|
69
103
|
let capsuleRoot;
|
|
70
104
|
let broker;
|
|
71
105
|
let accountedResult;
|
|
106
|
+
let promotedOutputs = [];
|
|
107
|
+
let executor = "mechanical";
|
|
72
108
|
try {
|
|
73
|
-
if (workPackage.kind !== "verify")
|
|
74
|
-
assertBudget(project, config);
|
|
75
109
|
let result;
|
|
76
|
-
let outputs;
|
|
77
|
-
let executor;
|
|
78
110
|
if (workPackage.kind === "verify") {
|
|
79
111
|
result = await closeProjectMechanically(root, project, workPackage);
|
|
80
112
|
accountedResult = result;
|
|
81
|
-
|
|
82
|
-
executor = "mechanical";
|
|
113
|
+
promotedOutputs = await outputRecords(root, project, workPackage.expectedOutputs);
|
|
83
114
|
}
|
|
84
115
|
else {
|
|
116
|
+
const reservation = reservePackageBudget(project, workPackage, config);
|
|
85
117
|
const capsule = await createCapsule(root, project, workPackage, runId);
|
|
86
118
|
capsuleRoot = capsule.capsuleRoot;
|
|
119
|
+
if (capsule.reviewPacketRecord) {
|
|
120
|
+
await appendJournalEvent(workspacePaths(root).journal, "review.packet.persisted", projectId, {
|
|
121
|
+
requestId,
|
|
122
|
+
projectId,
|
|
123
|
+
packageId,
|
|
124
|
+
packetSha256: capsule.reviewPacketSha256,
|
|
125
|
+
packet: capsule.reviewPacketRecord,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const stageContextContent = await stageContextForPackage(capsule.projectRoot, workPackage, config);
|
|
87
129
|
const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
|
|
88
130
|
executor = route.agent;
|
|
89
|
-
broker =
|
|
90
|
-
|
|
131
|
+
broker =
|
|
132
|
+
workPackage.stage === "discover"
|
|
133
|
+
? await startCapabilityBroker(root, project.id, capsule.projectRoot)
|
|
134
|
+
: undefined;
|
|
135
|
+
const primaryBrokerUrl = broker?.url ?? null;
|
|
136
|
+
const inputOnlyProvenance = workPackage.stage === "discover" && primaryBrokerUrl === null;
|
|
137
|
+
const primaryRequest = agentRequest({
|
|
138
|
+
root,
|
|
139
|
+
project,
|
|
140
|
+
workPackage,
|
|
91
141
|
route,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
brokerUrl:
|
|
142
|
+
capsule,
|
|
143
|
+
config,
|
|
144
|
+
options,
|
|
145
|
+
requestId,
|
|
146
|
+
purpose: "primary",
|
|
147
|
+
prompt: packagePrompt(project, workPackage, capsule.inputManifest, capsule.stagedSkills, capsule.reviewPacketSha256, capsule.contextBundle, capsule.contextBundleContent, stageContextContent),
|
|
148
|
+
brokerUrl: primaryBrokerUrl,
|
|
149
|
+
inputOnlyProvenance,
|
|
150
|
+
maxOutputTokens: Math.min(config.budget.maxOutputTokens, reservation.tokens),
|
|
151
|
+
maxCostUsd: reservation.costUsd,
|
|
152
|
+
expectedRuntime: runtimeForRoute(doctorAttestation, route),
|
|
99
153
|
});
|
|
154
|
+
assertPreCallTokenReservation(project, workPackage, config, primaryRequest, 0, true);
|
|
155
|
+
result = await withHeartbeat(packageExecutor(primaryRequest), options, requestId, project, workPackage, config);
|
|
100
156
|
accountedResult = result;
|
|
101
|
-
|
|
102
|
-
|
|
157
|
+
assertExecutorSucceeded(result);
|
|
158
|
+
assertActualPackageBudget(project, workPackage, config, result, config.budget.maxOutputTokens);
|
|
159
|
+
try {
|
|
160
|
+
await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, result.stdout, capsule.reviewPacketSha256);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (!(error instanceof StructuredOutputError))
|
|
164
|
+
throw error;
|
|
165
|
+
const repairTokens = availableRepairTokens(project, workPackage, config, result);
|
|
166
|
+
if (repairTokens < 1)
|
|
167
|
+
throw error;
|
|
168
|
+
const repairRequest = agentRequest({
|
|
169
|
+
root,
|
|
170
|
+
project,
|
|
171
|
+
workPackage,
|
|
172
|
+
route,
|
|
173
|
+
capsule,
|
|
174
|
+
config,
|
|
175
|
+
options,
|
|
176
|
+
requestId,
|
|
177
|
+
purpose: "repair",
|
|
178
|
+
prompt: repairPrompt(workPackage, result.stdout, error),
|
|
179
|
+
brokerUrl: null,
|
|
180
|
+
inputOnlyProvenance,
|
|
181
|
+
maxOutputTokens: repairTokens,
|
|
182
|
+
maxCostUsd: Math.max(0, reservation.costUsd - result.costUsd),
|
|
183
|
+
maxWallSeconds: Math.max(1, config.budget.packageMaxWallSeconds[workPackage.stage] -
|
|
184
|
+
result.wallSeconds),
|
|
185
|
+
expectedRuntime: runtimeForRoute(doctorAttestation, route),
|
|
186
|
+
});
|
|
187
|
+
assertPreCallTokenReservation(project, workPackage, config, repairRequest, result.tokens, false);
|
|
188
|
+
const repair = await withHeartbeat(packageExecutor(repairRequest), options, requestId, project, workPackage, config);
|
|
189
|
+
accountedResult = combineExecutionResults(result, repair);
|
|
190
|
+
assertExecutorSucceeded(repair);
|
|
191
|
+
assertActualPackageBudget(project, workPackage, config, accountedResult, config.budget.maxOutputTokens + config.budget.maxRepairTokens);
|
|
192
|
+
await materializeAndValidateStageOutput(root, project, capsule.projectRoot, workPackage, repair.stdout, capsule.reviewPacketSha256);
|
|
193
|
+
}
|
|
194
|
+
assertProjectedBudget(project, config, accountedResult);
|
|
195
|
+
promotedOutputs = await validateAndImportOutputs(root, project, workPackage, capsule.projectRoot, config, capsule.reviewPacketSha256);
|
|
196
|
+
if (workPackage.stage === "discover") {
|
|
197
|
+
await assertEvidenceCoverage(root, project);
|
|
103
198
|
}
|
|
104
|
-
assertProjectedBudget(project, config, result);
|
|
105
|
-
outputs = await validateAndImportOutputs(root, project, workPackage, capsule.projectRoot, config, capsule.reviewPacketSha256);
|
|
106
199
|
}
|
|
107
200
|
const completedAt = new Date().toISOString();
|
|
108
|
-
project
|
|
109
|
-
project.usage.costUsd = roundMoney(project.usage.costUsd + result.costUsd);
|
|
110
|
-
project.usage.wallSeconds += result.wallSeconds;
|
|
201
|
+
applyUsage(project, accountedResult);
|
|
111
202
|
workPackage.status = "complete";
|
|
112
203
|
workPackage.completedAt = completedAt;
|
|
113
204
|
workPackage.lastError = null;
|
|
205
|
+
workPackage.lastFailureKind = null;
|
|
206
|
+
workPackage.retryNotBefore = null;
|
|
114
207
|
refreshProject(project);
|
|
115
208
|
await saveProject(root, project);
|
|
116
|
-
|
|
209
|
+
await writeRunRecord(root, {
|
|
117
210
|
schemaVersion: 1,
|
|
118
211
|
runId,
|
|
119
212
|
projectId,
|
|
@@ -121,56 +214,105 @@ async function executeWorkPackage(root, projectId, packageId, config, environmen
|
|
|
121
214
|
executor,
|
|
122
215
|
startedAt,
|
|
123
216
|
completedAt,
|
|
124
|
-
exitCode:
|
|
125
|
-
tokens:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
217
|
+
exitCode: accountedResult.exitCode,
|
|
218
|
+
tokens: accountedResult.tokens,
|
|
219
|
+
inputTokens: accountedResult.inputTokens,
|
|
220
|
+
cachedInputTokens: accountedResult.cachedInputTokens,
|
|
221
|
+
outputTokens: accountedResult.outputTokens,
|
|
222
|
+
costUsd: accountedResult.costUsd,
|
|
223
|
+
wallSeconds: accountedResult.wallSeconds,
|
|
224
|
+
outputs: promotedOutputs,
|
|
225
|
+
stdoutSha256: sha256Text(accountedResult.stdout),
|
|
226
|
+
stderrSha256: sha256Text(accountedResult.stderr),
|
|
227
|
+
failureKind: null,
|
|
228
|
+
failureDetails: null,
|
|
229
|
+
runtime: accountedResult.runtime,
|
|
230
|
+
telemetry: accountedResult.telemetry,
|
|
231
|
+
});
|
|
232
|
+
const usage = usageSlice(accountedResult);
|
|
133
233
|
await appendJournalEvent(workspacePaths(root).journal, "package.completed", projectId, {
|
|
234
|
+
requestId,
|
|
134
235
|
projectId,
|
|
135
236
|
packageId,
|
|
136
237
|
runId,
|
|
137
238
|
executor,
|
|
138
|
-
outputs,
|
|
139
|
-
usage
|
|
239
|
+
outputs: promotedOutputs,
|
|
240
|
+
usage,
|
|
241
|
+
runtime: accountedResult.runtime,
|
|
140
242
|
});
|
|
243
|
+
emitProgress(options, progressEvent("package.completed", requestId, projectId, packageId, remainingBudget(project, config), { outputs: promotedOutputs, usage }));
|
|
141
244
|
return { projectId, packageId, status: "complete" };
|
|
142
245
|
}
|
|
143
246
|
catch (error) {
|
|
144
247
|
const failedProject = await loadProject(root, projectId);
|
|
145
248
|
const failedPackage = packageById(failedProject, packageId);
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
249
|
+
const secrets = configuredResearchSecrets(options.environment);
|
|
250
|
+
const failureDetails = sanitizedFailureDetails(error, secrets);
|
|
251
|
+
const gapSummary = Array.isArray(failureDetails?.gaps)
|
|
252
|
+
? failureDetails.gaps.filter((gap) => typeof gap === "string").join("; ")
|
|
253
|
+
: "";
|
|
254
|
+
const message = bounded(sanitizeResearchText(`${error instanceof Error ? error.message : String(error)}${gapSummary ? ` ${gapSummary}` : ""}`, secrets), 2000);
|
|
255
|
+
if (accountedResult)
|
|
256
|
+
applyUsage(failedProject, accountedResult);
|
|
257
|
+
const classification = classifyFailure(error);
|
|
152
258
|
failedPackage.lastError = message;
|
|
259
|
+
failedPackage.lastFailureKind = classification.kind;
|
|
153
260
|
failedPackage.completedAt = new Date().toISOString();
|
|
154
|
-
const
|
|
155
|
-
failedPackage.status =
|
|
156
|
-
|
|
261
|
+
const retryable = classification.retryable && failedPackage.attempts < failedPackage.maxAttempts;
|
|
262
|
+
failedPackage.status = retryable ? "retry" : "failed";
|
|
263
|
+
failedPackage.retryNotBefore = retryable
|
|
264
|
+
? retryNotBefore(classification.retryAfterSeconds)
|
|
265
|
+
: null;
|
|
157
266
|
refreshProject(failedProject);
|
|
158
267
|
await saveProject(root, failedProject);
|
|
268
|
+
if (accountedResult) {
|
|
269
|
+
await writeRunRecord(root, {
|
|
270
|
+
schemaVersion: 1,
|
|
271
|
+
runId,
|
|
272
|
+
projectId,
|
|
273
|
+
packageId,
|
|
274
|
+
executor,
|
|
275
|
+
startedAt,
|
|
276
|
+
completedAt: failedPackage.completedAt,
|
|
277
|
+
exitCode: accountedResult.exitCode,
|
|
278
|
+
tokens: accountedResult.tokens,
|
|
279
|
+
inputTokens: accountedResult.inputTokens,
|
|
280
|
+
cachedInputTokens: accountedResult.cachedInputTokens,
|
|
281
|
+
outputTokens: accountedResult.outputTokens,
|
|
282
|
+
costUsd: accountedResult.costUsd,
|
|
283
|
+
wallSeconds: accountedResult.wallSeconds,
|
|
284
|
+
outputs: promotedOutputs,
|
|
285
|
+
stdoutSha256: sha256Text(accountedResult.stdout),
|
|
286
|
+
stderrSha256: sha256Text(accountedResult.stderr),
|
|
287
|
+
failureKind: classification.kind,
|
|
288
|
+
failureDetails,
|
|
289
|
+
runtime: accountedResult.runtime,
|
|
290
|
+
telemetry: accountedResult.telemetry,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const usage = accountedResult ? usageSlice(accountedResult) : zeroUsageSlice();
|
|
159
294
|
await appendJournalEvent(workspacePaths(root).journal, "package.failed", projectId, {
|
|
295
|
+
requestId,
|
|
160
296
|
projectId,
|
|
161
297
|
packageId,
|
|
162
298
|
runId,
|
|
163
299
|
attempt: failedPackage.attempts,
|
|
164
|
-
retryable
|
|
300
|
+
retryable,
|
|
301
|
+
retryNotBefore: failedPackage.retryNotBefore,
|
|
302
|
+
failureKind: classification.kind,
|
|
165
303
|
error: message,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
costUsd: accountedResult.costUsd,
|
|
170
|
-
wallSeconds: accountedResult.wallSeconds,
|
|
171
|
-
}
|
|
172
|
-
: { tokens: 0, costUsd: 0, wallSeconds: 0 },
|
|
304
|
+
details: failureDetails,
|
|
305
|
+
outputs: promotedOutputs,
|
|
306
|
+
usage,
|
|
173
307
|
});
|
|
308
|
+
emitProgress(options, progressEvent("package.failed", requestId, projectId, packageId, remainingBudget(failedProject, config), {
|
|
309
|
+
retryable,
|
|
310
|
+
retryNotBefore: failedPackage.retryNotBefore,
|
|
311
|
+
failureKind: classification.kind,
|
|
312
|
+
error: message,
|
|
313
|
+
details: failureDetails,
|
|
314
|
+
usage,
|
|
315
|
+
}));
|
|
174
316
|
return { projectId, packageId, status: failedPackage.status };
|
|
175
317
|
}
|
|
176
318
|
finally {
|
|
@@ -198,64 +340,418 @@ async function createCapsule(root, project, workPackage, runId) {
|
|
|
198
340
|
const inputManifest = [];
|
|
199
341
|
for (const input of project.inputs) {
|
|
200
342
|
if ((await sha256File(input.path)) !== input.sha256) {
|
|
201
|
-
throw new
|
|
343
|
+
throw new CliError(`Input drift detected: ${input.id}.`, {
|
|
344
|
+
code: "RESEARCH_INPUT_DRIFT",
|
|
345
|
+
exitCode: 3,
|
|
346
|
+
});
|
|
202
347
|
}
|
|
203
348
|
const logical = join("inputs", input.id, basename(input.path)).replaceAll("\\", "/");
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
349
|
+
const hasBoundedContext = Boolean((input.contextPath || input.contextRanges?.length) &&
|
|
350
|
+
input.contextSha256 &&
|
|
351
|
+
input.contextBytes !== undefined);
|
|
352
|
+
const fullTextStaged = !hasBoundedContext || workPackage.stage === "review";
|
|
353
|
+
if (fullTextStaged) {
|
|
354
|
+
const destination = join(capsuleProject, logical);
|
|
355
|
+
await ensureDirectory(dirname(destination));
|
|
356
|
+
await cp(input.path, destination, { force: false });
|
|
357
|
+
}
|
|
358
|
+
let contextPath = logical;
|
|
359
|
+
let contextSha256 = input.sha256;
|
|
360
|
+
let contextBytes = input.bytes;
|
|
361
|
+
if (hasBoundedContext) {
|
|
362
|
+
const contextContent = input.contextRanges?.length
|
|
363
|
+
? await renderInputLineContext(input.path, input.contextRanges)
|
|
364
|
+
: null;
|
|
365
|
+
const actualContextSha256 = contextContent
|
|
366
|
+
? sha256Text(contextContent)
|
|
367
|
+
: await sha256File(input.contextPath);
|
|
368
|
+
if (actualContextSha256 !== input.contextSha256) {
|
|
369
|
+
throw new CliError(`Input context drift detected: ${input.id}.`, {
|
|
370
|
+
code: "RESEARCH_INPUT_DRIFT",
|
|
371
|
+
exitCode: 3,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
contextPath = join("inputs", input.id, "context", input.contextPath ? basename(input.contextPath) : "selected-lines.txt").replaceAll("\\", "/");
|
|
375
|
+
const destination = join(capsuleProject, contextPath);
|
|
376
|
+
await ensureDirectory(dirname(destination));
|
|
377
|
+
if (contextContent === null) {
|
|
378
|
+
await cp(input.contextPath, destination, { force: false });
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
await writeTextAtomic(destination, contextContent);
|
|
382
|
+
}
|
|
383
|
+
contextSha256 = input.contextSha256;
|
|
384
|
+
contextBytes = input.contextBytes;
|
|
385
|
+
}
|
|
386
|
+
inputManifest.push({
|
|
387
|
+
id: input.id,
|
|
388
|
+
role: input.role,
|
|
389
|
+
path: logical,
|
|
390
|
+
sha256: input.sha256,
|
|
391
|
+
bytes: input.bytes,
|
|
392
|
+
contextPath,
|
|
393
|
+
contextSha256,
|
|
394
|
+
contextBytes,
|
|
395
|
+
fullTextStaged,
|
|
396
|
+
});
|
|
208
397
|
}
|
|
209
398
|
await writeJsonAtomic(join(capsuleProject, "inputs", "manifest.json"), inputManifest);
|
|
399
|
+
const contextBundleContent = await buildInputContextBundle(capsuleProject, inputManifest);
|
|
400
|
+
const contextBundlePath = join(capsuleProject, "inputs", "context-bundle.txt");
|
|
401
|
+
await writeTextAtomic(contextBundlePath, contextBundleContent);
|
|
402
|
+
const contextBundle = await fileRecord(contextBundlePath, "inputs/context-bundle.txt");
|
|
403
|
+
const evidenceReceipts = await stageProjectEvidence(root, project.id, capsuleProject);
|
|
404
|
+
await writeJsonAtomic(join(capsuleProject, "inputs", "evidence-receipts.json"), evidenceReceipts.map(reviewSafeReceipt));
|
|
210
405
|
await writeJsonAtomic(join(capsuleProject, "project.json"), {
|
|
211
406
|
...project,
|
|
212
407
|
inputs: project.inputs.map((input, index) => ({
|
|
213
408
|
...input,
|
|
214
409
|
path: inputManifest[index]?.path ?? "inputs/unavailable",
|
|
410
|
+
contextPath: inputManifest[index]?.contextPath ?? "inputs/unavailable",
|
|
215
411
|
})),
|
|
216
412
|
});
|
|
217
413
|
const stagedSkills = await stageLockedCapabilities(root, join(capsuleProject, "skills"));
|
|
218
|
-
const
|
|
219
|
-
? await
|
|
414
|
+
const reviewEvidenceContext = workPackage.stage === "review"
|
|
415
|
+
? await writeReviewEvidenceContext(root, project.id, capsuleProject, contextBundleContent, evidenceReceipts)
|
|
416
|
+
: null;
|
|
417
|
+
const reviewPacket = reviewEvidenceContext
|
|
418
|
+
? await writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext.persistent)
|
|
220
419
|
: null;
|
|
221
420
|
return {
|
|
222
421
|
capsuleRoot,
|
|
223
422
|
projectRoot: capsuleProject,
|
|
224
423
|
inputManifest,
|
|
424
|
+
contextBundle,
|
|
425
|
+
contextBundleContent,
|
|
225
426
|
stagedSkills,
|
|
226
|
-
reviewPacketSha256,
|
|
427
|
+
reviewPacketSha256: reviewPacket?.sha256 ?? null,
|
|
428
|
+
reviewPacketRecord: reviewPacket?.record ?? null,
|
|
227
429
|
};
|
|
228
430
|
}
|
|
229
|
-
async function
|
|
431
|
+
async function buildInputContextBundle(capsuleProject, inputManifest) {
|
|
432
|
+
const sections = ["TIANGONG BOUNDED INPUT CONTEXT BUNDLE v1"];
|
|
433
|
+
for (const input of [...inputManifest].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
434
|
+
const context = await readFile(resolveContained(capsuleProject, input.contextPath), "utf8");
|
|
435
|
+
sections.push([
|
|
436
|
+
`--- INPUT ${input.id} ---`,
|
|
437
|
+
`role: ${input.role}`,
|
|
438
|
+
`fullEvidenceLocator: ${input.path}`,
|
|
439
|
+
`fullEvidenceSha256: ${input.sha256}`,
|
|
440
|
+
`contextLocator: ${input.contextPath}`,
|
|
441
|
+
`contextSha256: ${input.contextSha256}`,
|
|
442
|
+
"--- BEGIN CONTEXT ---",
|
|
443
|
+
context.trimEnd(),
|
|
444
|
+
"--- END CONTEXT ---",
|
|
445
|
+
].join("\n"));
|
|
446
|
+
}
|
|
447
|
+
return `${sections.join("\n\n")}\n`;
|
|
448
|
+
}
|
|
449
|
+
async function writeReviewPacket(root, capsuleProject, project, inputManifest, evidenceReceipts, reviewEvidenceContext) {
|
|
230
450
|
const artifactPaths = ["outputs/evidence.json", "outputs/analysis.json", "outputs/report.md"];
|
|
451
|
+
const evidenceFiles = new Map();
|
|
452
|
+
for (const receipt of evidenceReceipts) {
|
|
453
|
+
for (const locator of [receipt.locator, receipt.contextLocator]) {
|
|
454
|
+
if (!evidenceFiles.has(locator)) {
|
|
455
|
+
evidenceFiles.set(locator, await fileRecord(resolveContained(capsuleProject, locator), locator));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const environment = await reviewEnvironmentPacket(root, project.id);
|
|
460
|
+
const inputFiles = new Map();
|
|
461
|
+
for (const input of inputManifest) {
|
|
462
|
+
for (const locator of [input.path, input.contextPath]) {
|
|
463
|
+
if (!inputFiles.has(locator)) {
|
|
464
|
+
inputFiles.set(locator, await fileRecord(resolveContained(capsuleProject, locator), locator));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
await writeJsonAtomic(join(capsuleProject, "inputs", "runtime-fingerprint.json"), environment);
|
|
231
469
|
const packet = {
|
|
232
470
|
schemaVersion: 1,
|
|
233
471
|
projectId: project.id,
|
|
234
472
|
questionSha256: sha256Text(project.question),
|
|
473
|
+
evidenceRequirements: project.evidenceRequirements,
|
|
235
474
|
inputs: inputManifest,
|
|
475
|
+
reviewEvidenceContext,
|
|
476
|
+
inputFiles: [...inputFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
477
|
+
evidenceReceipts: evidenceReceipts.map(reviewSafeReceipt),
|
|
478
|
+
evidenceFiles: [...evidenceFiles.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
479
|
+
environment,
|
|
480
|
+
environmentFile: await fileRecord(join(capsuleProject, "inputs", "runtime-fingerprint.json"), "inputs/runtime-fingerprint.json"),
|
|
236
481
|
artifacts: await Promise.all(artifactPaths.map((logicalPath) => fileRecord(resolveContained(capsuleProject, logicalPath), logicalPath))),
|
|
237
482
|
};
|
|
238
483
|
const packetSha256 = sha256Text(canonicalJson(packet));
|
|
239
|
-
|
|
484
|
+
const completePacket = {
|
|
240
485
|
...packet,
|
|
241
486
|
packetSha256,
|
|
242
|
-
}
|
|
243
|
-
|
|
487
|
+
};
|
|
488
|
+
await writeJsonAtomic(join(capsuleProject, "inputs", "review-packet.json"), completePacket);
|
|
489
|
+
const record = await persistReviewPacket(root, project.id, completePacket, packetSha256);
|
|
490
|
+
return { sha256: packetSha256, record };
|
|
491
|
+
}
|
|
492
|
+
async function writeReviewEvidenceContext(root, projectId, capsuleProject, inputContextBundle, evidenceReceipts) {
|
|
493
|
+
const sections = [
|
|
494
|
+
"TIANGONG REVIEW EVIDENCE CONTEXT v1",
|
|
495
|
+
"The following are exact, hash-verified bounded views. Full objects are bound in the review packet.",
|
|
496
|
+
inputContextBundle.trimEnd(),
|
|
497
|
+
];
|
|
498
|
+
const seen = new Set();
|
|
499
|
+
for (const receipt of [...evidenceReceipts].sort((left, right) => left.attemptId.localeCompare(right.attemptId))) {
|
|
500
|
+
if (seen.has(receipt.contextLocator))
|
|
501
|
+
continue;
|
|
502
|
+
seen.add(receipt.contextLocator);
|
|
503
|
+
const metadata = reviewSafeReceipt(receipt);
|
|
504
|
+
const content = reviewableTextContentType(receipt.contentType)
|
|
505
|
+
? await readFile(resolveContained(capsuleProject, receipt.contextLocator), "utf8")
|
|
506
|
+
: "[Binary bounded view omitted from model context; verify the bound file mechanically.]";
|
|
507
|
+
sections.push([
|
|
508
|
+
`--- BROKER RECEIPT ${receipt.attemptId} ---`,
|
|
509
|
+
`metadata: ${JSON.stringify(metadata)}`,
|
|
510
|
+
"--- BEGIN BOUNDED VIEW ---",
|
|
511
|
+
content.trimEnd(),
|
|
512
|
+
"--- END BOUNDED VIEW ---",
|
|
513
|
+
].join("\n"));
|
|
514
|
+
}
|
|
515
|
+
const logicalPath = "inputs/review-evidence-context.txt";
|
|
516
|
+
const path = resolveContained(capsuleProject, logicalPath);
|
|
517
|
+
const content = `${sections.join("\n\n")}\n`;
|
|
518
|
+
await writeTextAtomic(path, content);
|
|
519
|
+
const capsule = await fileRecord(path, logicalPath);
|
|
520
|
+
const persistentLogicalPath = `review/contexts/${capsule.sha256}.txt`;
|
|
521
|
+
const persistentPath = resolveContained(projectRoot(root, projectId), persistentLogicalPath);
|
|
522
|
+
if (await pathExists(persistentPath)) {
|
|
523
|
+
const existing = await fileRecord(persistentPath, persistentLogicalPath);
|
|
524
|
+
if (existing.sha256 !== capsule.sha256 || existing.bytes !== capsule.bytes) {
|
|
525
|
+
throw new CliError("Content-addressed review evidence context drift detected.", {
|
|
526
|
+
code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
|
|
527
|
+
exitCode: 3,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
await ensureDirectory(dirname(persistentPath));
|
|
533
|
+
await writeTextAtomic(persistentPath, content);
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
capsule,
|
|
537
|
+
persistent: await fileRecord(persistentPath, persistentLogicalPath),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function reviewableTextContentType(contentType) {
|
|
541
|
+
return /^(?:text\/|application\/(?:[^;]+\+)?(?:json|xml|javascript|xhtml\+xml|csv))(?:;|$)/i.test(contentType);
|
|
542
|
+
}
|
|
543
|
+
async function persistReviewPacket(root, projectId, packet, packetSha256) {
|
|
544
|
+
const logicalPath = `review/packets/${packetSha256}.json`;
|
|
545
|
+
const path = resolveContained(projectRoot(root, projectId), logicalPath);
|
|
546
|
+
if (await pathExists(path)) {
|
|
547
|
+
const existing = await readJsonFile(path, "Research review packet");
|
|
548
|
+
verifyReviewPacketValue(existing, packetSha256);
|
|
549
|
+
if (canonicalJson(existing) !== canonicalJson(packet)) {
|
|
550
|
+
throw new CliError("Content-addressed review packet collision or drift detected.", {
|
|
551
|
+
code: "RESEARCH_REVIEW_PACKET_DRIFT",
|
|
552
|
+
exitCode: 3,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
await ensureDirectory(dirname(path));
|
|
558
|
+
await writeJsonAtomic(path, packet);
|
|
559
|
+
}
|
|
560
|
+
return fileRecord(path, logicalPath);
|
|
561
|
+
}
|
|
562
|
+
async function loadVerifiedReviewPacket(root, projectId, packetSha256) {
|
|
563
|
+
const logicalPath = `review/packets/${packetSha256}.json`;
|
|
564
|
+
const path = resolveContained(projectRoot(root, projectId), logicalPath);
|
|
565
|
+
const packet = await readJsonFile(path, "Research review packet");
|
|
566
|
+
verifyReviewPacketValue(packet, packetSha256);
|
|
567
|
+
const context = packet.reviewEvidenceContext;
|
|
568
|
+
if (!isObject(context) ||
|
|
569
|
+
typeof context.path !== "string" ||
|
|
570
|
+
typeof context.sha256 !== "string" ||
|
|
571
|
+
!/^[0-9a-f]{64}$/.test(context.sha256) ||
|
|
572
|
+
!Number.isInteger(context.bytes) ||
|
|
573
|
+
context.path !== `review/contexts/${context.sha256}.txt`) {
|
|
574
|
+
throw new CliError("Persistent review packet has an invalid evidence context binding.", {
|
|
575
|
+
code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
|
|
576
|
+
exitCode: 3,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
let actualContext;
|
|
580
|
+
try {
|
|
581
|
+
actualContext = await fileRecord(resolveContained(projectRoot(root, projectId), context.path), context.path);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
throw new CliError("Persistent review evidence context is missing or invalid.", {
|
|
585
|
+
code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
|
|
586
|
+
exitCode: 3,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
if (actualContext.sha256 !== context.sha256 || actualContext.bytes !== context.bytes) {
|
|
590
|
+
throw new CliError("Persistent review evidence context failed hash verification.", {
|
|
591
|
+
code: "RESEARCH_REVIEW_CONTEXT_DRIFT",
|
|
592
|
+
exitCode: 3,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return fileRecord(path, logicalPath);
|
|
596
|
+
}
|
|
597
|
+
function verifyReviewPacketValue(packet, packetSha256) {
|
|
598
|
+
const { packetSha256: recordedSha256, ...body } = packet;
|
|
599
|
+
if (recordedSha256 !== packetSha256 || sha256Text(canonicalJson(body)) !== packetSha256) {
|
|
600
|
+
throw new CliError("Persistent review packet failed content-address verification.", {
|
|
601
|
+
code: "RESEARCH_REVIEW_PACKET_DRIFT",
|
|
602
|
+
exitCode: 3,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async function reviewEnvironmentPacket(root, projectId) {
|
|
607
|
+
const paths = workspacePaths(root);
|
|
608
|
+
const runtimeLock = await readJsonFile(paths.runtimeLock, "Research runtime lock");
|
|
609
|
+
const capabilityLock = (await pathExists(paths.capabilityLock))
|
|
610
|
+
? await readJsonFile(paths.capabilityLock, "Capability lock")
|
|
611
|
+
: { capabilities: [] };
|
|
612
|
+
const capabilities = Array.isArray(capabilityLock.capabilities)
|
|
613
|
+
? capabilityLock.capabilities.filter(isObject).map((record) => ({
|
|
614
|
+
id: record.id,
|
|
615
|
+
skillName: record.skillName,
|
|
616
|
+
treeSha256: record.treeSha256,
|
|
617
|
+
policySha256: record.policySha256,
|
|
618
|
+
permissions: record.permissions,
|
|
619
|
+
credentialIds: record.credentialIds,
|
|
620
|
+
}))
|
|
621
|
+
: [];
|
|
622
|
+
const runsPath = join(projectRoot(root, projectId), "runs");
|
|
623
|
+
const priorRuns = [];
|
|
624
|
+
if (await pathExists(runsPath)) {
|
|
625
|
+
for (const path of await regularTreeFiles(runsPath)) {
|
|
626
|
+
const record = JSON.parse(await readFile(path, "utf8"));
|
|
627
|
+
if (!isObject(record))
|
|
628
|
+
continue;
|
|
629
|
+
priorRuns.push({
|
|
630
|
+
runId: record.runId,
|
|
631
|
+
packageId: record.packageId,
|
|
632
|
+
executor: record.executor,
|
|
633
|
+
tokens: record.tokens,
|
|
634
|
+
inputTokens: record.inputTokens,
|
|
635
|
+
cachedInputTokens: record.cachedInputTokens,
|
|
636
|
+
outputTokens: record.outputTokens,
|
|
637
|
+
costUsd: record.costUsd,
|
|
638
|
+
outputs: record.outputs,
|
|
639
|
+
runtime: record.runtime,
|
|
640
|
+
telemetry: record.telemetry,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
schemaVersion: 1,
|
|
646
|
+
cli: {
|
|
647
|
+
packageName: runtimeLock.packageName,
|
|
648
|
+
packageVersion: runtimeLock.packageVersion,
|
|
649
|
+
protocolVersion: runtimeLock.protocolVersion,
|
|
650
|
+
},
|
|
651
|
+
capabilities,
|
|
652
|
+
priorRuns: priorRuns.sort((left, right) => String(left.packageId).localeCompare(String(right.packageId))),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function reviewSafeReceipt(receipt) {
|
|
656
|
+
return {
|
|
657
|
+
schemaVersion: receipt.schemaVersion,
|
|
658
|
+
attemptId: receipt.attemptId,
|
|
659
|
+
capabilityId: receipt.capabilityId,
|
|
660
|
+
status: receipt.status,
|
|
661
|
+
contentType: receipt.contentType,
|
|
662
|
+
bytes: receipt.bytes,
|
|
663
|
+
sha256: receipt.sha256,
|
|
664
|
+
sourceSha256: receipt.sourceSha256,
|
|
665
|
+
locator: receipt.locator,
|
|
666
|
+
contextLocator: receipt.contextLocator,
|
|
667
|
+
contextSha256: receipt.contextSha256,
|
|
668
|
+
contextBytes: receipt.contextBytes,
|
|
669
|
+
contextEstimatedTokens: receipt.contextEstimatedTokens,
|
|
670
|
+
contextItems: receipt.contextItems,
|
|
671
|
+
contextOffset: receipt.contextOffset ?? 0,
|
|
672
|
+
contextTotalItems: receipt.contextTotalItems ?? null,
|
|
673
|
+
contextNextOffset: receipt.contextNextOffset ?? null,
|
|
674
|
+
contextTruncated: receipt.contextTruncated,
|
|
675
|
+
retrievedAt: receipt.retrievedAt,
|
|
676
|
+
servedAt: receipt.servedAt,
|
|
677
|
+
cacheHit: receipt.cacheHit,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function agentRequest(input) {
|
|
681
|
+
const toolPolicy = input.purpose === "repair" ||
|
|
682
|
+
input.workPackage.stage === "analyze" ||
|
|
683
|
+
input.workPackage.stage === "synthesize" ||
|
|
684
|
+
input.workPackage.stage === "review" ||
|
|
685
|
+
(input.workPackage.stage === "discover" && input.brokerUrl === null)
|
|
686
|
+
? "none"
|
|
687
|
+
: "workspace-read";
|
|
688
|
+
return {
|
|
689
|
+
route: input.route,
|
|
690
|
+
prompt: input.prompt,
|
|
691
|
+
outputSchema: schemaForStage(input.workPackage.stage, input.capsule.reviewPacketSha256, input.inputOnlyProvenance
|
|
692
|
+
? { inputOnlyProvenanceIds: input.capsule.inputManifest.map((record) => record.id) }
|
|
693
|
+
: {}),
|
|
694
|
+
requestId: input.requestId,
|
|
695
|
+
purpose: input.purpose,
|
|
696
|
+
capsuleRoot: input.capsule.capsuleRoot,
|
|
697
|
+
projectRoot: input.capsule.projectRoot,
|
|
698
|
+
workspaceRoot: input.root,
|
|
699
|
+
timeoutSeconds: Math.min(remainingWallSeconds(input.project, input.config), input.maxWallSeconds ??
|
|
700
|
+
input.config.budget.packageMaxWallSeconds[input.workPackage.stage]),
|
|
701
|
+
maxTurns: input.purpose === "repair"
|
|
702
|
+
? RESEARCH_REPAIR_MAX_TURNS
|
|
703
|
+
: toolPolicy === "none"
|
|
704
|
+
? RESEARCH_STRUCTURED_OUTPUT_MAX_TURNS
|
|
705
|
+
: RESEARCH_BROKER_MAX_TURNS,
|
|
706
|
+
maxOutputTokens: input.maxOutputTokens,
|
|
707
|
+
maxCostUsd: input.maxCostUsd,
|
|
708
|
+
expectedRuntime: input.expectedRuntime,
|
|
709
|
+
toolPolicy,
|
|
710
|
+
environment: input.options.environment,
|
|
711
|
+
brokerUrl: input.brokerUrl,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function runtimeForRoute(attestation, route) {
|
|
715
|
+
if (!attestation)
|
|
716
|
+
return undefined;
|
|
717
|
+
const runtime = attestation.runtimes.find((candidate) => candidate.agent === route.agent && candidate.model === route.model);
|
|
718
|
+
if (!runtime) {
|
|
719
|
+
throw new CliError(`Doctor attestation does not contain the ${route.agent} route.`, {
|
|
720
|
+
code: "RESEARCH_DOCTOR_ATTESTATION_INVALID",
|
|
721
|
+
exitCode: 3,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return runtime;
|
|
725
|
+
}
|
|
726
|
+
async function materializeAndValidateStageOutput(root, project, capsuleProject, workPackage, raw, reviewPacketSha256) {
|
|
727
|
+
if (workPackage.stage === "close" || workPackage.expectedOutputs.length !== 1) {
|
|
728
|
+
throw new CliError("Agent package output declaration is unsupported.", {
|
|
729
|
+
code: "RESEARCH_PACKAGE_INVALID",
|
|
730
|
+
exitCode: 3,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
const parsed = parseStructuredStageOutput(workPackage.stage, raw, reviewPacketSha256);
|
|
734
|
+
const destination = resolveContained(capsuleProject, workPackage.expectedOutputs[0]);
|
|
735
|
+
const fileContent = workPackage.stage === "discover"
|
|
736
|
+
? `${JSON.stringify(normalizeEvidenceCoverage(project, parsed.value), null, 2)}\n`
|
|
737
|
+
: parsed.fileContent;
|
|
738
|
+
await writeTextAtomic(destination, fileContent);
|
|
739
|
+
await validateOutputShape(root, project, workPackage, destination, reviewPacketSha256);
|
|
244
740
|
}
|
|
245
741
|
async function validateAndImportOutputs(root, project, workPackage, capsuleProject, config, reviewPacketSha256) {
|
|
246
742
|
const admitted = [];
|
|
247
743
|
let totalBytes = 0;
|
|
248
744
|
if (workPackage.expectedOutputs.length > config.budget.maxFilesPerPackage) {
|
|
249
|
-
throw
|
|
745
|
+
throw deterministicError("Declared output count exceeds the package file budget.");
|
|
250
746
|
}
|
|
251
747
|
for (const logicalPath of workPackage.expectedOutputs) {
|
|
252
748
|
const source = resolveContained(capsuleProject, logicalPath);
|
|
253
749
|
const record = await fileRecord(source, logicalPath);
|
|
254
750
|
totalBytes += record.bytes;
|
|
255
751
|
if (totalBytes > config.budget.maxBytesPerPackage) {
|
|
256
|
-
throw
|
|
752
|
+
throw deterministicError("Package outputs exceed the byte budget.");
|
|
257
753
|
}
|
|
258
|
-
await validateOutputShape(root, project,
|
|
754
|
+
await validateOutputShape(root, project, workPackage, source, reviewPacketSha256);
|
|
259
755
|
admitted.push({ logicalPath, content: await readFile(source, "utf8"), record });
|
|
260
756
|
}
|
|
261
757
|
for (const output of admitted) {
|
|
@@ -265,41 +761,27 @@ async function validateAndImportOutputs(root, project, workPackage, capsuleProje
|
|
|
265
761
|
}
|
|
266
762
|
return admitted.map((output) => output.record);
|
|
267
763
|
}
|
|
268
|
-
async function validateOutputShape(root, project,
|
|
764
|
+
async function validateOutputShape(root, project, workPackage, path, reviewPacketSha256) {
|
|
269
765
|
const content = await readFile(path, "utf8");
|
|
270
766
|
if (!content.trim())
|
|
271
|
-
throw
|
|
272
|
-
if (
|
|
767
|
+
throw deterministicError(`${workPackage.expectedOutputs[0]} is empty.`);
|
|
768
|
+
if (workPackage.stage === "synthesize")
|
|
273
769
|
return;
|
|
274
|
-
const value =
|
|
275
|
-
if (
|
|
276
|
-
throw new Error(`${logicalPath} must contain a JSON object`);
|
|
277
|
-
if (logicalPath.endsWith("evidence.json")) {
|
|
278
|
-
if (!Array.isArray(value.sources) ||
|
|
279
|
-
value.sources.length === 0 ||
|
|
280
|
-
!Array.isArray(value.limitations) ||
|
|
281
|
-
value.limitations.some((item) => typeof item !== "string")) {
|
|
282
|
-
throw new Error("evidence.json must contain sources and limitations arrays");
|
|
283
|
-
}
|
|
770
|
+
const { value } = parseStructuredStageOutput(workPackage.stage, content, reviewPacketSha256);
|
|
771
|
+
if (workPackage.stage === "discover") {
|
|
284
772
|
await validateEvidenceSources(root, project, value.sources);
|
|
285
773
|
}
|
|
286
|
-
if (
|
|
287
|
-
if (!Array.isArray(value.findings) || value.findings.length === 0) {
|
|
288
|
-
throw new Error("analysis.json must contain a non-empty findings array");
|
|
289
|
-
}
|
|
774
|
+
if (workPackage.stage === "analyze") {
|
|
290
775
|
await validateFindings(path, value.findings);
|
|
291
776
|
}
|
|
292
|
-
if (
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
throw new Error("review.json must bind the review packet, decision, issues, and rationale");
|
|
777
|
+
if (workPackage.stage === "review") {
|
|
778
|
+
if (value.decision !== "pass") {
|
|
779
|
+
throw new CliError("Independent review requested revision.", {
|
|
780
|
+
code: "RESEARCH_REVIEW_REVISION_REQUIRED",
|
|
781
|
+
exitCode: 3,
|
|
782
|
+
details: { issues: value.issues },
|
|
783
|
+
});
|
|
300
784
|
}
|
|
301
|
-
if (value.decision !== "pass")
|
|
302
|
-
throw new Error("independent review requested revision");
|
|
303
785
|
}
|
|
304
786
|
}
|
|
305
787
|
async function validateEvidenceSources(root, project, sources) {
|
|
@@ -307,47 +789,65 @@ async function validateEvidenceSources(root, project, sources) {
|
|
|
307
789
|
input.id,
|
|
308
790
|
join("inputs", input.id, basename(input.path)).replaceAll("\\", "/"),
|
|
309
791
|
]));
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
if (event.scope !== project.id || event.type !== "capability.fetch.completed")
|
|
313
|
-
continue;
|
|
314
|
-
const attemptId = event.payload.attemptId;
|
|
315
|
-
const locator = event.payload.path;
|
|
316
|
-
if (typeof attemptId === "string" && typeof locator === "string") {
|
|
317
|
-
brokerLocators.set(attemptId, locator);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
792
|
+
const receipts = await loadProjectEvidenceReceipts(root, project.id);
|
|
793
|
+
const brokerLocators = new Map(receipts.map((receipt) => [receipt.attemptId, receipt.locator]));
|
|
320
794
|
const sourceIds = new Set();
|
|
321
795
|
for (const source of sources) {
|
|
322
|
-
if (!isObject(source) ||
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
typeof source.relevance !== "string" ||
|
|
331
|
-
!source.relevance.trim() ||
|
|
332
|
-
!isObject(source.provenance) ||
|
|
333
|
-
(source.provenance.kind !== "input" && source.provenance.kind !== "broker") ||
|
|
334
|
-
typeof source.provenance.id !== "string") {
|
|
335
|
-
throw new Error("evidence.json contains an invalid or duplicate source");
|
|
796
|
+
if (!isObject(source) || !isObject(source.provenance)) {
|
|
797
|
+
throw new StructuredOutputError("discover output contains an invalid evidence source.");
|
|
798
|
+
}
|
|
799
|
+
const id = source.id;
|
|
800
|
+
if (typeof id !== "string" || sourceIds.has(id)) {
|
|
801
|
+
throw new StructuredOutputError("discover output contains a duplicate source ID.", {
|
|
802
|
+
validation: [`source ID must be unique: ${String(id)}`],
|
|
803
|
+
});
|
|
336
804
|
}
|
|
337
805
|
const expectedLocator = source.provenance.kind === "input"
|
|
338
|
-
? inputLocators.get(source.provenance.id)
|
|
339
|
-
:
|
|
806
|
+
? inputLocators.get(String(source.provenance.id))
|
|
807
|
+
: source.provenance.kind === "broker"
|
|
808
|
+
? brokerLocators.get(String(source.provenance.id))
|
|
809
|
+
: undefined;
|
|
340
810
|
if (!expectedLocator || expectedLocator !== source.locator) {
|
|
341
|
-
throw new
|
|
811
|
+
throw new StructuredOutputError(`discover output contains invalid provenance for evidence source ${String(id)}.`, {
|
|
812
|
+
validation: [
|
|
813
|
+
"provenance.id must be an exact immutable input ID or broker receipt attemptId",
|
|
814
|
+
"locator must exactly match the locator bound to that provenance record",
|
|
815
|
+
],
|
|
816
|
+
actual: {
|
|
817
|
+
kind: source.provenance.kind,
|
|
818
|
+
id: source.provenance.id,
|
|
819
|
+
locator: source.locator,
|
|
820
|
+
},
|
|
821
|
+
allowedInputs: [...inputLocators].map(([inputId, locator]) => ({
|
|
822
|
+
kind: "input",
|
|
823
|
+
id: inputId,
|
|
824
|
+
locator,
|
|
825
|
+
})),
|
|
826
|
+
allowedBrokerReceipts: [...brokerLocators].map(([attemptId, locator]) => ({
|
|
827
|
+
kind: "broker",
|
|
828
|
+
id: attemptId,
|
|
829
|
+
locator,
|
|
830
|
+
})),
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
if (typeof source.url === "string")
|
|
834
|
+
assertPublicEvidenceUrl(source.url, id);
|
|
835
|
+
if (typeof source.retrievedAt !== "string" ||
|
|
836
|
+
!Number.isFinite(Date.parse(source.retrievedAt))) {
|
|
837
|
+
throw new StructuredOutputError(`discover output contains an invalid retrieval date for evidence source ${String(id)}.`);
|
|
342
838
|
}
|
|
343
|
-
|
|
839
|
+
if (source.publicationDate !== null &&
|
|
840
|
+
publicationDateInterval(source.publicationDate) === null) {
|
|
841
|
+
throw new StructuredOutputError(`discover output contains an invalid publication date for evidence source ${String(id)}.`);
|
|
842
|
+
}
|
|
843
|
+
sourceIds.add(id);
|
|
344
844
|
}
|
|
345
845
|
}
|
|
346
846
|
async function validateFindings(path, findings) {
|
|
347
847
|
const evidencePath = join(dirname(path), "evidence.json");
|
|
348
848
|
const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
|
|
349
849
|
if (!isObject(evidence) || !Array.isArray(evidence.sources)) {
|
|
350
|
-
throw
|
|
850
|
+
throw deterministicError("Analysis requires admitted evidence.json.");
|
|
351
851
|
}
|
|
352
852
|
const sourceIds = new Set(evidence.sources
|
|
353
853
|
.filter((source) => isObject(source))
|
|
@@ -357,21 +857,165 @@ async function validateFindings(path, findings) {
|
|
|
357
857
|
for (const finding of findings) {
|
|
358
858
|
if (!isObject(finding) ||
|
|
359
859
|
typeof finding.id !== "string" ||
|
|
360
|
-
!finding.id.trim() ||
|
|
361
860
|
findingIds.has(finding.id) ||
|
|
362
|
-
typeof finding.statement !== "string" ||
|
|
363
|
-
!finding.statement.trim() ||
|
|
364
861
|
!Array.isArray(finding.evidence) ||
|
|
365
|
-
finding.evidence.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
862
|
+
finding.evidence.some((id) => typeof id !== "string" || !sourceIds.has(id))) {
|
|
863
|
+
throw new StructuredOutputError("analyze output contains an invalid or untraceable finding.", {
|
|
864
|
+
validation: [
|
|
865
|
+
"finding IDs must be unique and evidence IDs must reference admitted sources",
|
|
866
|
+
],
|
|
867
|
+
admittedEvidenceIds: [...sourceIds].sort(),
|
|
868
|
+
});
|
|
370
869
|
}
|
|
371
870
|
findingIds.add(finding.id);
|
|
372
871
|
}
|
|
373
872
|
}
|
|
873
|
+
function normalizeEvidenceCoverage(project, value) {
|
|
874
|
+
const inputIds = new Set(project.inputs.map((input) => input.id));
|
|
875
|
+
const sources = (value.sources ?? []).map((source) => {
|
|
876
|
+
const provenance = isObject(source.provenance) ? source.provenance : {};
|
|
877
|
+
return provenance.kind === "input" && inputIds.has(String(provenance.id))
|
|
878
|
+
? { ...source, fullTextAvailable: true }
|
|
879
|
+
: source;
|
|
880
|
+
});
|
|
881
|
+
const declared = isObject(value.coverage) ? value.coverage : {};
|
|
882
|
+
const computed = computeEvidenceCoverage(project, sources, declared);
|
|
883
|
+
const declaredGaps = Array.isArray(declared.gaps)
|
|
884
|
+
? declared.gaps.filter((gap) => typeof gap === "string")
|
|
885
|
+
: [];
|
|
886
|
+
return {
|
|
887
|
+
...value,
|
|
888
|
+
sources,
|
|
889
|
+
coverage: {
|
|
890
|
+
dimensions: computed.dimensions,
|
|
891
|
+
sourceTypes: computed.sourceTypes,
|
|
892
|
+
fullTextSources: computed.fullTextSources,
|
|
893
|
+
datedSources: computed.datedSources,
|
|
894
|
+
publicationDateRange: computed.publicationDateRange,
|
|
895
|
+
decision: computed.decision,
|
|
896
|
+
gaps: [...new Set([...declaredGaps, ...computed.mechanicalGaps])],
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function computeEvidenceCoverage(project, sources, declared) {
|
|
901
|
+
const gaps = [];
|
|
902
|
+
if (sources.length < project.evidenceRequirements.minSources) {
|
|
903
|
+
gaps.push(`requires ${project.evidenceRequirements.minSources} source(s), found ${sources.length}`);
|
|
904
|
+
}
|
|
905
|
+
const fullTextSources = sources.filter((source) => source.fullTextAvailable === true).length;
|
|
906
|
+
if (fullTextSources < project.evidenceRequirements.minFullTextSources) {
|
|
907
|
+
gaps.push(`requires ${project.evidenceRequirements.minFullTextSources} full-text source(s), found ${fullTextSources}`);
|
|
908
|
+
}
|
|
909
|
+
const publicationIntervals = sources.flatMap((source) => {
|
|
910
|
+
const interval = publicationDateInterval(source.publicationDate);
|
|
911
|
+
return interval ? [interval] : [];
|
|
912
|
+
});
|
|
913
|
+
const requiredFrom = project.evidenceRequirements.publicationDateFrom;
|
|
914
|
+
const requiredTo = project.evidenceRequirements.publicationDateTo;
|
|
915
|
+
const inRangeDatedSources = publicationIntervals.filter((interval) => (requiredFrom === null || interval.latest >= requiredFrom) &&
|
|
916
|
+
(requiredTo === null || interval.earliest <= requiredTo)).length;
|
|
917
|
+
if (inRangeDatedSources < project.evidenceRequirements.minDatedSources) {
|
|
918
|
+
gaps.push(`requires ${project.evidenceRequirements.minDatedSources} dated source(s) within the publication boundary, found ${inRangeDatedSources}`);
|
|
919
|
+
}
|
|
920
|
+
const publicationDateRange = {
|
|
921
|
+
earliest: publicationIntervals.length
|
|
922
|
+
? publicationIntervals.map((interval) => interval.earliest).sort()[0]
|
|
923
|
+
: null,
|
|
924
|
+
latest: publicationIntervals.length
|
|
925
|
+
? publicationIntervals
|
|
926
|
+
.map((interval) => interval.latest)
|
|
927
|
+
.sort()
|
|
928
|
+
.at(-1)
|
|
929
|
+
: null,
|
|
930
|
+
};
|
|
931
|
+
const sourceTypes = [...new Set(sources.map((source) => String(source.sourceType)))].sort();
|
|
932
|
+
for (const sourceType of project.evidenceRequirements.sourceTypes) {
|
|
933
|
+
if (!sourceTypes.includes(sourceType))
|
|
934
|
+
gaps.push(`missing required source type: ${sourceType}`);
|
|
935
|
+
}
|
|
936
|
+
const declaredDimensions = Array.isArray(declared.dimensions)
|
|
937
|
+
? declared.dimensions.filter(isObject)
|
|
938
|
+
: [];
|
|
939
|
+
const dimensions = project.evidenceRequirements.dimensions.map((dimension) => {
|
|
940
|
+
const sourceIds = sources
|
|
941
|
+
.filter((source) => Array.isArray(source.coverageDimensions) && source.coverageDimensions.includes(dimension))
|
|
942
|
+
.map((source) => String(source.id))
|
|
943
|
+
.sort();
|
|
944
|
+
const entry = declaredDimensions.find((item) => item.id === dimension);
|
|
945
|
+
const declaredStatus = entry?.status;
|
|
946
|
+
const status = sourceIds.length
|
|
947
|
+
? declaredStatus === "covered"
|
|
948
|
+
? "covered"
|
|
949
|
+
: "partial"
|
|
950
|
+
: "missing";
|
|
951
|
+
if (!sourceIds.length)
|
|
952
|
+
gaps.push(`missing evidence dimension: ${dimension}`);
|
|
953
|
+
return { id: dimension, status, sourceIds };
|
|
954
|
+
});
|
|
955
|
+
return {
|
|
956
|
+
dimensions,
|
|
957
|
+
sourceTypes,
|
|
958
|
+
fullTextSources,
|
|
959
|
+
datedSources: publicationIntervals.length,
|
|
960
|
+
publicationDateRange,
|
|
961
|
+
decision: gaps.length ? "insufficient" : "pass",
|
|
962
|
+
mechanicalGaps: gaps,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
async function assertEvidenceCoverage(root, project) {
|
|
966
|
+
const path = resolveContained(projectRoot(root, project.id), "outputs/evidence.json");
|
|
967
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
968
|
+
const sources = value.sources;
|
|
969
|
+
const declared = value.coverage;
|
|
970
|
+
const computed = computeEvidenceCoverage(project, sources, declared);
|
|
971
|
+
const gaps = [...computed.mechanicalGaps];
|
|
972
|
+
if (canonicalJson(declared.dimensions) !== canonicalJson(computed.dimensions) ||
|
|
973
|
+
canonicalJson(declared.sourceTypes) !== canonicalJson(computed.sourceTypes) ||
|
|
974
|
+
declared.fullTextSources !== computed.fullTextSources ||
|
|
975
|
+
declared.datedSources !== computed.datedSources ||
|
|
976
|
+
canonicalJson(declared.publicationDateRange) !== canonicalJson(computed.publicationDateRange)) {
|
|
977
|
+
gaps.push("coverage summary does not match admitted sources");
|
|
978
|
+
}
|
|
979
|
+
if (declared.decision !== computed.decision) {
|
|
980
|
+
gaps.push(`coverage decision must be ${computed.decision}`);
|
|
981
|
+
}
|
|
982
|
+
if (gaps.length) {
|
|
983
|
+
throw new CliError("Evidence coverage is insufficient; downstream packages were not started.", {
|
|
984
|
+
code: "RESEARCH_EVIDENCE_INSUFFICIENT",
|
|
985
|
+
exitCode: 3,
|
|
986
|
+
details: { gaps },
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
function publicationDateInterval(value) {
|
|
991
|
+
if (typeof value !== "string")
|
|
992
|
+
return null;
|
|
993
|
+
const match = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(value);
|
|
994
|
+
if (!match)
|
|
995
|
+
return null;
|
|
996
|
+
const year = Number(match[1]);
|
|
997
|
+
const month = match[2] ? Number(match[2]) : null;
|
|
998
|
+
const day = match[3] ? Number(match[3]) : null;
|
|
999
|
+
if (year < 1 || year > 9999 || (month !== null && (month < 1 || month > 12)))
|
|
1000
|
+
return null;
|
|
1001
|
+
if (day !== null) {
|
|
1002
|
+
const exact = `${match[1]}-${match[2]}-${match[3]}`;
|
|
1003
|
+
const timestamp = Date.parse(`${exact}T00:00:00.000Z`);
|
|
1004
|
+
if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== exact) {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
return { earliest: exact, latest: exact };
|
|
1008
|
+
}
|
|
1009
|
+
if (month !== null) {
|
|
1010
|
+
const monthText = String(month).padStart(2, "0");
|
|
1011
|
+
const earliest = `${match[1]}-${monthText}-01`;
|
|
1012
|
+
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
1013
|
+
return { earliest, latest: `${match[1]}-${monthText}-${String(lastDay).padStart(2, "0")}` };
|
|
1014
|
+
}
|
|
1015
|
+
return { earliest: `${match[1]}-01-01`, latest: `${match[1]}-12-31` };
|
|
1016
|
+
}
|
|
374
1017
|
async function closeProjectMechanically(root, project, workPackage) {
|
|
1018
|
+
await assertEvidenceCoverage(root, project);
|
|
375
1019
|
const required = [
|
|
376
1020
|
"outputs/evidence.json",
|
|
377
1021
|
"outputs/analysis.json",
|
|
@@ -381,8 +1025,14 @@ async function closeProjectMechanically(root, project, workPackage) {
|
|
|
381
1025
|
const artifacts = await outputRecords(root, project, required);
|
|
382
1026
|
const review = JSON.parse(await readFile(resolveContained(projectRoot(root, project.id), "outputs/review.json"), "utf8"));
|
|
383
1027
|
if (!isObject(review) || review.decision !== "pass") {
|
|
384
|
-
throw
|
|
1028
|
+
throw deterministicError("Project cannot close without a passing independent review.");
|
|
1029
|
+
}
|
|
1030
|
+
await verifyProjectInputBindings(project);
|
|
1031
|
+
if (typeof review.packetSha256 !== "string" || !/^[0-9a-f]{64}$/.test(review.packetSha256)) {
|
|
1032
|
+
throw deterministicError("Project review does not bind a valid review packet hash.");
|
|
385
1033
|
}
|
|
1034
|
+
const reviewPacket = await loadVerifiedReviewPacket(root, project.id, review.packetSha256);
|
|
1035
|
+
const evidenceReceipts = await loadProjectEvidenceReceipts(root, project.id);
|
|
386
1036
|
const journal = await verifyJournal(workspacePaths(root).journal);
|
|
387
1037
|
const closure = {
|
|
388
1038
|
schemaVersion: 1,
|
|
@@ -390,40 +1040,503 @@ async function closeProjectMechanically(root, project, workPackage) {
|
|
|
390
1040
|
status: "complete",
|
|
391
1041
|
closedAt: new Date().toISOString(),
|
|
392
1042
|
questionSha256: sha256Text(project.question),
|
|
1043
|
+
evidenceRequirements: project.evidenceRequirements,
|
|
1044
|
+
inputs: project.inputs.map((input) => ({
|
|
1045
|
+
id: input.id,
|
|
1046
|
+
role: input.role,
|
|
1047
|
+
sha256: input.sha256,
|
|
1048
|
+
bytes: input.bytes,
|
|
1049
|
+
contextSha256: input.contextSha256,
|
|
1050
|
+
contextBytes: input.contextBytes,
|
|
1051
|
+
contextRanges: input.contextRanges ?? null,
|
|
1052
|
+
})),
|
|
1053
|
+
reviewPacket: { ...reviewPacket, packetSha256: review.packetSha256 },
|
|
1054
|
+
evidenceObjects: evidenceReceipts.map((receipt) => ({
|
|
1055
|
+
attemptId: receipt.attemptId,
|
|
1056
|
+
locator: receipt.locator,
|
|
1057
|
+
sha256: receipt.sha256,
|
|
1058
|
+
bytes: receipt.bytes,
|
|
1059
|
+
})),
|
|
393
1060
|
artifacts,
|
|
394
1061
|
journalHead: journal.head,
|
|
395
1062
|
};
|
|
396
1063
|
const closurePath = resolveContained(projectRoot(root, project.id), workPackage.expectedOutputs[0]);
|
|
397
1064
|
await writeJsonAtomic(closurePath, closure);
|
|
398
|
-
return
|
|
1065
|
+
return zeroExecutionResult();
|
|
1066
|
+
}
|
|
1067
|
+
async function verifyProjectInputBindings(project) {
|
|
1068
|
+
for (const input of project.inputs) {
|
|
1069
|
+
const info = await lstat(input.path).catch(() => undefined);
|
|
1070
|
+
if (!info?.isFile() ||
|
|
1071
|
+
info.isSymbolicLink() ||
|
|
1072
|
+
info.size !== input.bytes ||
|
|
1073
|
+
(await sha256File(input.path)) !== input.sha256) {
|
|
1074
|
+
throw new CliError(`Registered input failed closure verification: ${input.id}.`, {
|
|
1075
|
+
code: "RESEARCH_INPUT_DRIFT",
|
|
1076
|
+
exitCode: 3,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
if (!input.contextSha256 || input.contextBytes === undefined)
|
|
1080
|
+
continue;
|
|
1081
|
+
let contextValid = false;
|
|
1082
|
+
if (input.contextRanges?.length) {
|
|
1083
|
+
const context = await renderInputLineContext(input.path, input.contextRanges);
|
|
1084
|
+
contextValid =
|
|
1085
|
+
Buffer.byteLength(context, "utf8") === input.contextBytes &&
|
|
1086
|
+
sha256Text(context) === input.contextSha256;
|
|
1087
|
+
}
|
|
1088
|
+
else if (input.contextPath) {
|
|
1089
|
+
const contextInfo = await lstat(input.contextPath).catch(() => undefined);
|
|
1090
|
+
contextValid = Boolean(contextInfo?.isFile() &&
|
|
1091
|
+
!contextInfo.isSymbolicLink() &&
|
|
1092
|
+
contextInfo.size === input.contextBytes &&
|
|
1093
|
+
(await sha256File(input.contextPath)) === input.contextSha256);
|
|
1094
|
+
}
|
|
1095
|
+
if (!contextValid) {
|
|
1096
|
+
throw new CliError(`Registered input context failed closure verification: ${input.id}.`, {
|
|
1097
|
+
code: "RESEARCH_INPUT_DRIFT",
|
|
1098
|
+
exitCode: 3,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
399
1102
|
}
|
|
400
1103
|
async function outputRecords(root, project, logicalPaths) {
|
|
401
1104
|
return Promise.all(logicalPaths.map((logicalPath) => fileRecord(resolveContained(projectRoot(root, project.id), logicalPath), logicalPath)));
|
|
402
1105
|
}
|
|
403
|
-
function
|
|
1106
|
+
async function stageContextForPackage(capsuleProject, workPackage, config) {
|
|
1107
|
+
const logicalPaths = workPackage.stage === "analyze"
|
|
1108
|
+
? ["outputs/evidence.json"]
|
|
1109
|
+
: workPackage.stage === "synthesize"
|
|
1110
|
+
? ["outputs/evidence.json", "outputs/analysis.json"]
|
|
1111
|
+
: workPackage.stage === "review"
|
|
1112
|
+
? [
|
|
1113
|
+
"inputs/review-packet.json",
|
|
1114
|
+
"inputs/review-evidence-context.txt",
|
|
1115
|
+
"outputs/evidence.json",
|
|
1116
|
+
"outputs/analysis.json",
|
|
1117
|
+
"outputs/report.md",
|
|
1118
|
+
]
|
|
1119
|
+
: [];
|
|
1120
|
+
const sections = [];
|
|
1121
|
+
for (const logicalPath of logicalPaths) {
|
|
1122
|
+
const content = await readFile(resolveContained(capsuleProject, logicalPath), "utf8");
|
|
1123
|
+
sections.push(`### ${logicalPath}\n${content.trimEnd()}`);
|
|
1124
|
+
}
|
|
1125
|
+
const bundled = sections.join("\n\n");
|
|
1126
|
+
const estimatedTokens = Math.ceil(Buffer.byteLength(bundled, "utf8") / 4);
|
|
1127
|
+
if (estimatedTokens > config.budget.maxInputContextTokens) {
|
|
1128
|
+
throw new CliError(`Admitted stage context exceeds the configured input context limit for ${workPackage.id}.`, {
|
|
1129
|
+
code: "RESEARCH_INPUT_CONTEXT_BUDGET_EXCEEDED",
|
|
1130
|
+
exitCode: 3,
|
|
1131
|
+
details: {
|
|
1132
|
+
packageId: workPackage.id,
|
|
1133
|
+
estimatedTokens,
|
|
1134
|
+
maxInputContextTokens: config.budget.maxInputContextTokens,
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
return bundled;
|
|
1139
|
+
}
|
|
1140
|
+
function packagePrompt(project, workPackage, inputs, stagedSkills, reviewPacketSha256, contextBundle, contextBundleContent, stageContextContent) {
|
|
404
1141
|
const stageInstructions = {
|
|
405
|
-
discover:
|
|
406
|
-
analyze: "
|
|
407
|
-
synthesize: "
|
|
408
|
-
review: `Independently inspect
|
|
1142
|
+
discover: "Return the evidence object defined by the supplied JSON Schema. Cite only declared inputs or broker receipts. Each source.id is your concise evidence label; it must not be reused as provenance.id. For declared inputs, provenance must use the exact id and path shown in the declared input manifest, with locator=path. Every declared input binds an exact registered full source, so fullTextAvailable=true even when fullTextStaged=false; that flag means the producer receives only its bounded context while independent review binds both the verified full-file hash and the exact bounded review view. For broker evidence, provenance.id must be the exact receipt attemptId and locator must be the receipt locator (not contextLocator); use contextLocator only to inspect the bounded view. Include source type, retrieval metadata, an excerpt or JSON Pointer when available, quality, applicability, coverage dimensions, limitations, and an honest coverage assessment. A partial dimension is usable but incomplete; missing means no admitted source covers it. coverage.gaps records qualitative limitations and does not alone force an insufficient decision. The CLI mechanically derives local-input full-text availability, sourceTypes, counts, date range, sourceIds, and the pass/insufficient decision from admitted sources and declared minimums. Never place credentials or sensitive URL parameters in any field.",
|
|
1143
|
+
analyze: "Use only the complete embedded admitted evidence below and return the schema-defined analysis object. Every finding must cite admitted evidence source IDs and state uncertainty and applicability.",
|
|
1144
|
+
synthesize: "Use only the complete embedded admitted evidence and findings below. Return the schema-defined object whose reportMarkdown separates supported conclusions, uncertainty, limitations, and next actions.",
|
|
1145
|
+
review: `Independently inspect the complete embedded review packet, artifacts, and exact bounded evidence views. The CLI has already verified every bound full evidence object's size and SHA-256 and persistently stored the review packet; do not claim to have read beyond the embedded views. Return the schema-defined review bound to packetSha256 ${reviewPacketSha256 ?? "unavailable"}. Use pass only when every material claim is traceable within the admitted evidence and clearly scoped to its limitations.`,
|
|
409
1146
|
close: "No agent action is allowed for mechanical closure.",
|
|
410
1147
|
};
|
|
411
|
-
|
|
1148
|
+
const prompt = [
|
|
412
1149
|
"Operate only inside this isolated research capsule.",
|
|
413
1150
|
`Project: ${project.id}`,
|
|
414
1151
|
`Question: ${project.question}`,
|
|
415
1152
|
`Stage: ${workPackage.stage}`,
|
|
1153
|
+
`Evidence requirements: ${JSON.stringify(project.evidenceRequirements)}`,
|
|
416
1154
|
`Declared inputs: ${JSON.stringify(inputs)}`,
|
|
1155
|
+
`Bounded input context bundle: ${JSON.stringify(contextBundle)}`,
|
|
417
1156
|
`Staged capability directories: ${JSON.stringify(stagedSkills.map((path) => `skills/${basename(path)}`))}`,
|
|
1157
|
+
workPackage.stage === "discover"
|
|
1158
|
+
? "Keep broker inspection within the package budget and use bounded views."
|
|
1159
|
+
: "Use only the complete embedded stage context; no tools or additional source reads are allowed.",
|
|
418
1160
|
stageInstructions[workPackage.stage],
|
|
419
|
-
|
|
420
|
-
"Do not edit project.json,
|
|
1161
|
+
"Do not write stage output files directly. Your final response must be only the JSON object required by the supplied output schema; the CLI will validate and atomically materialize it.",
|
|
1162
|
+
"Do not edit project.json, input manifests, prior outputs, evidence objects, or staged capability files.",
|
|
1163
|
+
];
|
|
1164
|
+
if (workPackage.stage === "discover") {
|
|
1165
|
+
prompt.push(`Exact local-input provenance mappings: ${JSON.stringify(inputs.map((input) => ({ kind: "input", id: input.id, locator: input.path })))}`, "The complete authorized local-input context is embedded below. Use it directly and do not re-read individual local input files. Full evidence files are intentionally withheld from producer packages when fullTextStaged=false.", contextBundleContent);
|
|
1166
|
+
}
|
|
1167
|
+
if (stageContextContent) {
|
|
1168
|
+
prompt.push("The complete admitted stage context is embedded below. Use it directly and do not re-read output files.", stageContextContent);
|
|
1169
|
+
}
|
|
1170
|
+
return prompt.join("\n\n");
|
|
1171
|
+
}
|
|
1172
|
+
function repairPrompt(workPackage, raw, error) {
|
|
1173
|
+
return [
|
|
1174
|
+
"This is an isolated, low-cost formatting repair. Do not perform research, fetch sources, or add facts.",
|
|
1175
|
+
`Stage: ${workPackage.stage}`,
|
|
1176
|
+
`Validation failure: ${sanitizeResearchText(error.message)}`,
|
|
1177
|
+
`Validation detail: ${JSON.stringify(sanitizeResearchRecord(isObject(error.details) ? error.details : {}))}`,
|
|
1178
|
+
"Return only a corrected JSON object satisfying the supplied schema while preserving the source content below.",
|
|
1179
|
+
`Invalid output:\n${bounded(sanitizeResearchText(raw), 32_000)}`,
|
|
421
1180
|
].join("\n\n");
|
|
422
1181
|
}
|
|
423
|
-
|
|
424
|
-
const
|
|
1182
|
+
function assertPreCallTokenReservation(project, workPackage, config, request, alreadyUsedTokens, reserveRepair) {
|
|
1183
|
+
const schemaBytes = Buffer.byteLength(JSON.stringify(request.outputSchema), "utf8");
|
|
1184
|
+
const promptBytes = Buffer.byteLength(request.prompt, "utf8");
|
|
1185
|
+
const protocolOverhead = RESEARCH_AGENT_PROTOCOL_OVERHEAD_TOKENS[request.route.agent];
|
|
1186
|
+
const callInputTokensPerTurn = protocolOverhead + Math.ceil((schemaBytes + promptBytes) / RESEARCH_ESTIMATED_BYTES_PER_TOKEN);
|
|
1187
|
+
const callInputTokens = callInputTokensPerTurn * request.maxTurns;
|
|
1188
|
+
const repairTokens = reserveRepair
|
|
1189
|
+
? (protocolOverhead +
|
|
1190
|
+
Math.ceil((schemaBytes + RESEARCH_MAX_REPAIR_SOURCE_BYTES + 2_048) /
|
|
1191
|
+
RESEARCH_ESTIMATED_BYTES_PER_TOKEN)) *
|
|
1192
|
+
RESEARCH_REPAIR_MAX_TURNS +
|
|
1193
|
+
config.budget.maxRepairTokens
|
|
1194
|
+
: 0;
|
|
1195
|
+
const reservation = {
|
|
1196
|
+
alreadyUsedTokens,
|
|
1197
|
+
maxTurns: request.maxTurns,
|
|
1198
|
+
estimatedCallInputTokensPerTurn: callInputTokensPerTurn,
|
|
1199
|
+
estimatedCallInputTokens: callInputTokens,
|
|
1200
|
+
outputTokens: request.maxOutputTokens,
|
|
1201
|
+
potentialRepairTokens: repairTokens,
|
|
1202
|
+
totalTokens: alreadyUsedTokens + callInputTokens + request.maxOutputTokens + repairTokens,
|
|
1203
|
+
};
|
|
1204
|
+
const packageMaxTokens = config.budget.packageMaxTokens[workPackage.stage];
|
|
1205
|
+
const projectRemainingTokens = Math.max(0, config.budget.maxTokens - project.usage.tokens);
|
|
1206
|
+
if (reservation.totalTokens > packageMaxTokens ||
|
|
1207
|
+
reservation.totalTokens > projectRemainingTokens) {
|
|
1208
|
+
throw new CliError(`Pre-call input/output reservation does not fit package ${workPackage.id}.`, {
|
|
1209
|
+
code: "RESEARCH_BUDGET_RESERVATION_FAILED",
|
|
1210
|
+
exitCode: 3,
|
|
1211
|
+
details: {
|
|
1212
|
+
packageId: workPackage.id,
|
|
1213
|
+
packageMaxTokens,
|
|
1214
|
+
projectRemainingTokens,
|
|
1215
|
+
reservation,
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function reservePackageBudget(project, workPackage, config) {
|
|
1221
|
+
if (workPackage.stage === "close")
|
|
1222
|
+
return { tokens: 0, costUsd: 0 };
|
|
1223
|
+
const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
|
|
1224
|
+
const tokens = config.budget.packageMaxTokens[workPackage.stage];
|
|
1225
|
+
const costUsd = roundMoney(reservedAgentPackageCost(route, tokens, config));
|
|
1226
|
+
const wallSeconds = config.budget.packageMaxWallSeconds[workPackage.stage];
|
|
1227
|
+
const remaining = remainingBudget(project, config);
|
|
1228
|
+
if (remaining.tokens < tokens ||
|
|
1229
|
+
remaining.costUsd < costUsd ||
|
|
1230
|
+
remaining.wallSeconds < wallSeconds) {
|
|
1231
|
+
throw new CliError(`Remaining budget cannot reserve package ${workPackage.id} for project ${project.id}.`, {
|
|
1232
|
+
code: "RESEARCH_BUDGET_RESERVATION_FAILED",
|
|
1233
|
+
exitCode: 3,
|
|
1234
|
+
details: {
|
|
1235
|
+
remaining,
|
|
1236
|
+
reservation: { tokens, costUsd, wallSeconds },
|
|
1237
|
+
packageId: workPackage.id,
|
|
1238
|
+
},
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
return { tokens, costUsd };
|
|
1242
|
+
}
|
|
1243
|
+
function assertActualPackageBudget(project, workPackage, config, result, maxOutputTokens) {
|
|
1244
|
+
if (workPackage.stage !== "close" &&
|
|
1245
|
+
result.tokens > config.budget.packageMaxTokens[workPackage.stage]) {
|
|
1246
|
+
throw new CliError(`Executor exceeded the package token limit for ${workPackage.id}.`, {
|
|
1247
|
+
code: "RESEARCH_PACKAGE_BUDGET_EXCEEDED",
|
|
1248
|
+
exitCode: 3,
|
|
1249
|
+
details: {
|
|
1250
|
+
projectId: project.id,
|
|
1251
|
+
packageId: workPackage.id,
|
|
1252
|
+
actualTokens: result.tokens,
|
|
1253
|
+
maxTokens: config.budget.packageMaxTokens[workPackage.stage],
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
if (result.outputTokens > maxOutputTokens) {
|
|
1258
|
+
throw new CliError(`Executor exceeded the output token limit for ${workPackage.id}.`, {
|
|
1259
|
+
code: "RESEARCH_PACKAGE_OUTPUT_BUDGET_EXCEEDED",
|
|
1260
|
+
exitCode: 3,
|
|
1261
|
+
details: {
|
|
1262
|
+
projectId: project.id,
|
|
1263
|
+
packageId: workPackage.id,
|
|
1264
|
+
actualOutputTokens: result.outputTokens,
|
|
1265
|
+
maxOutputTokens,
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
if (workPackage.stage !== "close" &&
|
|
1270
|
+
result.wallSeconds > config.budget.packageMaxWallSeconds[workPackage.stage]) {
|
|
1271
|
+
throw new CliError(`Executor exceeded the package wall-time limit for ${workPackage.id}.`, {
|
|
1272
|
+
code: "RESEARCH_PACKAGE_WALL_BUDGET_EXCEEDED",
|
|
1273
|
+
exitCode: 3,
|
|
1274
|
+
details: {
|
|
1275
|
+
projectId: project.id,
|
|
1276
|
+
packageId: workPackage.id,
|
|
1277
|
+
actualWallSeconds: result.wallSeconds,
|
|
1278
|
+
maxWallSeconds: config.budget.packageMaxWallSeconds[workPackage.stage],
|
|
1279
|
+
},
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
function availableRepairTokens(project, workPackage, config, primary) {
|
|
1284
|
+
if (workPackage.stage === "close")
|
|
1285
|
+
return 0;
|
|
1286
|
+
if (primary.wallSeconds >= config.budget.packageMaxWallSeconds[workPackage.stage]) {
|
|
1287
|
+
return 0;
|
|
1288
|
+
}
|
|
1289
|
+
return Math.max(0, Math.min(config.budget.maxRepairTokens, config.budget.packageMaxTokens[workPackage.stage] - primary.tokens, config.budget.maxTokens - project.usage.tokens - primary.tokens));
|
|
1290
|
+
}
|
|
1291
|
+
function assertProjectedBudget(project, config, result) {
|
|
1292
|
+
const projected = {
|
|
1293
|
+
tokens: project.usage.tokens + result.tokens,
|
|
1294
|
+
costUsd: project.usage.costUsd + result.costUsd,
|
|
1295
|
+
wallSeconds: project.usage.wallSeconds + result.wallSeconds,
|
|
1296
|
+
};
|
|
1297
|
+
if (projected.tokens > config.budget.maxTokens ||
|
|
1298
|
+
projected.costUsd > config.budget.maxCostUsd ||
|
|
1299
|
+
projected.wallSeconds > config.budget.maxWallSeconds) {
|
|
1300
|
+
throw new CliError(`Research execution exceeded a hard budget for project ${project.id}.`, {
|
|
1301
|
+
code: "RESEARCH_BUDGET_EXHAUSTED",
|
|
1302
|
+
exitCode: 3,
|
|
1303
|
+
details: { projected, budget: config.budget },
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function remainingBudget(project, config) {
|
|
1308
|
+
return {
|
|
1309
|
+
tokens: Math.max(0, config.budget.maxTokens - project.usage.tokens),
|
|
1310
|
+
costUsd: Math.max(0, roundMoney(config.budget.maxCostUsd - project.usage.costUsd)),
|
|
1311
|
+
wallSeconds: Math.max(0, config.budget.maxWallSeconds - project.usage.wallSeconds),
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
function remainingWallSeconds(project, config) {
|
|
1315
|
+
return Math.max(1, Math.floor(config.budget.maxWallSeconds - project.usage.wallSeconds));
|
|
1316
|
+
}
|
|
1317
|
+
function assertExecutionConfiguration(config) {
|
|
1318
|
+
if (config.producer.agent === config.reviewer.agent) {
|
|
1319
|
+
throw new CliError("Research producer and reviewer must use different agent families.", {
|
|
1320
|
+
code: "RESEARCH_REVIEW_ROUTE_INVALID",
|
|
1321
|
+
exitCode: 3,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
if (config.mode === "production-research" && (!config.producer.model || !config.reviewer.model)) {
|
|
1325
|
+
throw new CliError("Production research requires explicit producer and reviewer models.", {
|
|
1326
|
+
code: "RESEARCH_MODEL_REQUIRED",
|
|
1327
|
+
exitCode: 3,
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
if (config.mode === "production-research" &&
|
|
1331
|
+
(!config.producer.pricing || !config.reviewer.pricing)) {
|
|
1332
|
+
throw new CliError("Production research requires explicit producer and reviewer pricing.", {
|
|
1333
|
+
code: "RESEARCH_PRICING_REQUIRED",
|
|
1334
|
+
exitCode: 3,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
function assertExecutorSucceeded(result) {
|
|
1339
|
+
if (result.exitCode === 0)
|
|
1340
|
+
return;
|
|
1341
|
+
const diagnostic = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
|
1342
|
+
throw new CliError(`Executor exited ${result.exitCode}: ${bounded(diagnostic || "no diagnostic output", 1000)}`, {
|
|
1343
|
+
code: "RESEARCH_EXECUTOR_FAILED",
|
|
1344
|
+
exitCode: 3,
|
|
1345
|
+
details: { exitCode: result.exitCode },
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
function sanitizedFailureDetails(error, secrets) {
|
|
1349
|
+
if (!(error instanceof CliError) || !isObject(error.details))
|
|
1350
|
+
return null;
|
|
1351
|
+
const sanitized = sanitizeResearchRecord(error.details, secrets);
|
|
1352
|
+
const encoded = JSON.stringify(sanitized);
|
|
1353
|
+
if (encoded.length <= 16_000)
|
|
1354
|
+
return sanitized;
|
|
1355
|
+
return {
|
|
1356
|
+
truncated: true,
|
|
1357
|
+
sha256: sha256Text(encoded),
|
|
1358
|
+
preview: bounded(encoded, 12_000),
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
function classifyFailure(error) {
|
|
1362
|
+
if (error instanceof StructuredOutputError) {
|
|
1363
|
+
return { kind: "structured-output", retryable: false, retryAfterSeconds: null };
|
|
1364
|
+
}
|
|
1365
|
+
if (error instanceof CliError) {
|
|
1366
|
+
if (error.code.includes("BUDGET")) {
|
|
1367
|
+
return { kind: "budget", retryable: false, retryAfterSeconds: null };
|
|
1368
|
+
}
|
|
1369
|
+
if (error.code.includes("CONFIG") ||
|
|
1370
|
+
error.code.includes("INVALID") ||
|
|
1371
|
+
error.code.includes("DRIFT") ||
|
|
1372
|
+
error.code.includes("UNAVAILABLE") ||
|
|
1373
|
+
error.code === "RESEARCH_EVIDENCE_INSUFFICIENT" ||
|
|
1374
|
+
error.code === "RESEARCH_REVIEW_REVISION_REQUIRED") {
|
|
1375
|
+
return { kind: "configuration", retryable: false, retryAfterSeconds: null };
|
|
1376
|
+
}
|
|
1377
|
+
if (error.code === "RESEARCH_BROKER_HTTP_ERROR" && isObject(error.details)) {
|
|
1378
|
+
const status = error.details.status;
|
|
1379
|
+
const retryAfter = numericOrNull(error.details.retryAfterSeconds);
|
|
1380
|
+
if (status === 429) {
|
|
1381
|
+
return { kind: "rate-limit", retryable: true, retryAfterSeconds: retryAfter };
|
|
1382
|
+
}
|
|
1383
|
+
if (typeof status === "number" && status >= 500) {
|
|
1384
|
+
return { kind: "server", retryable: true, retryAfterSeconds: null };
|
|
1385
|
+
}
|
|
1386
|
+
return { kind: "deterministic", retryable: false, retryAfterSeconds: null };
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1390
|
+
if (/error_max_budget|budget_exhausted|reached maximum budget|max(?:imum)? budget usd|error_max_turns|reached maximum (?:number of )?turns|max_turns/i.test(message)) {
|
|
1391
|
+
return { kind: "budget", retryable: false, retryAfterSeconds: null };
|
|
1392
|
+
}
|
|
1393
|
+
if (/\b(401|403|authentication|unauthorized|forbidden|login)\b/i.test(message)) {
|
|
1394
|
+
return { kind: "authentication", retryable: false, retryAfterSeconds: null };
|
|
1395
|
+
}
|
|
1396
|
+
if (/\b429\b|rate.?limit/i.test(message)) {
|
|
1397
|
+
const retryAfter = /retry-after(?:seconds)?["':=\s]+(\d+)/i.exec(message)?.[1];
|
|
1398
|
+
return {
|
|
1399
|
+
kind: "rate-limit",
|
|
1400
|
+
retryable: true,
|
|
1401
|
+
retryAfterSeconds: retryAfter ? Number(retryAfter) : 60,
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
if (/\b5\d\d\b|server error|service unavailable/i.test(message)) {
|
|
1405
|
+
return { kind: "server", retryable: true, retryAfterSeconds: null };
|
|
1406
|
+
}
|
|
1407
|
+
if (/timeout|timed out|ECONNRESET|ECONNREFUSED|EAI_AGAIN|temporary failure/i.test(message)) {
|
|
1408
|
+
return { kind: "transient", retryable: true, retryAfterSeconds: null };
|
|
1409
|
+
}
|
|
1410
|
+
return { kind: "deterministic", retryable: false, retryAfterSeconds: null };
|
|
1411
|
+
}
|
|
1412
|
+
function retryNotBefore(retryAfterSeconds) {
|
|
1413
|
+
if (retryAfterSeconds === null)
|
|
1414
|
+
return null;
|
|
1415
|
+
return new Date(Date.now() + Math.max(1, retryAfterSeconds) * 1000).toISOString();
|
|
1416
|
+
}
|
|
1417
|
+
function combineExecutionResults(primary, repair) {
|
|
1418
|
+
return {
|
|
1419
|
+
exitCode: repair.exitCode,
|
|
1420
|
+
stdout: `${primary.stdout}\n${repair.stdout}`,
|
|
1421
|
+
stderr: `${primary.stderr}\n${repair.stderr}`.trim(),
|
|
1422
|
+
tokens: primary.tokens + repair.tokens,
|
|
1423
|
+
inputTokens: primary.inputTokens + repair.inputTokens,
|
|
1424
|
+
cachedInputTokens: primary.cachedInputTokens + repair.cachedInputTokens,
|
|
1425
|
+
outputTokens: primary.outputTokens + repair.outputTokens,
|
|
1426
|
+
costUsd: roundMoney(primary.costUsd + repair.costUsd),
|
|
1427
|
+
wallSeconds: primary.wallSeconds + repair.wallSeconds,
|
|
1428
|
+
model: repair.model ?? primary.model,
|
|
1429
|
+
runtime: repair.runtime ?? primary.runtime,
|
|
1430
|
+
telemetry: mergeTelemetry(primary.telemetry, repair.telemetry),
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
function mergeTelemetry(primary, repair) {
|
|
1434
|
+
if (!primary)
|
|
1435
|
+
return repair;
|
|
1436
|
+
if (!repair)
|
|
1437
|
+
return primary;
|
|
1438
|
+
return {
|
|
1439
|
+
eventCounts: mergeCounts(primary.eventCounts, repair.eventCounts),
|
|
1440
|
+
itemCounts: mergeCounts(primary.itemCounts, repair.itemCounts),
|
|
1441
|
+
toolCalls: primary.toolCalls + repair.toolCalls,
|
|
1442
|
+
providerTurns: primary.providerTurns === null && repair.providerTurns === null
|
|
1443
|
+
? null
|
|
1444
|
+
: (primary.providerTurns ?? 0) + (repair.providerTurns ?? 0),
|
|
1445
|
+
reasoningOutputTokens: primary.reasoningOutputTokens + repair.reasoningOutputTokens,
|
|
1446
|
+
providerErrors: [...new Set([...primary.providerErrors, ...repair.providerErrors])].slice(0, 10),
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
function mergeCounts(left, right) {
|
|
1450
|
+
const result = { ...left };
|
|
1451
|
+
for (const [key, value] of Object.entries(right))
|
|
1452
|
+
result[key] = (result[key] ?? 0) + value;
|
|
1453
|
+
return result;
|
|
1454
|
+
}
|
|
1455
|
+
function applyUsage(project, result) {
|
|
1456
|
+
project.usage.tokens += result.tokens;
|
|
1457
|
+
project.usage.inputTokens += result.inputTokens;
|
|
1458
|
+
project.usage.cachedInputTokens += result.cachedInputTokens;
|
|
1459
|
+
project.usage.outputTokens += result.outputTokens;
|
|
1460
|
+
project.usage.costUsd = roundMoney(project.usage.costUsd + result.costUsd);
|
|
1461
|
+
project.usage.wallSeconds += result.wallSeconds;
|
|
1462
|
+
}
|
|
1463
|
+
function usageSlice(result) {
|
|
1464
|
+
return {
|
|
1465
|
+
tokens: result.tokens,
|
|
1466
|
+
inputTokens: result.inputTokens,
|
|
1467
|
+
cachedInputTokens: result.cachedInputTokens,
|
|
1468
|
+
outputTokens: result.outputTokens,
|
|
1469
|
+
costUsd: result.costUsd,
|
|
1470
|
+
wallSeconds: result.wallSeconds,
|
|
1471
|
+
telemetry: result.telemetry ?? null,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
function zeroUsageSlice() {
|
|
1475
|
+
return {
|
|
1476
|
+
tokens: 0,
|
|
1477
|
+
inputTokens: 0,
|
|
1478
|
+
cachedInputTokens: 0,
|
|
1479
|
+
outputTokens: 0,
|
|
1480
|
+
costUsd: 0,
|
|
1481
|
+
wallSeconds: 0,
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
function zeroExecutionResult() {
|
|
1485
|
+
return {
|
|
1486
|
+
exitCode: 0,
|
|
1487
|
+
stdout: "",
|
|
1488
|
+
stderr: "",
|
|
1489
|
+
tokens: 0,
|
|
1490
|
+
inputTokens: 0,
|
|
1491
|
+
cachedInputTokens: 0,
|
|
1492
|
+
outputTokens: 0,
|
|
1493
|
+
costUsd: 0,
|
|
1494
|
+
wallSeconds: 0,
|
|
1495
|
+
model: null,
|
|
1496
|
+
runtime: null,
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
async function writeRunRecord(root, record) {
|
|
1500
|
+
await writeJsonAtomic(join(projectRoot(root, record.projectId), "runs", `${record.runId}.json`), sanitizeResearchRecord(record));
|
|
1501
|
+
}
|
|
1502
|
+
async function withHeartbeat(operation, options, requestId, project, workPackage, config) {
|
|
1503
|
+
const timer = setInterval(() => {
|
|
1504
|
+
emitProgress(options, progressEvent("package.heartbeat", requestId, project.id, workPackage.id, remainingBudget(project, config), { attempt: workPackage.attempts }));
|
|
1505
|
+
}, 30_000);
|
|
1506
|
+
timer.unref();
|
|
1507
|
+
try {
|
|
1508
|
+
return await operation;
|
|
1509
|
+
}
|
|
1510
|
+
finally {
|
|
1511
|
+
clearInterval(timer);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
function emitProgress(options, event) {
|
|
1515
|
+
try {
|
|
1516
|
+
options.onProgress?.(sanitizeResearchRecord(event, configuredResearchSecrets(options.environment)));
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
// Progress reporting must not alter research execution.
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
function progressEvent(type, requestId, projectId, packageId, remaining, detail) {
|
|
1523
|
+
return {
|
|
1524
|
+
schemaVersion: 1,
|
|
1525
|
+
type,
|
|
1526
|
+
timestamp: new Date().toISOString(),
|
|
1527
|
+
requestId,
|
|
1528
|
+
projectId,
|
|
1529
|
+
packageId,
|
|
1530
|
+
remainingBudget: remaining,
|
|
1531
|
+
...(detail ? { detail } : {}),
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
async function dryRunResult(root, requestId, projectId) {
|
|
1535
|
+
const projects = await projectsForRun(root, projectId);
|
|
425
1536
|
return {
|
|
426
1537
|
workspace: root,
|
|
1538
|
+
requestId,
|
|
1539
|
+
projectId: projectId ?? null,
|
|
427
1540
|
status: "dry-run",
|
|
428
1541
|
stopReason: "dry-run",
|
|
429
1542
|
cycles: 0,
|
|
@@ -436,60 +1549,62 @@ async function dryRunResult(root) {
|
|
|
436
1549
|
})),
|
|
437
1550
|
};
|
|
438
1551
|
}
|
|
439
|
-
async function summarizeRun(root, cycles, executed, maxCycles) {
|
|
440
|
-
const projects = await
|
|
1552
|
+
async function summarizeRun(root, requestId, cycles, executed, maxCycles, projectId) {
|
|
1553
|
+
const projects = await projectsForRun(root, projectId);
|
|
441
1554
|
const summaries = projects.map((project) => ({
|
|
442
1555
|
id: project.id,
|
|
443
1556
|
status: refreshProject(project).status,
|
|
444
1557
|
readyPackage: nextReadyPackage(project)?.id ?? null,
|
|
445
1558
|
usage: project.usage,
|
|
446
1559
|
}));
|
|
1560
|
+
const unfinished = summaries.filter((project) => project.status !== "complete");
|
|
1561
|
+
const hasReadyPackage = summaries.some((project) => project.readyPackage !== null);
|
|
447
1562
|
const status = summaries.length > 0 && summaries.every((project) => project.status === "complete")
|
|
448
1563
|
? "complete"
|
|
449
|
-
:
|
|
1564
|
+
: unfinished.length > 0 && unfinished.every((project) => project.status === "blocked")
|
|
450
1565
|
? "blocked"
|
|
451
1566
|
: "ready";
|
|
452
1567
|
const stopReason = summaries.length === 0
|
|
453
1568
|
? "no-projects"
|
|
454
1569
|
: status === "complete"
|
|
455
1570
|
? "all-projects-complete"
|
|
456
|
-
:
|
|
457
|
-
? "
|
|
458
|
-
:
|
|
459
|
-
? "
|
|
1571
|
+
: hasReadyPackage && cycles >= maxCycles
|
|
1572
|
+
? "cycle-limit"
|
|
1573
|
+
: status === "blocked"
|
|
1574
|
+
? "project-blocked"
|
|
460
1575
|
: "no-ready-work";
|
|
461
|
-
return {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
exitCode: 3,
|
|
471
|
-
details: { usage: project.usage, budget: config.budget },
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
function assertProjectedBudget(project, config, result) {
|
|
476
|
-
const projected = {
|
|
477
|
-
tokens: project.usage.tokens + result.tokens,
|
|
478
|
-
costUsd: project.usage.costUsd + result.costUsd,
|
|
479
|
-
wallSeconds: project.usage.wallSeconds + result.wallSeconds,
|
|
1576
|
+
return {
|
|
1577
|
+
workspace: root,
|
|
1578
|
+
requestId,
|
|
1579
|
+
projectId: projectId ?? null,
|
|
1580
|
+
status,
|
|
1581
|
+
stopReason,
|
|
1582
|
+
cycles,
|
|
1583
|
+
executed,
|
|
1584
|
+
projects: summaries,
|
|
480
1585
|
};
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1586
|
+
}
|
|
1587
|
+
async function projectsForRun(root, projectId) {
|
|
1588
|
+
return projectId ? [await loadProject(root, projectId)] : listProjects(root);
|
|
1589
|
+
}
|
|
1590
|
+
function assertPublicEvidenceUrl(value, sourceId) {
|
|
1591
|
+
let url;
|
|
1592
|
+
try {
|
|
1593
|
+
url = new URL(value);
|
|
1594
|
+
}
|
|
1595
|
+
catch {
|
|
1596
|
+
throw deterministicError(`Evidence source ${sourceId} contains an invalid URL.`);
|
|
1597
|
+
}
|
|
1598
|
+
if (url.username || url.password) {
|
|
1599
|
+
throw deterministicError(`Evidence source ${sourceId} URL contains credentials.`);
|
|
1600
|
+
}
|
|
1601
|
+
const sensitive = /^(access_token|api[_-]?key|apikey|auth|authorization|code|cookie|key|password|secret|session|sig|signature|token)$/i;
|
|
1602
|
+
if ([...url.searchParams.keys()].some((key) => sensitive.test(key))) {
|
|
1603
|
+
throw deterministicError(`Evidence source ${sourceId} URL contains sensitive parameters.`);
|
|
489
1604
|
}
|
|
490
1605
|
}
|
|
491
|
-
function
|
|
492
|
-
return
|
|
1606
|
+
function deterministicError(message) {
|
|
1607
|
+
return new CliError(message, { code: "RESEARCH_OUTPUT_INVALID", exitCode: 3 });
|
|
493
1608
|
}
|
|
494
1609
|
function projectRoot(root, projectId) {
|
|
495
1610
|
return join(workspacePaths(root).projects, projectId);
|
|
@@ -510,10 +1625,13 @@ function validateRunOptions(options) {
|
|
|
510
1625
|
});
|
|
511
1626
|
}
|
|
512
1627
|
}
|
|
1628
|
+
function bounded(value, length) {
|
|
1629
|
+
return value.length <= length ? value : `${value.slice(0, length)}…`;
|
|
1630
|
+
}
|
|
513
1631
|
function roundMoney(value) {
|
|
514
1632
|
return Math.round(value * 1_000_000) / 1_000_000;
|
|
515
1633
|
}
|
|
516
|
-
function
|
|
517
|
-
return value
|
|
1634
|
+
function numericOrNull(value) {
|
|
1635
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
518
1636
|
}
|
|
519
1637
|
//# sourceMappingURL=runtime.js.map
|