@tea-agent/loop-agent 0.39.0-beta.7 → 0.39.0-beta.9
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 +1 -1
- package/CHANGELOG.md +23 -5
- package/README.md +2 -2
- package/dist/application/task-lifecycle/advance.js +12 -0
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +7 -0
- package/dist/cli/program.js +5 -0
- package/dist/cli/update/runtime-activity.js +1 -29
- package/dist/commands/dag-follow-up.js +138 -0
- package/dist/commands/dag-rerun.js +1 -1
- package/dist/commands/init.js +26 -0
- package/dist/executors/dag-pi-executor.js +326 -8
- package/dist/executors/pi-read-budget-policy.js +166 -0
- package/dist/executors/shell-executor.js +26 -30
- package/dist/shared/dag-prompt-override.js +4 -5
- package/dist/shared/update/npm-client.js +33 -11
- package/dist/task/config-types.js +15 -0
- package/dist/task/contract/apply.js +11 -1
- package/dist/task/contract/constants.js +2 -0
- package/dist/task/contract/hash.js +3 -0
- package/dist/task/contract/observe.js +25 -4
- package/dist/task/contract/paths.js +2 -1
- package/dist/task/contract/project.js +15 -1
- package/dist/task/contract/schema.js +2 -0
- package/dist/task/contract/transaction.js +12 -0
- package/dist/task/frontend-project-capability.js +123 -4
- package/dist/task/source-prepare/completeness.js +188 -0
- package/dist/task/source-prepare/fragment-inventory.js +477 -0
- package/dist/task/source-prepare/index.js +3 -0
- package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
- package/dist/task/source-prepare/ledger-review.js +214 -0
- package/dist/task/source-prepare/ledger.js +545 -0
- package/dist/task/source-prepare/prepare.js +262 -1
- package/dist/task/source-prepare/semantic-intake.js +25 -5
- package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
- package/dist/task/source-prepare/types.js +22 -0
- package/dist/worker/cli.js +12 -26
- package/dist/worker/console/console-handoff.js +82 -0
- package/dist/worker/console/console-update-and-init.js +115 -0
- package/dist/worker/console/console-update-runtime.js +84 -0
- package/dist/worker/console/draft-store.js +49 -0
- package/dist/worker/console/operation-store.js +1 -1
- package/dist/worker/console/operator-actions.js +129 -4
- package/dist/worker/console/operator-surface-health.js +1 -0
- package/dist/worker/console/prd-intake-bridge.js +13 -0
- package/dist/worker/console/routes.js +3 -0
- package/dist/worker/console/server.js +73 -5
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-bPer1waP.js → abnfDiagram-N423BO3Z-D1-Z-BBa.js} +1 -1
- package/dist/worker/console/static/assets/{arc-BLdkwucW.js → arc-q8qAYm5m.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Bw4wo4I1.js → architectureDiagram-T3A2C74G-CAu4_lve.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DUUkc0gr.js → blockDiagram-VBNYF7ZC-D5GhQvHm.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BC3O0nob.js → c4Diagram-5PPSVZJV-ClQjeLkO.js} +1 -1
- package/dist/worker/console/static/assets/channel-ClAuhSUL.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Dd4g2nti.js → chunk-2GRJ4B5K-DLqWlFbo.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-s2EDm7-n.js → chunk-2Q5K7J3B-DkRT_usL.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-GqtHM2yQ.js → chunk-5RXB4S5H-Cqp7Chi3.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BzRFrN8j.js → chunk-5VM5RSS4-CedEdFB9.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bw9nFjCJ.js → chunk-6Q2QTUOP-BHM8vP5x.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B4n-03Qm.js → chunk-GF5L2VYU-D7EFr377.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-Dt75ZdBD.js → chunk-JWPE2WC7-DR38mTWV.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B_afMhHB.js → chunk-KBJHAD2P-BFv2LsR3.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Bd8Rqqg9.js → chunk-RYQCIY6F-BAvpJS5z.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-9boOMji4.js → chunk-XXDRQBXY-eKI9TO72.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-cBgeCpVU.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-cBgeCpVU.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BtCycfCc.js → cose-bilkent-JH36ORCC-BMJXpgo4.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-eTOts9L1.js → cynefin-VYW2F7L2-CKJ2ofZx.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Cibd2mjg.js → cynefinDiagram-MW4NZA55-CsKnsazm.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CDjDZtDV.js → dagre-VZM6K2ZE-DVQ46vIw.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Btexn-JJ.js → diagram-7IWD3JNH-D_RuonYS.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cyl8sdpM.js → diagram-B4RE2ZJO-D2Mt0nMu.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Ev-83E1t.js → diagram-LBJQPF4R-DjBDuye6.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-41LqE0Mc.js → diagram-Q27KOJAE-8VZkqpE_.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BC39T5R3.js → diagram-UB23O5K3-D0izYxia.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DzcNRuri.js → ebnfDiagram-BXEA7PRR-CncG2I95.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-B5Frvvnz.js → erDiagram-JOGREHBK-DiBUUXR8.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BapxROIM.js → flowDiagram-UKHOOZJN-Dtc0fArb.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cd4d4wQ0.js → ganttDiagram-PKOTCBZU-BPNFP5OZ.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CrL6buFW.js → gitGraphDiagram-DS77QQ5N-CsgpxEEd.js} +1 -1
- package/dist/worker/console/static/assets/{index-DBh_x_Ek.js → index-Bai8Z25q.js} +81 -81
- package/dist/worker/console/static/assets/index-CH0Tr5hY.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BrSiNxyd.js → infoDiagram-6WML65LV-kHGxkJUS.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-gvlvY-n9.js → ishikawaDiagram-WSZJBQD7-CbLe9wrr.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-YaPzRQ.js → journeyDiagram-NVQOT4AX-BOkwxAcP.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BsmDv9E_.js → kanban-definition-27J2QSJJ-BHYQ2rDu.js} +1 -1
- package/dist/worker/console/static/assets/{linear-UWaoDN3h.js → linear-CHT4ujob.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BlvpGVG8.js → mermaid.core-yL8sfqnY.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CUCIP5CP.js → mindmap-definition-FAOFIHXS-DZx1coV-.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CB1aQUAK.js → pegDiagram-VL7TDLO6-DStWRCI8.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BwehHxhf.js → pieDiagram-7S7Q4E2Y-DSKZkwzK.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CFz30APB.js → quadrantDiagram-CIZ2JOQS-CmoEGOuW.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwNmw1i6.js → railroadDiagram-AXF67PYL-BLqzhKyq.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-jg-tPXlI.js → requirementDiagram-LRYGKXZP-gbHeUabM.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BlyReDKl.js → sankeyDiagram-W5VNT64P-CaKkBCKO.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DBUzW1f3.js → sequenceDiagram-SI44F4Z6-PUbi3Upu.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BYcI2RAr.js → sizeCapture-X5ZJPWSS-dm7S9Qfe.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-D7AiyJdo.js → stateDiagram-OKZ733FA-C7WSzv4p.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-sQDv5AP3.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BHGt7Kkv.js → swimlanes-SLNWSIFB-CBKCiQwd.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BXwsmcPs.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-OPjooWcX.js → timeline-definition-Z64GVDOM-CvnUlylk.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-ggsWDvrO.js → vennDiagram-T6HMQDX7-xM0StY1O.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DjNe-BjZ.js → wardleyDiagram-T6FBY63Y-CWtJMagy.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Byp63Hei.js → xychartDiagram-ELKLHX3M-BO_-MeK8.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +40 -1
- package/dist/worker/console/static-src/app/useOperatorActions.js +5 -0
- package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
- package/dist/worker/observability/init-runtime-activity.js +26 -0
- package/dist/worker/observe/node-input.js +88 -2
- package/dist/worker/observe/routes.js +188 -0
- package/dist/worker/observe/static/api.js +3 -3
- package/dist/worker/observe/static/format.js +37 -0
- package/dist/worker/observe/static/index.html +5 -0
- package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
- package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
- package/dist/worker/observe/static/state.js +2 -2
- package/dist/worker/observe/static/styles.css +331 -35
- package/dist/worker/observe/static/views/dag-inspector.js +509 -78
- package/dist/worker/observe/static/views/task.js +55 -0
- package/dist/workflows/dag/frontend-design-policy.js +19 -9
- package/dist/workflows/dag/frontend-implementation-contract.js +117 -50
- package/dist/workflows/dag/frontend-plan-render.js +27 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +45 -1
- package/dist/workflows/dag/frontend-recovery-controller.js +1 -0
- package/dist/workflows/dag/frontend-review-context.js +102 -15
- package/dist/workflows/dag/frontend-review-findings.js +42 -10
- package/dist/workflows/dag/frontend-shadow-dual-write.js +36 -2
- package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
- package/dist/workflows/dag/frontend-worktree-diff.js +154 -0
- package/dist/workflows/dag/frontend-writer-admission.js +12 -0
- package/dist/workflows/dag/init-hybrid.js +297 -65
- package/dist/workflows/dag/interrupt-request.js +7 -0
- package/dist/workflows/dag/node-execution.js +111 -1
- package/dist/workflows/dag/prompt.js +34 -1
- package/dist/workflows/dag/recovery-lease.js +80 -0
- package/dist/workflows/dag/rerun-feedback.js +31 -8
- package/dist/workflows/dag/rerun-run.js +23 -0
- package/dist/workflows/dag/rerun-task.js +29 -0
- package/dist/workflows/dag/retry-policy.js +34 -9
- package/dist/workflows/dag/runner.js +83 -16
- package/dist/workflows/dag/types.js +67 -11
- package/docs/README.md +1 -0
- package/docs/architecture/evolution.md +45 -0
- package/docs/init-surface.manifest.json +4 -0
- package/docs/templates/README.md +2 -1
- package/docs/templates/frontend-implementation-contract.schema.json +684 -24
- package/docs/templates/spec-registry.schema.json +45 -0
- package/harness.json +3 -3
- package/package.json +1 -1
- package/skills/frontend-bounded-implement/SKILL.md +8 -14
- package/skills/frontend-design-review/SKILL.md +20 -41
- package/skills/frontend-implementation/SKILL.md +2 -3
- package/skills/frontend-implementation/references/design-spec.md +4 -3
- package/skills/frontend-implementation/references/node-contracts.md +4 -3
- package/skills/frontend-review/SKILL.md +22 -14
- package/skills/frontend-review/references/review-findings.md +6 -7
- package/skills/loop-agent/references/hybrid-dag.md +7 -0
- package/dist/worker/console/static/assets/channel-AbFtVnFm.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +0 -1
- package/dist/worker/console/static/assets/index-DwOGauxU.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +0 -8
|
@@ -8,6 +8,7 @@ import { routeFrontendProviderCapability } from "../workflows/dag/frontend-provi
|
|
|
8
8
|
import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
|
|
9
9
|
import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
|
|
10
10
|
import { buildPiWriterToolPolicyContext, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
|
|
11
|
+
import { createPiReadBudgetCustomTools, } from "./pi-read-budget-policy.js";
|
|
11
12
|
import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
|
|
12
13
|
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
13
14
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
@@ -338,6 +339,90 @@ export function scanReviewTerminalKindsFromSessionEvents(content) {
|
|
|
338
339
|
* the plan re-read upstream outputs/sources instead of trusting typed facts,
|
|
339
340
|
* which blows up the context window (400 request-too-large). */
|
|
340
341
|
const PLAN_READ_TOOL_BUDGET = 40;
|
|
342
|
+
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "ls", "find"]);
|
|
343
|
+
function readEventPath(event) {
|
|
344
|
+
const candidates = [event.path, event.readPath, event.input];
|
|
345
|
+
for (const value of candidates) {
|
|
346
|
+
if (typeof value === "string")
|
|
347
|
+
return value;
|
|
348
|
+
if (value && typeof value === "object") {
|
|
349
|
+
const nested = value;
|
|
350
|
+
for (const key of ["path", "readPath", "file"]) {
|
|
351
|
+
if (typeof nested[key] === "string")
|
|
352
|
+
return nested[key];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
function eventTimestamp(event) {
|
|
359
|
+
for (const key of ["timestamp", "timestampMs", "ts", "createdAt"]) {
|
|
360
|
+
const value = event[key];
|
|
361
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
362
|
+
return value < 10_000_000_000 ? value * 1000 : value;
|
|
363
|
+
if (typeof value === "string") {
|
|
364
|
+
const parsed = Date.parse(value);
|
|
365
|
+
if (Number.isFinite(parsed))
|
|
366
|
+
return parsed;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
function eventBytes(event, line) {
|
|
372
|
+
const result = event.result ?? event.toolResult ?? event.output;
|
|
373
|
+
if (typeof result === "string")
|
|
374
|
+
return Buffer.byteLength(result);
|
|
375
|
+
if (result !== undefined)
|
|
376
|
+
return Buffer.byteLength(JSON.stringify(result));
|
|
377
|
+
return Buffer.byteLength(line);
|
|
378
|
+
}
|
|
379
|
+
export async function detectNodeReadBudget(input) {
|
|
380
|
+
if (!input.budget)
|
|
381
|
+
return [];
|
|
382
|
+
const sessionEventsPath = path.join(input.runDir, input.nodeId, "session-events.jsonl");
|
|
383
|
+
let content;
|
|
384
|
+
try {
|
|
385
|
+
content = await readFile(sessionEventsPath, "utf8");
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
const stats = { files: new Set(), bytes: 0, tools: 0 };
|
|
391
|
+
let firstTs;
|
|
392
|
+
let lastTs;
|
|
393
|
+
for (const line of content.split("\n")) {
|
|
394
|
+
if (!line.trim())
|
|
395
|
+
continue;
|
|
396
|
+
try {
|
|
397
|
+
const event = JSON.parse(line);
|
|
398
|
+
if (event.type !== "tool_execution_start" || typeof event.toolName !== "string" || !READ_ONLY_TOOL_NAMES.has(event.toolName))
|
|
399
|
+
continue;
|
|
400
|
+
stats.tools++;
|
|
401
|
+
const readPath = readEventPath(event);
|
|
402
|
+
if (readPath)
|
|
403
|
+
stats.files.add(readPath);
|
|
404
|
+
stats.bytes += eventBytes(event, line);
|
|
405
|
+
const ts = eventTimestamp(event);
|
|
406
|
+
if (ts !== undefined) {
|
|
407
|
+
firstTs ??= ts;
|
|
408
|
+
lastTs = ts;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch { /* ignore malformed telemetry */ }
|
|
412
|
+
}
|
|
413
|
+
if (firstTs !== undefined && lastTs !== undefined)
|
|
414
|
+
stats.elapsedMs = Math.max(0, lastTs - firstTs);
|
|
415
|
+
const issues = [];
|
|
416
|
+
if (stats.files.size > input.budget.maxFiles)
|
|
417
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.files.size} files (budget ${input.budget.maxFiles})`);
|
|
418
|
+
if (stats.bytes > input.budget.maxBytes)
|
|
419
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.bytes} bytes (budget ${input.budget.maxBytes})`);
|
|
420
|
+
if (input.budget.maxMs !== undefined &&
|
|
421
|
+
stats.elapsedMs !== undefined &&
|
|
422
|
+
stats.elapsedMs > input.budget.maxMs)
|
|
423
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.elapsedMs}ms (budget ${input.budget.maxMs}ms)`);
|
|
424
|
+
return issues;
|
|
425
|
+
}
|
|
341
426
|
/** Deterministic read-burst guard for frontend-plan-pi: count read-only
|
|
342
427
|
* discovery tool calls (read/grep/ls/find) from the session log. Over budget →
|
|
343
428
|
* read-burst, retried with a reduced-reading instruction. Pure scan; a
|
|
@@ -685,6 +770,56 @@ export async function createFrontendDesignTerminalTools(input) {
|
|
|
685
770
|
},
|
|
686
771
|
};
|
|
687
772
|
}
|
|
773
|
+
/**
|
|
774
|
+
* Source fidelity ledger (AC-005/AC-006): load the contract node's committed
|
|
775
|
+
* requirement facts and build a requirement-id → provenance map. The contract
|
|
776
|
+
* node declared sourceFragmentIds/sourceRefs from the ledger's
|
|
777
|
+
* requirement→fragment mapping; the plan inherits them by id so the compiled
|
|
778
|
+
* canonical contract carries authoritative provenance without the plan
|
|
779
|
+
* re-deriving (or fabricating) it. Best-effort: missing/unreadable contract
|
|
780
|
+
* ledger yields an empty map and the plan compiles as before (the design
|
|
781
|
+
* policy shell will then fail closed on missing provenance).
|
|
782
|
+
*/
|
|
783
|
+
async function loadContractRequirementInheritance(runDir) {
|
|
784
|
+
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
785
|
+
let records;
|
|
786
|
+
try {
|
|
787
|
+
records = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return new Map();
|
|
791
|
+
}
|
|
792
|
+
const byId = new Map();
|
|
793
|
+
for (const record of records) {
|
|
794
|
+
const fact = record.fact;
|
|
795
|
+
if (!fact || typeof fact !== "object")
|
|
796
|
+
continue;
|
|
797
|
+
const recordFact = fact;
|
|
798
|
+
if (recordFact.origin !== "contract" ||
|
|
799
|
+
recordFact.kind !== "requirement") {
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
// Contract requirement facts carry id/sourceFragmentIds/sourceRefs on
|
|
803
|
+
// the fact itself (origin=contract, kind=requirement, id, text, ...),
|
|
804
|
+
// not inside an `entry` wrapper.
|
|
805
|
+
const id = typeof recordFact.id === "string" ? recordFact.id : "";
|
|
806
|
+
if (!id)
|
|
807
|
+
continue;
|
|
808
|
+
const sourceFragmentIds = Array.isArray(recordFact.sourceFragmentIds)
|
|
809
|
+
? recordFact.sourceFragmentIds.filter((value) => typeof value === "string")
|
|
810
|
+
: undefined;
|
|
811
|
+
const sourceRefs = Array.isArray(recordFact.sourceRefs)
|
|
812
|
+
? recordFact.sourceRefs.filter((value) => typeof value === "string")
|
|
813
|
+
: undefined;
|
|
814
|
+
if (sourceFragmentIds || sourceRefs) {
|
|
815
|
+
byId.set(id, {
|
|
816
|
+
...(sourceFragmentIds ? { sourceFragmentIds } : {}),
|
|
817
|
+
...(sourceRefs ? { sourceRefs } : {}),
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return byId;
|
|
822
|
+
}
|
|
688
823
|
/**
|
|
689
824
|
* A+B: `frontend-plan-pi` now records its decision ledger through seven
|
|
690
825
|
* incremental `record_*` tools (origin=plan) and closes with exactly one
|
|
@@ -725,9 +860,19 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
725
860
|
implementationTargets: optionalStringArray,
|
|
726
861
|
verificationTargetIds: optionalStringArray,
|
|
727
862
|
notApplicableReason: optionalString,
|
|
863
|
+
// Compatibility is intentionally limited to this model-facing tool
|
|
864
|
+
// boundary. Canonical contract facts always use notApplicableReason.
|
|
865
|
+
reason: Type.Optional(Type.String({
|
|
866
|
+
description: "Legacy/model alias for notApplicableReason. Use notApplicableReason in new calls.",
|
|
867
|
+
})),
|
|
728
868
|
}, { additionalProperties: false });
|
|
729
869
|
const interactionSchema = Type.Object({
|
|
730
|
-
name:
|
|
870
|
+
name: optionalString,
|
|
871
|
+
// Compatibility is intentionally limited to this model-facing tool
|
|
872
|
+
// boundary. Canonical contract facts always use name.
|
|
873
|
+
id: Type.Optional(Type.String({
|
|
874
|
+
description: "Legacy/model alias for name. Use name in new calls.",
|
|
875
|
+
})),
|
|
731
876
|
trigger: Type.String({}),
|
|
732
877
|
expectedBehavior: Type.String({}),
|
|
733
878
|
implementationTargets: stringArray,
|
|
@@ -764,7 +909,9 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
764
909
|
description: "Optional. Real exported identifier or describe/it title in the target file. Omit it to keep the call small — the trace gate then only verifies the file exists and the command ran. Forbidden for type=static (runtime strips it).",
|
|
765
910
|
})),
|
|
766
911
|
requirementIds: stringArray,
|
|
767
|
-
uiStates:
|
|
912
|
+
uiStates: Type.Optional(Type.Array(Type.String({}), {
|
|
913
|
+
description: "Optional only at this tool boundary. An omitted value is deterministically recorded as []. Pass an explicit array for new calls.",
|
|
914
|
+
})),
|
|
768
915
|
}, { additionalProperties: false });
|
|
769
916
|
const evidenceGapSchema = Type.Object({
|
|
770
917
|
requirementId: optionalString,
|
|
@@ -791,6 +938,9 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
791
938
|
const stringList = (value) => Array.isArray(value)
|
|
792
939
|
? value.filter((item) => typeof item === "string")
|
|
793
940
|
: [];
|
|
941
|
+
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0
|
|
942
|
+
? value.trim()
|
|
943
|
+
: undefined;
|
|
794
944
|
const planToolReceipt = (details) => ({
|
|
795
945
|
content: [
|
|
796
946
|
{
|
|
@@ -863,7 +1013,7 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
863
1013
|
const recordComponentChoiceTool = defineTool({
|
|
864
1014
|
name: "record_component_choice",
|
|
865
1015
|
label: "record_component_choice",
|
|
866
|
-
description: "Record ONE component choice (origin=plan component-choice fact).
|
|
1016
|
+
description: "Record ONE component choice (origin=plan component-choice fact). Declare every UI purpose's component selection. A component the task source/PRD mandates adding (not present in the repo) MUST be decision=new with a specReference pointing at the PRD section that defines it; decision=reuse-existing is only for components that already exist in the repo (e.g. reusing ActiveRunBadge's styling convention). Omit rationale for reuse-existing; it is optional. Call once per component — one tool call per message. Optionally include stylingStrategy (set it once, on the first call).",
|
|
867
1017
|
promptSnippet: "Record one component choice (one tool call per message).",
|
|
868
1018
|
parameters: Type.Object({
|
|
869
1019
|
choice: uiComponentChoiceSchema,
|
|
@@ -902,10 +1052,79 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
902
1052
|
interactions: Type.Array(interactionSchema),
|
|
903
1053
|
}, { additionalProperties: false }),
|
|
904
1054
|
async execute(_toolCallId, params) {
|
|
905
|
-
const
|
|
906
|
-
|
|
1055
|
+
const rawUiStates = Array.isArray(params?.uiStates)
|
|
1056
|
+
? params.uiStates
|
|
1057
|
+
: [];
|
|
1058
|
+
const rawInteractions = Array.isArray(params?.interactions)
|
|
907
1059
|
? params.interactions
|
|
908
1060
|
: [];
|
|
1061
|
+
const uiStates = [];
|
|
1062
|
+
for (const rawState of rawUiStates) {
|
|
1063
|
+
if (!isRecordObject(rawState)) {
|
|
1064
|
+
return planToolReceipt({
|
|
1065
|
+
ok: false,
|
|
1066
|
+
kind: "state-flow",
|
|
1067
|
+
error: "record_state_flow uiStates entries must be objects",
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
const { reason, notApplicableReason, ...state } = rawState;
|
|
1071
|
+
const canonicalReason = nonEmptyString(notApplicableReason);
|
|
1072
|
+
const aliasReason = nonEmptyString(reason);
|
|
1073
|
+
if (canonicalReason !== undefined &&
|
|
1074
|
+
aliasReason !== undefined &&
|
|
1075
|
+
canonicalReason !== aliasReason) {
|
|
1076
|
+
return planToolReceipt({
|
|
1077
|
+
ok: false,
|
|
1078
|
+
kind: "state-flow",
|
|
1079
|
+
error: "record_state_flow uiState has conflicting reason and notApplicableReason values",
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
const resolvedReason = canonicalReason ?? aliasReason;
|
|
1083
|
+
if (state.applicable === false && !resolvedReason) {
|
|
1084
|
+
return planToolReceipt({
|
|
1085
|
+
ok: false,
|
|
1086
|
+
kind: "state-flow",
|
|
1087
|
+
error: "record_state_flow requires non-empty notApplicableReason when uiState.applicable is false",
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
uiStates.push({
|
|
1091
|
+
...state,
|
|
1092
|
+
...(resolvedReason
|
|
1093
|
+
? { notApplicableReason: resolvedReason }
|
|
1094
|
+
: {}),
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
const interactions = [];
|
|
1098
|
+
for (const rawInteraction of rawInteractions) {
|
|
1099
|
+
if (!isRecordObject(rawInteraction)) {
|
|
1100
|
+
return planToolReceipt({
|
|
1101
|
+
ok: false,
|
|
1102
|
+
kind: "state-flow",
|
|
1103
|
+
error: "record_state_flow interactions entries must be objects",
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const { id, name, ...interaction } = rawInteraction;
|
|
1107
|
+
const canonicalName = nonEmptyString(name);
|
|
1108
|
+
const aliasName = nonEmptyString(id);
|
|
1109
|
+
if (canonicalName !== undefined &&
|
|
1110
|
+
aliasName !== undefined &&
|
|
1111
|
+
canonicalName !== aliasName) {
|
|
1112
|
+
return planToolReceipt({
|
|
1113
|
+
ok: false,
|
|
1114
|
+
kind: "state-flow",
|
|
1115
|
+
error: "record_state_flow interaction has conflicting id and name values",
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
const resolvedName = canonicalName ?? aliasName;
|
|
1119
|
+
if (!resolvedName) {
|
|
1120
|
+
return planToolReceipt({
|
|
1121
|
+
ok: false,
|
|
1122
|
+
kind: "state-flow",
|
|
1123
|
+
error: "record_state_flow requires non-empty interaction.name (id is accepted only as a legacy alias)",
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
interactions.push({ ...interaction, name: resolvedName });
|
|
1127
|
+
}
|
|
909
1128
|
const states = uiStates
|
|
910
1129
|
.map((state) => (typeof state?.name === "string" ? state.name : ""))
|
|
911
1130
|
.filter(Boolean);
|
|
@@ -993,7 +1212,7 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
993
1212
|
const recordPlanRequirementTool = defineTool({
|
|
994
1213
|
name: "record_plan_requirement",
|
|
995
1214
|
label: "record_plan_requirement",
|
|
996
|
-
description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement; entry carries id, implementationTargets, verificationTargetIds, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). IMPORTANT: call this tool exactly ONE time per assistant message — never batch multiple record_* calls together in one message; emit one call, wait for its result, then call the next.",
|
|
1215
|
+
description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement; entry carries id, implementationTargets, verificationTargetIds, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). Each requirement id must be recorded EXACTLY once — re-recording the same id is rejected as a duplicate and would compile a duplicated requirements[] entry. IMPORTANT: call this tool exactly ONE time per assistant message — never batch multiple record_* calls together in one message; emit one call, wait for its result, then call the next.",
|
|
997
1216
|
promptSnippet: "Commit one plan requirement entry (one tool call per message).",
|
|
998
1217
|
parameters: Type.Object({
|
|
999
1218
|
entry: requirementSchema,
|
|
@@ -1008,6 +1227,28 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
1008
1227
|
});
|
|
1009
1228
|
}
|
|
1010
1229
|
const entry = rawEntry;
|
|
1230
|
+
// A requirement id is a canonical identity: recording it twice would
|
|
1231
|
+
// compile a duplicate requirements[] entry and fail design review.
|
|
1232
|
+
// Reject duplicates at the tool boundary so the model can fix them
|
|
1233
|
+
// in-node instead of burning the attempt on a later validation error.
|
|
1234
|
+
const id = typeof entry.id === "string" ? entry.id : "";
|
|
1235
|
+
if (id) {
|
|
1236
|
+
const existing = readCommittedEvents(store, attemptId).find((event) => {
|
|
1237
|
+
const fact = event.fact;
|
|
1238
|
+
if (!fact || fact.kind !== "plan-requirement")
|
|
1239
|
+
return false;
|
|
1240
|
+
const entryFact = fact.entry;
|
|
1241
|
+
return (typeof entryFact?.id === "string" &&
|
|
1242
|
+
entryFact.id === id);
|
|
1243
|
+
});
|
|
1244
|
+
if (existing) {
|
|
1245
|
+
return planToolReceipt({
|
|
1246
|
+
ok: false,
|
|
1247
|
+
kind: "plan-requirement",
|
|
1248
|
+
error: `record_plan_requirement duplicate: requirement ${id} is already recorded; do not record the same requirement id twice`,
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1011
1252
|
const result = await adoptPlanFact("plan-requirement", `${attemptId}:record_plan_requirement:${randomUUID()}`, { kind: "plan-requirement", origin: "plan", entry });
|
|
1012
1253
|
return planToolReceipt(result);
|
|
1013
1254
|
},
|
|
@@ -1029,7 +1270,13 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
1029
1270
|
error: "record_plan_verification_target requires a non-empty entry object",
|
|
1030
1271
|
});
|
|
1031
1272
|
}
|
|
1032
|
-
|
|
1273
|
+
// uiStates: [] means this verification target is intentionally not
|
|
1274
|
+
// bound to a named UI state. Keep that canonical representation even
|
|
1275
|
+
// when a model omits the optional tool-boundary field.
|
|
1276
|
+
const entry = {
|
|
1277
|
+
...rawEntry,
|
|
1278
|
+
uiStates: stringList(rawEntry.uiStates),
|
|
1279
|
+
};
|
|
1033
1280
|
const result = await adoptPlanFact("plan-verification-target", `${attemptId}:record_plan_verification_target:${randomUUID()}`, { kind: "plan-verification-target", origin: "plan", entry });
|
|
1034
1281
|
return planToolReceipt(result);
|
|
1035
1282
|
},
|
|
@@ -1068,7 +1315,13 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
1068
1315
|
async execute(_toolCallId, params) {
|
|
1069
1316
|
try {
|
|
1070
1317
|
const committed = readCommittedEvents(store, attemptId);
|
|
1071
|
-
|
|
1318
|
+
// Source fidelity ledger (AC-005/AC-006): inherit the contract
|
|
1319
|
+
// node's declared requirement→fragment provenance so the compiled
|
|
1320
|
+
// canonical contract carries sourceFragmentIds/sourceRefs even
|
|
1321
|
+
// when the plan did not re-declare them. The contract node is the
|
|
1322
|
+
// sole synthesis point; the plan inherits by requirement id.
|
|
1323
|
+
const contractInheritance = await loadContractRequirementInheritance(input.runDir);
|
|
1324
|
+
const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
|
|
1072
1325
|
const patch = {
|
|
1073
1326
|
...fragment,
|
|
1074
1327
|
...(params?.residualRisks
|
|
@@ -1783,6 +2036,11 @@ const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
|
|
|
1783
2036
|
* blocks the node.
|
|
1784
2037
|
*/
|
|
1785
2038
|
async function runFrontendReviewTerminalShadow(input) {
|
|
2039
|
+
// A provider/executor failure (in particular context-overflow) is already
|
|
2040
|
+
// authoritative. Do not rewrite it to review-terminal-missing merely
|
|
2041
|
+
// because no terminal tool could be submitted after the failed call.
|
|
2042
|
+
if (!input.mapped.ok)
|
|
2043
|
+
return input.mapped;
|
|
1786
2044
|
const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
|
|
1787
2045
|
const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
|
|
1788
2046
|
const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
|
|
@@ -1863,6 +2121,10 @@ async function runFrontendReviewTerminalShadow(input) {
|
|
|
1863
2121
|
* `design-typed-facts.jsonl` as the only authoritative verdict.
|
|
1864
2122
|
*/
|
|
1865
2123
|
async function runFrontendDesignTerminalShadow(input) {
|
|
2124
|
+
// See the review counterpart above: a failed provider call cannot be
|
|
2125
|
+
// diagnosed as an omitted terminal tool call.
|
|
2126
|
+
if (!input.mapped.ok)
|
|
2127
|
+
return input.mapped;
|
|
1866
2128
|
const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
|
|
1867
2129
|
let typedKinds = [];
|
|
1868
2130
|
try {
|
|
@@ -1980,6 +2242,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
1980
2242
|
let planLedgerTools;
|
|
1981
2243
|
let contractTools;
|
|
1982
2244
|
let scoutEvidenceTools;
|
|
2245
|
+
let readBudgetTools;
|
|
1983
2246
|
const commandPolicy = resolveDagCommandPolicy(input.task.commandPolicy);
|
|
1984
2247
|
const allowsPlaywrightCli = dagCommandPolicyAllows(input.task.commandPolicy, "playwright-cli");
|
|
1985
2248
|
let playwrightToolContext;
|
|
@@ -2177,6 +2440,30 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
2177
2440
|
};
|
|
2178
2441
|
}
|
|
2179
2442
|
}
|
|
2443
|
+
if (input.task.readBudget) {
|
|
2444
|
+
try {
|
|
2445
|
+
readBudgetTools = await createPiReadBudgetCustomTools({
|
|
2446
|
+
repoRoot: input.cwd,
|
|
2447
|
+
budget: input.task.readBudget,
|
|
2448
|
+
});
|
|
2449
|
+
writerToolPolicy = {
|
|
2450
|
+
requireSdk: true,
|
|
2451
|
+
customTools: [
|
|
2452
|
+
...(writerToolPolicy?.customTools ?? []),
|
|
2453
|
+
...readBudgetTools.customTools,
|
|
2454
|
+
],
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
catch (error) {
|
|
2458
|
+
return {
|
|
2459
|
+
ok: false,
|
|
2460
|
+
stdout: "",
|
|
2461
|
+
stderr: `pi read budget policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2462
|
+
failureCategory: "tool-policy",
|
|
2463
|
+
durationMs: Date.now() - started,
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2180
2467
|
let result;
|
|
2181
2468
|
try {
|
|
2182
2469
|
// Phase 5 (P1-9): capture the writeSet baseline BEFORE the writer provider
|
|
@@ -2294,6 +2581,37 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
2294
2581
|
: input.task.writerOutcomePolicy
|
|
2295
2582
|
? WRITER_OUTCOME_PROTOCOL_LINE
|
|
2296
2583
|
: input.task.firstProtocolLine);
|
|
2584
|
+
// A node-specific budget is enforced for every frontend reader that opts in,
|
|
2585
|
+
// including design/final review. Earlier code only checked contract, scout,
|
|
2586
|
+
// and plan nodes, leaving the two largest review sessions unbounded.
|
|
2587
|
+
const readBudgetIssues = [
|
|
2588
|
+
...(readBudgetTools?.issues() ?? []),
|
|
2589
|
+
...(await detectNodeReadBudget({
|
|
2590
|
+
runDir: meta.runDir,
|
|
2591
|
+
nodeId: input.task.id,
|
|
2592
|
+
budget: input.task.readBudget,
|
|
2593
|
+
})),
|
|
2594
|
+
];
|
|
2595
|
+
if (readBudgetIssues.length > 0) {
|
|
2596
|
+
// Read-budget telemetry is diagnostic only when the provider/executor has
|
|
2597
|
+
// already failed. In particular, keep context-overflow so node-execution
|
|
2598
|
+
// selects its compact retry envelope instead of treating the attempt as a
|
|
2599
|
+
// generic read-burst retry.
|
|
2600
|
+
if (!mapped.ok) {
|
|
2601
|
+
return {
|
|
2602
|
+
...mapped,
|
|
2603
|
+
stderr: [mapped.stderr, ...readBudgetIssues]
|
|
2604
|
+
.filter(Boolean)
|
|
2605
|
+
.join("\n\n"),
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
return {
|
|
2609
|
+
...mapped,
|
|
2610
|
+
ok: false,
|
|
2611
|
+
failureCategory: "read-burst",
|
|
2612
|
+
stderr: [mapped.stderr, ...readBudgetIssues].filter(Boolean).join("\n\n"),
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2297
2615
|
if (isFrontendReviewTypedTerminalNode(input.task)) {
|
|
2298
2616
|
return await runFrontendReviewTerminalShadow({
|
|
2299
2617
|
task: input.task,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { access, open, readFile, stat, } from "node:fs/promises";
|
|
2
|
+
class PiReadBudgetGuard {
|
|
3
|
+
budget;
|
|
4
|
+
files = new Set();
|
|
5
|
+
violations = [];
|
|
6
|
+
bytes = 0;
|
|
7
|
+
startedAt = Date.now();
|
|
8
|
+
constructor(budget) {
|
|
9
|
+
this.budget = budget;
|
|
10
|
+
}
|
|
11
|
+
issues() {
|
|
12
|
+
return [...this.violations];
|
|
13
|
+
}
|
|
14
|
+
report(message) {
|
|
15
|
+
if (!this.violations.includes(message))
|
|
16
|
+
this.violations.push(message);
|
|
17
|
+
}
|
|
18
|
+
assertElapsed() {
|
|
19
|
+
if (this.budget.maxMs === undefined)
|
|
20
|
+
return;
|
|
21
|
+
const elapsedMs = Date.now() - this.startedAt;
|
|
22
|
+
if (elapsedMs > this.budget.maxMs) {
|
|
23
|
+
const message = `read budget exhausted: ${elapsedMs}ms (budget ${this.budget.maxMs}ms)`;
|
|
24
|
+
this.report(message);
|
|
25
|
+
throw new Error(message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
reserveFile(absolutePath) {
|
|
29
|
+
this.assertElapsed();
|
|
30
|
+
if (this.files.has(absolutePath))
|
|
31
|
+
return;
|
|
32
|
+
if (this.files.size >= this.budget.maxFiles) {
|
|
33
|
+
const message = `read budget exhausted: file ${absolutePath} would exceed ${this.budget.maxFiles} files`;
|
|
34
|
+
this.report(message);
|
|
35
|
+
throw new Error(message);
|
|
36
|
+
}
|
|
37
|
+
this.files.add(absolutePath);
|
|
38
|
+
}
|
|
39
|
+
reserveBytes(bytes) {
|
|
40
|
+
this.assertElapsed();
|
|
41
|
+
const remaining = Math.max(0, this.budget.maxBytes - this.bytes);
|
|
42
|
+
if (remaining === 0) {
|
|
43
|
+
const message = `read budget exhausted: ${this.bytes} bytes (budget ${this.budget.maxBytes})`;
|
|
44
|
+
this.report(message);
|
|
45
|
+
throw new Error(message);
|
|
46
|
+
}
|
|
47
|
+
const granted = Math.min(bytes, remaining);
|
|
48
|
+
this.bytes += granted;
|
|
49
|
+
if (granted < bytes) {
|
|
50
|
+
this.report(`read budget truncated: ${bytes} requested bytes at ${this.bytes}/${this.budget.maxBytes} bytes`);
|
|
51
|
+
}
|
|
52
|
+
return granted;
|
|
53
|
+
}
|
|
54
|
+
async readFile(absolutePath) {
|
|
55
|
+
this.reserveFile(absolutePath);
|
|
56
|
+
const metadata = await stat(absolutePath);
|
|
57
|
+
const granted = this.reserveBytes(metadata.size);
|
|
58
|
+
if (granted === metadata.size)
|
|
59
|
+
return await readFile(absolutePath);
|
|
60
|
+
const handle = await open(absolutePath, "r");
|
|
61
|
+
try {
|
|
62
|
+
const buffer = Buffer.allocUnsafe(granted);
|
|
63
|
+
const { bytesRead } = await handle.read(buffer, 0, granted, 0);
|
|
64
|
+
return buffer.subarray(0, bytesRead);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async access(absolutePath) {
|
|
71
|
+
this.reserveFile(absolutePath);
|
|
72
|
+
await access(absolutePath);
|
|
73
|
+
}
|
|
74
|
+
beforeNonFileRead() {
|
|
75
|
+
this.assertElapsed();
|
|
76
|
+
}
|
|
77
|
+
limitToolResult(result) {
|
|
78
|
+
if (!result || typeof result !== "object" || !Array.isArray(result.content)) {
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const content = result.content.map((item) => {
|
|
82
|
+
if (!item || item.type !== "text" || typeof item.text !== "string") {
|
|
83
|
+
this.report("read budget truncated: non-text tool result omitted");
|
|
84
|
+
return {
|
|
85
|
+
type: "text",
|
|
86
|
+
text: "[read budget reached: non-text result omitted]",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const granted = this.reserveBytes(Buffer.byteLength(item.text));
|
|
90
|
+
return {
|
|
91
|
+
...item,
|
|
92
|
+
text: truncateUtf8(item.text, granted),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
return { ...result, content };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function truncateUtf8(text, maxBytes) {
|
|
99
|
+
if (Buffer.byteLength(text) <= maxBytes)
|
|
100
|
+
return text;
|
|
101
|
+
const marker = "\n[read result truncated by node budget]";
|
|
102
|
+
const markerBytes = Buffer.byteLength(marker);
|
|
103
|
+
if (maxBytes <= markerBytes)
|
|
104
|
+
return truncateUtf8Prefix(marker, maxBytes);
|
|
105
|
+
const contentBudget = maxBytes - markerBytes;
|
|
106
|
+
return `${truncateUtf8Prefix(text, contentBudget)}${marker}`;
|
|
107
|
+
}
|
|
108
|
+
function truncateUtf8Prefix(text, maxBytes) {
|
|
109
|
+
let result = "";
|
|
110
|
+
let bytes = 0;
|
|
111
|
+
for (const codePoint of text) {
|
|
112
|
+
const size = Buffer.byteLength(codePoint);
|
|
113
|
+
if (bytes + size > maxBytes)
|
|
114
|
+
break;
|
|
115
|
+
result += codePoint;
|
|
116
|
+
bytes += size;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function wrapTool(definition, guard, consumeResult) {
|
|
121
|
+
return {
|
|
122
|
+
...definition,
|
|
123
|
+
async execute(...args) {
|
|
124
|
+
guard.beforeNonFileRead();
|
|
125
|
+
const result = await definition.execute(...args);
|
|
126
|
+
return consumeResult ? guard.limitToolResult(result) : result;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Override Pi's built-in read-only tools with SDK definitions that meter the
|
|
132
|
+
* content before it reaches the model. The read tool caps filesystem bytes at
|
|
133
|
+
* the operation boundary; grep/find/ls have their returned payload clipped
|
|
134
|
+
* before the agent receives it. This is SDK-only by design: the CLI backend
|
|
135
|
+
* cannot inject same-name built-in overrides safely.
|
|
136
|
+
*/
|
|
137
|
+
export async function createPiReadBudgetCustomTools(input) {
|
|
138
|
+
const sdk = (await import("@earendil-works/pi-coding-agent"));
|
|
139
|
+
const createReadToolDefinition = sdk.createReadToolDefinition;
|
|
140
|
+
const createGrepToolDefinition = sdk.createGrepToolDefinition;
|
|
141
|
+
const createFindToolDefinition = sdk.createFindToolDefinition;
|
|
142
|
+
const createLsToolDefinition = sdk.createLsToolDefinition;
|
|
143
|
+
if (typeof createReadToolDefinition !== "function" ||
|
|
144
|
+
typeof createGrepToolDefinition !== "function" ||
|
|
145
|
+
typeof createFindToolDefinition !== "function" ||
|
|
146
|
+
typeof createLsToolDefinition !== "function") {
|
|
147
|
+
throw new Error("pi read budget policy unavailable: SDK read/grep/find/ls tool factories missing");
|
|
148
|
+
}
|
|
149
|
+
const guard = new PiReadBudgetGuard(input.budget);
|
|
150
|
+
const read = createReadToolDefinition(input.repoRoot, {
|
|
151
|
+
operations: {
|
|
152
|
+
readFile: (absolutePath) => guard.readFile(absolutePath),
|
|
153
|
+
access: (absolutePath) => guard.access(absolutePath),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
const grep = wrapTool(createGrepToolDefinition(input.repoRoot), guard, true);
|
|
157
|
+
const find = wrapTool(createFindToolDefinition(input.repoRoot), guard, true);
|
|
158
|
+
const ls = wrapTool(createLsToolDefinition(input.repoRoot), guard, true);
|
|
159
|
+
if (read.name !== "read" ||
|
|
160
|
+
grep.name !== "grep" ||
|
|
161
|
+
find.name !== "find" ||
|
|
162
|
+
ls.name !== "ls") {
|
|
163
|
+
throw new Error("pi read budget policy unavailable: SDK tool name mismatch");
|
|
164
|
+
}
|
|
165
|
+
return { customTools: [read, grep, find, ls], issues: () => guard.issues() };
|
|
166
|
+
}
|