@tiangong-ai/cli 0.0.19 → 0.0.21
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 +8 -2
- package/README.md +209 -4
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/research/commands.js +6 -0
- package/dist/research/commands.js.map +1 -1
- package/dist/research/orchestration.d.ts +3 -0
- package/dist/research/orchestration.js +391 -0
- package/dist/research/orchestration.js.map +1 -0
- package/dist/research/workspace/broker.d.ts +5 -0
- package/dist/research/workspace/broker.js +729 -0
- package/dist/research/workspace/broker.js.map +1 -0
- package/dist/research/workspace/capabilities.d.ts +10 -0
- package/dist/research/workspace/capabilities.js +356 -0
- package/dist/research/workspace/capabilities.js.map +1 -0
- package/dist/research/workspace/constants.d.ts +8 -0
- package/dist/research/workspace/constants.js +41 -0
- package/dist/research/workspace/constants.js.map +1 -0
- package/dist/research/workspace/context.d.ts +3 -0
- package/dist/research/workspace/context.js +77 -0
- package/dist/research/workspace/context.js.map +1 -0
- 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 +22 -0
- package/dist/research/workspace/executor.js +926 -0
- package/dist/research/workspace/executor.js.map +1 -0
- 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.d.ts +7 -0
- package/dist/research/workspace/journal.js +105 -0
- package/dist/research/workspace/journal.js.map +1 -0
- 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 +12 -0
- package/dist/research/workspace/projects.js +514 -0
- package/dist/research/workspace/projects.js.map +1 -0
- package/dist/research/workspace/runtime.d.ts +31 -0
- package/dist/research/workspace/runtime.js +1637 -0
- package/dist/research/workspace/runtime.js.map +1 -0
- 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.d.ts +25 -0
- package/dist/research/workspace/storage.js +222 -0
- package/dist/research/workspace/storage.js.map +1 -0
- package/dist/research/workspace/types.d.ts +341 -0
- package/dist/research/workspace/types.js +2 -0
- package/dist/research/workspace/types.js.map +1 -0
- package/dist/research/workspace/workspace.d.ts +23 -0
- package/dist/research/workspace/workspace.js +702 -0
- package/dist/research/workspace/workspace.js.map +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,1637 @@
|
|
|
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
|
+
import { CliError } from "../../errors.js";
|
|
5
|
+
import { stageLockedCapabilities, verifyCapabilities } from "./capabilities.js";
|
|
6
|
+
import { startCapabilityBroker } from "./broker.js";
|
|
7
|
+
import { loadProjectEvidenceReceipts, stageProjectEvidence } from "./evidence.js";
|
|
8
|
+
import { executeAgent } from "./executor.js";
|
|
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";
|
|
12
|
+
import { listProjects, loadProject, nextReadyPackage, packageById, refreshProject, saveProject, } from "./projects.js";
|
|
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";
|
|
17
|
+
export async function runResearchWorkspace(root, options, packageExecutor = executeAgent) {
|
|
18
|
+
validateRunOptions(options);
|
|
19
|
+
const requestId = randomUUID();
|
|
20
|
+
if (options.dryRun)
|
|
21
|
+
return dryRunResult(root, requestId, options.projectId);
|
|
22
|
+
return withWorkspaceLock(root, "research.run", async () => {
|
|
23
|
+
await verifyJournal(workspacePaths(root).journal);
|
|
24
|
+
const config = await loadWorkspaceConfig(root);
|
|
25
|
+
assertExecutionConfiguration(config);
|
|
26
|
+
let doctorAttestation = null;
|
|
27
|
+
const capabilities = await verifyCapabilities(root);
|
|
28
|
+
if (capabilities.status !== "verified") {
|
|
29
|
+
throw new CliError("Research capabilities are not locked and verified.", {
|
|
30
|
+
code: "RESEARCH_CAPABILITY_DRIFT",
|
|
31
|
+
exitCode: 3,
|
|
32
|
+
details: capabilities,
|
|
33
|
+
});
|
|
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));
|
|
56
|
+
const executed = [];
|
|
57
|
+
let cycles = 0;
|
|
58
|
+
while (cycles < options.maxCycles) {
|
|
59
|
+
const projects = await projectsForRun(root, options.projectId);
|
|
60
|
+
const selected = projects
|
|
61
|
+
.map((project) => ({ project, workPackage: nextReadyPackage(project) }))
|
|
62
|
+
.filter((item) => Boolean(item.workPackage) &&
|
|
63
|
+
item.project.status !== "blocked" &&
|
|
64
|
+
item.project.status !== "complete")
|
|
65
|
+
.slice(0, options.maxParallel);
|
|
66
|
+
if (!selected.length)
|
|
67
|
+
break;
|
|
68
|
+
cycles += 1;
|
|
69
|
+
const results = await Promise.all(selected.map(({ project, workPackage }) => executeWorkPackage(root, project.id, workPackage.id, config, options, requestId, packageExecutor, doctorAttestation)));
|
|
70
|
+
executed.push(...results);
|
|
71
|
+
}
|
|
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;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function executeWorkPackage(root, projectId, packageId, config, options, requestId, packageExecutor, doctorAttestation) {
|
|
81
|
+
const project = await loadProject(root, projectId);
|
|
82
|
+
const workPackage = packageById(project, packageId);
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
workPackage.status = "running";
|
|
85
|
+
workPackage.attempts += 1;
|
|
86
|
+
workPackage.startedAt = now;
|
|
87
|
+
workPackage.completedAt = null;
|
|
88
|
+
workPackage.lastError = null;
|
|
89
|
+
workPackage.lastFailureKind = null;
|
|
90
|
+
workPackage.retryNotBefore = null;
|
|
91
|
+
project.status = "running";
|
|
92
|
+
project.updatedAt = now;
|
|
93
|
+
await saveProject(root, project);
|
|
94
|
+
await appendJournalEvent(workspacePaths(root).journal, "package.started", projectId, {
|
|
95
|
+
requestId,
|
|
96
|
+
projectId,
|
|
97
|
+
packageId,
|
|
98
|
+
attempt: workPackage.attempts,
|
|
99
|
+
});
|
|
100
|
+
emitProgress(options, progressEvent("package.started", requestId, projectId, packageId, remainingBudget(project, config), { attempt: workPackage.attempts }));
|
|
101
|
+
const runId = randomUUID();
|
|
102
|
+
const startedAt = new Date().toISOString();
|
|
103
|
+
let capsuleRoot;
|
|
104
|
+
let broker;
|
|
105
|
+
let accountedResult;
|
|
106
|
+
let promotedOutputs = [];
|
|
107
|
+
let executor = "mechanical";
|
|
108
|
+
try {
|
|
109
|
+
let result;
|
|
110
|
+
if (workPackage.kind === "verify") {
|
|
111
|
+
result = await closeProjectMechanically(root, project, workPackage);
|
|
112
|
+
accountedResult = result;
|
|
113
|
+
promotedOutputs = await outputRecords(root, project, workPackage.expectedOutputs);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const reservation = reservePackageBudget(project, workPackage, config);
|
|
117
|
+
const capsule = await createCapsule(root, project, workPackage, runId);
|
|
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);
|
|
129
|
+
const route = workPackage.executor === "reviewer" ? config.reviewer : config.producer;
|
|
130
|
+
executor = route.agent;
|
|
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,
|
|
141
|
+
route,
|
|
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),
|
|
153
|
+
});
|
|
154
|
+
assertPreCallTokenReservation(project, workPackage, config, primaryRequest, 0, true);
|
|
155
|
+
result = await withHeartbeat(packageExecutor(primaryRequest), options, requestId, project, workPackage, config);
|
|
156
|
+
accountedResult = result;
|
|
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);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const completedAt = new Date().toISOString();
|
|
201
|
+
applyUsage(project, accountedResult);
|
|
202
|
+
workPackage.status = "complete";
|
|
203
|
+
workPackage.completedAt = completedAt;
|
|
204
|
+
workPackage.lastError = null;
|
|
205
|
+
workPackage.lastFailureKind = null;
|
|
206
|
+
workPackage.retryNotBefore = null;
|
|
207
|
+
refreshProject(project);
|
|
208
|
+
await saveProject(root, project);
|
|
209
|
+
await writeRunRecord(root, {
|
|
210
|
+
schemaVersion: 1,
|
|
211
|
+
runId,
|
|
212
|
+
projectId,
|
|
213
|
+
packageId,
|
|
214
|
+
executor,
|
|
215
|
+
startedAt,
|
|
216
|
+
completedAt,
|
|
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);
|
|
233
|
+
await appendJournalEvent(workspacePaths(root).journal, "package.completed", projectId, {
|
|
234
|
+
requestId,
|
|
235
|
+
projectId,
|
|
236
|
+
packageId,
|
|
237
|
+
runId,
|
|
238
|
+
executor,
|
|
239
|
+
outputs: promotedOutputs,
|
|
240
|
+
usage,
|
|
241
|
+
runtime: accountedResult.runtime,
|
|
242
|
+
});
|
|
243
|
+
emitProgress(options, progressEvent("package.completed", requestId, projectId, packageId, remainingBudget(project, config), { outputs: promotedOutputs, usage }));
|
|
244
|
+
return { projectId, packageId, status: "complete" };
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
const failedProject = await loadProject(root, projectId);
|
|
248
|
+
const failedPackage = packageById(failedProject, packageId);
|
|
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);
|
|
258
|
+
failedPackage.lastError = message;
|
|
259
|
+
failedPackage.lastFailureKind = classification.kind;
|
|
260
|
+
failedPackage.completedAt = new Date().toISOString();
|
|
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;
|
|
266
|
+
refreshProject(failedProject);
|
|
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();
|
|
294
|
+
await appendJournalEvent(workspacePaths(root).journal, "package.failed", projectId, {
|
|
295
|
+
requestId,
|
|
296
|
+
projectId,
|
|
297
|
+
packageId,
|
|
298
|
+
runId,
|
|
299
|
+
attempt: failedPackage.attempts,
|
|
300
|
+
retryable,
|
|
301
|
+
retryNotBefore: failedPackage.retryNotBefore,
|
|
302
|
+
failureKind: classification.kind,
|
|
303
|
+
error: message,
|
|
304
|
+
details: failureDetails,
|
|
305
|
+
outputs: promotedOutputs,
|
|
306
|
+
usage,
|
|
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
|
+
}));
|
|
316
|
+
return { projectId, packageId, status: failedPackage.status };
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
if (broker)
|
|
320
|
+
await broker.stop();
|
|
321
|
+
if (capsuleRoot)
|
|
322
|
+
await rm(capsuleRoot, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function createCapsule(root, project, workPackage, runId) {
|
|
326
|
+
const paths = workspacePaths(root);
|
|
327
|
+
const capsuleRoot = join(paths.runtime, runId);
|
|
328
|
+
const capsuleProject = join(capsuleRoot, "project");
|
|
329
|
+
await ensureDirectory(capsuleProject);
|
|
330
|
+
await ensureDirectory(join(capsuleProject, "outputs"));
|
|
331
|
+
const canonicalOutputs = join(projectRoot(root, project.id), "outputs");
|
|
332
|
+
if (await pathExists(canonicalOutputs)) {
|
|
333
|
+
for (const source of await regularTreeFiles(canonicalOutputs)) {
|
|
334
|
+
const logical = relative(canonicalOutputs, source);
|
|
335
|
+
const destination = join(capsuleProject, "outputs", logical);
|
|
336
|
+
await ensureDirectory(dirname(destination));
|
|
337
|
+
await cp(source, destination, { force: false });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const inputManifest = [];
|
|
341
|
+
for (const input of project.inputs) {
|
|
342
|
+
if ((await sha256File(input.path)) !== input.sha256) {
|
|
343
|
+
throw new CliError(`Input drift detected: ${input.id}.`, {
|
|
344
|
+
code: "RESEARCH_INPUT_DRIFT",
|
|
345
|
+
exitCode: 3,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const logical = join("inputs", input.id, basename(input.path)).replaceAll("\\", "/");
|
|
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
|
+
});
|
|
397
|
+
}
|
|
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));
|
|
405
|
+
await writeJsonAtomic(join(capsuleProject, "project.json"), {
|
|
406
|
+
...project,
|
|
407
|
+
inputs: project.inputs.map((input, index) => ({
|
|
408
|
+
...input,
|
|
409
|
+
path: inputManifest[index]?.path ?? "inputs/unavailable",
|
|
410
|
+
contextPath: inputManifest[index]?.contextPath ?? "inputs/unavailable",
|
|
411
|
+
})),
|
|
412
|
+
});
|
|
413
|
+
const stagedSkills = await stageLockedCapabilities(root, join(capsuleProject, "skills"));
|
|
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)
|
|
419
|
+
: null;
|
|
420
|
+
return {
|
|
421
|
+
capsuleRoot,
|
|
422
|
+
projectRoot: capsuleProject,
|
|
423
|
+
inputManifest,
|
|
424
|
+
contextBundle,
|
|
425
|
+
contextBundleContent,
|
|
426
|
+
stagedSkills,
|
|
427
|
+
reviewPacketSha256: reviewPacket?.sha256 ?? null,
|
|
428
|
+
reviewPacketRecord: reviewPacket?.record ?? null,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
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) {
|
|
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);
|
|
469
|
+
const packet = {
|
|
470
|
+
schemaVersion: 1,
|
|
471
|
+
projectId: project.id,
|
|
472
|
+
questionSha256: sha256Text(project.question),
|
|
473
|
+
evidenceRequirements: project.evidenceRequirements,
|
|
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"),
|
|
481
|
+
artifacts: await Promise.all(artifactPaths.map((logicalPath) => fileRecord(resolveContained(capsuleProject, logicalPath), logicalPath))),
|
|
482
|
+
};
|
|
483
|
+
const packetSha256 = sha256Text(canonicalJson(packet));
|
|
484
|
+
const completePacket = {
|
|
485
|
+
...packet,
|
|
486
|
+
packetSha256,
|
|
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);
|
|
740
|
+
}
|
|
741
|
+
async function validateAndImportOutputs(root, project, workPackage, capsuleProject, config, reviewPacketSha256) {
|
|
742
|
+
const admitted = [];
|
|
743
|
+
let totalBytes = 0;
|
|
744
|
+
if (workPackage.expectedOutputs.length > config.budget.maxFilesPerPackage) {
|
|
745
|
+
throw deterministicError("Declared output count exceeds the package file budget.");
|
|
746
|
+
}
|
|
747
|
+
for (const logicalPath of workPackage.expectedOutputs) {
|
|
748
|
+
const source = resolveContained(capsuleProject, logicalPath);
|
|
749
|
+
const record = await fileRecord(source, logicalPath);
|
|
750
|
+
totalBytes += record.bytes;
|
|
751
|
+
if (totalBytes > config.budget.maxBytesPerPackage) {
|
|
752
|
+
throw deterministicError("Package outputs exceed the byte budget.");
|
|
753
|
+
}
|
|
754
|
+
await validateOutputShape(root, project, workPackage, source, reviewPacketSha256);
|
|
755
|
+
admitted.push({ logicalPath, content: await readFile(source, "utf8"), record });
|
|
756
|
+
}
|
|
757
|
+
for (const output of admitted) {
|
|
758
|
+
const destination = resolveContained(projectRoot(root, project.id), output.logicalPath);
|
|
759
|
+
await ensureDirectory(dirname(destination));
|
|
760
|
+
await writeTextAtomic(destination, output.content);
|
|
761
|
+
}
|
|
762
|
+
return admitted.map((output) => output.record);
|
|
763
|
+
}
|
|
764
|
+
async function validateOutputShape(root, project, workPackage, path, reviewPacketSha256) {
|
|
765
|
+
const content = await readFile(path, "utf8");
|
|
766
|
+
if (!content.trim())
|
|
767
|
+
throw deterministicError(`${workPackage.expectedOutputs[0]} is empty.`);
|
|
768
|
+
if (workPackage.stage === "synthesize")
|
|
769
|
+
return;
|
|
770
|
+
const { value } = parseStructuredStageOutput(workPackage.stage, content, reviewPacketSha256);
|
|
771
|
+
if (workPackage.stage === "discover") {
|
|
772
|
+
await validateEvidenceSources(root, project, value.sources);
|
|
773
|
+
}
|
|
774
|
+
if (workPackage.stage === "analyze") {
|
|
775
|
+
await validateFindings(path, value.findings);
|
|
776
|
+
}
|
|
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
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async function validateEvidenceSources(root, project, sources) {
|
|
788
|
+
const inputLocators = new Map(project.inputs.map((input) => [
|
|
789
|
+
input.id,
|
|
790
|
+
join("inputs", input.id, basename(input.path)).replaceAll("\\", "/"),
|
|
791
|
+
]));
|
|
792
|
+
const receipts = await loadProjectEvidenceReceipts(root, project.id);
|
|
793
|
+
const brokerLocators = new Map(receipts.map((receipt) => [receipt.attemptId, receipt.locator]));
|
|
794
|
+
const sourceIds = new Set();
|
|
795
|
+
for (const source of sources) {
|
|
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
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const expectedLocator = source.provenance.kind === "input"
|
|
806
|
+
? inputLocators.get(String(source.provenance.id))
|
|
807
|
+
: source.provenance.kind === "broker"
|
|
808
|
+
? brokerLocators.get(String(source.provenance.id))
|
|
809
|
+
: undefined;
|
|
810
|
+
if (!expectedLocator || expectedLocator !== source.locator) {
|
|
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)}.`);
|
|
838
|
+
}
|
|
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);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function validateFindings(path, findings) {
|
|
847
|
+
const evidencePath = join(dirname(path), "evidence.json");
|
|
848
|
+
const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
|
|
849
|
+
if (!isObject(evidence) || !Array.isArray(evidence.sources)) {
|
|
850
|
+
throw deterministicError("Analysis requires admitted evidence.json.");
|
|
851
|
+
}
|
|
852
|
+
const sourceIds = new Set(evidence.sources
|
|
853
|
+
.filter((source) => isObject(source))
|
|
854
|
+
.map((source) => source.id)
|
|
855
|
+
.filter((id) => typeof id === "string"));
|
|
856
|
+
const findingIds = new Set();
|
|
857
|
+
for (const finding of findings) {
|
|
858
|
+
if (!isObject(finding) ||
|
|
859
|
+
typeof finding.id !== "string" ||
|
|
860
|
+
findingIds.has(finding.id) ||
|
|
861
|
+
!Array.isArray(finding.evidence) ||
|
|
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
|
+
});
|
|
869
|
+
}
|
|
870
|
+
findingIds.add(finding.id);
|
|
871
|
+
}
|
|
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
|
+
}
|
|
1017
|
+
async function closeProjectMechanically(root, project, workPackage) {
|
|
1018
|
+
await assertEvidenceCoverage(root, project);
|
|
1019
|
+
const required = [
|
|
1020
|
+
"outputs/evidence.json",
|
|
1021
|
+
"outputs/analysis.json",
|
|
1022
|
+
"outputs/report.md",
|
|
1023
|
+
"outputs/review.json",
|
|
1024
|
+
];
|
|
1025
|
+
const artifacts = await outputRecords(root, project, required);
|
|
1026
|
+
const review = JSON.parse(await readFile(resolveContained(projectRoot(root, project.id), "outputs/review.json"), "utf8"));
|
|
1027
|
+
if (!isObject(review) || review.decision !== "pass") {
|
|
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.");
|
|
1033
|
+
}
|
|
1034
|
+
const reviewPacket = await loadVerifiedReviewPacket(root, project.id, review.packetSha256);
|
|
1035
|
+
const evidenceReceipts = await loadProjectEvidenceReceipts(root, project.id);
|
|
1036
|
+
const journal = await verifyJournal(workspacePaths(root).journal);
|
|
1037
|
+
const closure = {
|
|
1038
|
+
schemaVersion: 1,
|
|
1039
|
+
projectId: project.id,
|
|
1040
|
+
status: "complete",
|
|
1041
|
+
closedAt: new Date().toISOString(),
|
|
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
|
+
})),
|
|
1060
|
+
artifacts,
|
|
1061
|
+
journalHead: journal.head,
|
|
1062
|
+
};
|
|
1063
|
+
const closurePath = resolveContained(projectRoot(root, project.id), workPackage.expectedOutputs[0]);
|
|
1064
|
+
await writeJsonAtomic(closurePath, closure);
|
|
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
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
async function outputRecords(root, project, logicalPaths) {
|
|
1104
|
+
return Promise.all(logicalPaths.map((logicalPath) => fileRecord(resolveContained(projectRoot(root, project.id), logicalPath), logicalPath)));
|
|
1105
|
+
}
|
|
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) {
|
|
1141
|
+
const stageInstructions = {
|
|
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.`,
|
|
1146
|
+
close: "No agent action is allowed for mechanical closure.",
|
|
1147
|
+
};
|
|
1148
|
+
const prompt = [
|
|
1149
|
+
"Operate only inside this isolated research capsule.",
|
|
1150
|
+
`Project: ${project.id}`,
|
|
1151
|
+
`Question: ${project.question}`,
|
|
1152
|
+
`Stage: ${workPackage.stage}`,
|
|
1153
|
+
`Evidence requirements: ${JSON.stringify(project.evidenceRequirements)}`,
|
|
1154
|
+
`Declared inputs: ${JSON.stringify(inputs)}`,
|
|
1155
|
+
`Bounded input context bundle: ${JSON.stringify(contextBundle)}`,
|
|
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.",
|
|
1160
|
+
stageInstructions[workPackage.stage],
|
|
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)}`,
|
|
1180
|
+
].join("\n\n");
|
|
1181
|
+
}
|
|
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);
|
|
1536
|
+
return {
|
|
1537
|
+
workspace: root,
|
|
1538
|
+
requestId,
|
|
1539
|
+
projectId: projectId ?? null,
|
|
1540
|
+
status: "dry-run",
|
|
1541
|
+
stopReason: "dry-run",
|
|
1542
|
+
cycles: 0,
|
|
1543
|
+
executed: [],
|
|
1544
|
+
projects: projects.map((project) => ({
|
|
1545
|
+
id: project.id,
|
|
1546
|
+
status: refreshProject(project).status,
|
|
1547
|
+
readyPackage: nextReadyPackage(project)?.id ?? null,
|
|
1548
|
+
usage: project.usage,
|
|
1549
|
+
})),
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
async function summarizeRun(root, requestId, cycles, executed, maxCycles, projectId) {
|
|
1553
|
+
const projects = await projectsForRun(root, projectId);
|
|
1554
|
+
const summaries = projects.map((project) => ({
|
|
1555
|
+
id: project.id,
|
|
1556
|
+
status: refreshProject(project).status,
|
|
1557
|
+
readyPackage: nextReadyPackage(project)?.id ?? null,
|
|
1558
|
+
usage: project.usage,
|
|
1559
|
+
}));
|
|
1560
|
+
const unfinished = summaries.filter((project) => project.status !== "complete");
|
|
1561
|
+
const hasReadyPackage = summaries.some((project) => project.readyPackage !== null);
|
|
1562
|
+
const status = summaries.length > 0 && summaries.every((project) => project.status === "complete")
|
|
1563
|
+
? "complete"
|
|
1564
|
+
: unfinished.length > 0 && unfinished.every((project) => project.status === "blocked")
|
|
1565
|
+
? "blocked"
|
|
1566
|
+
: "ready";
|
|
1567
|
+
const stopReason = summaries.length === 0
|
|
1568
|
+
? "no-projects"
|
|
1569
|
+
: status === "complete"
|
|
1570
|
+
? "all-projects-complete"
|
|
1571
|
+
: hasReadyPackage && cycles >= maxCycles
|
|
1572
|
+
? "cycle-limit"
|
|
1573
|
+
: status === "blocked"
|
|
1574
|
+
? "project-blocked"
|
|
1575
|
+
: "no-ready-work";
|
|
1576
|
+
return {
|
|
1577
|
+
workspace: root,
|
|
1578
|
+
requestId,
|
|
1579
|
+
projectId: projectId ?? null,
|
|
1580
|
+
status,
|
|
1581
|
+
stopReason,
|
|
1582
|
+
cycles,
|
|
1583
|
+
executed,
|
|
1584
|
+
projects: summaries,
|
|
1585
|
+
};
|
|
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.`);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function deterministicError(message) {
|
|
1607
|
+
return new CliError(message, { code: "RESEARCH_OUTPUT_INVALID", exitCode: 3 });
|
|
1608
|
+
}
|
|
1609
|
+
function projectRoot(root, projectId) {
|
|
1610
|
+
return join(workspacePaths(root).projects, projectId);
|
|
1611
|
+
}
|
|
1612
|
+
function validateRunOptions(options) {
|
|
1613
|
+
if (!Number.isInteger(options.maxParallel) ||
|
|
1614
|
+
options.maxParallel < 1 ||
|
|
1615
|
+
options.maxParallel > 8) {
|
|
1616
|
+
throw new CliError("--max-parallel must be an integer from 1 to 8.", {
|
|
1617
|
+
code: "RESEARCH_RUN_OPTION_INVALID",
|
|
1618
|
+
exitCode: 2,
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
if (!Number.isInteger(options.maxCycles) || options.maxCycles < 1 || options.maxCycles > 100) {
|
|
1622
|
+
throw new CliError("--max-cycles must be an integer from 1 to 100.", {
|
|
1623
|
+
code: "RESEARCH_RUN_OPTION_INVALID",
|
|
1624
|
+
exitCode: 2,
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
function bounded(value, length) {
|
|
1629
|
+
return value.length <= length ? value : `${value.slice(0, length)}…`;
|
|
1630
|
+
}
|
|
1631
|
+
function roundMoney(value) {
|
|
1632
|
+
return Math.round(value * 1_000_000) / 1_000_000;
|
|
1633
|
+
}
|
|
1634
|
+
function numericOrNull(value) {
|
|
1635
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1636
|
+
}
|
|
1637
|
+
//# sourceMappingURL=runtime.js.map
|