bareloop 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +1 -1
- package/bareloop.context.md +169 -7
- package/package.json +2 -2
- package/src/behaviour.js +150 -0
- package/src/bridges.js +1 -1
- package/src/calibrate.js +1 -1
- package/src/cardauthor.js +1 -1
- package/src/index.js +21 -1
- package/src/job.js +1 -1
- package/src/judged.js +1 -1
- package/src/ledger.js +103 -1
- package/src/plan.js +43 -2
- package/src/planrun.js +306 -16
- package/src/readshim.js +570 -0
- package/src/reuse.js +27 -3
- package/src/root.js +1 -1
- package/src/run.js +39 -4
- package/src/selection.js +1 -1
- package/src/tools.js +34 -2
- package/src/transport.js +71 -0
- package/types/behaviour.d.ts +46 -0
- package/types/index.d.ts +3 -1
- package/types/ledger.d.ts +58 -0
- package/types/plan.d.ts +19 -2
- package/types/planrun.d.ts +37 -2
- package/types/readshim.d.ts +128 -0
- package/types/reuse.d.ts +24 -0
- package/types/run.d.ts +15 -1
- package/types/tools.d.ts +17 -1
- package/types/transport.d.ts +19 -0
package/src/planrun.js
CHANGED
|
@@ -21,7 +21,7 @@ import { Gate } from 'bareguard';
|
|
|
21
21
|
import { LiteCtx } from 'litectx';
|
|
22
22
|
import { runClose, runStages, ralph, CLOSE_FAULTS, boundGap } from './ralph.js';
|
|
23
23
|
import { validatePlan, legalScopes, closeStagesOf } from './plan.js';
|
|
24
|
-
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU } from './plan.js';
|
|
24
|
+
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU, RETRIEVAL_PAIR } from './plan.js';
|
|
25
25
|
import { snapshotScope, evalExits, CHECK_GAP_MAX } from './exits.js';
|
|
26
26
|
import { createRoot } from './root.js';
|
|
27
27
|
import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
|
|
@@ -29,6 +29,7 @@ import { createLadder, STRIKE_LIMIT } from './ladder.js';
|
|
|
29
29
|
import { createTrend, FIX_STRIKE_LIMIT } from './trend.js';
|
|
30
30
|
import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
|
|
31
31
|
import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
|
|
32
|
+
import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
|
|
32
33
|
import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
|
|
33
34
|
import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
|
|
34
35
|
import { extractArtifact } from './text.js';
|
|
@@ -41,9 +42,101 @@ import {
|
|
|
41
42
|
REVIEW_DOOR, doorOpens, mechanicalStages,
|
|
42
43
|
} from './kinds.js';
|
|
43
44
|
import { workBranchName, prepareWorkBranch } from './workbranch.js';
|
|
45
|
+
import { TRANSPORT_MAX_ATTEMPTS, isTransportFailure } from './transport.js';
|
|
44
46
|
|
|
45
47
|
const require = createRequire(import.meta.url);
|
|
46
|
-
const { Loop, wireGate, HaltError } = require('bare-agent');
|
|
48
|
+
const { Loop, Retry, wireGate, HaltError } = require('bare-agent');
|
|
49
|
+
|
|
50
|
+
// F115 — hamr's ruling verbatim: "one retry for transport layer failure and
|
|
51
|
+
// reporting with the rest end of gate." TRANSPORT_MAX_ATTEMPTS is fixed
|
|
52
|
+
// (src/transport.js — never a job-spec/argv knob); `retryOn` is bare-agent's
|
|
53
|
+
// own extension seam (node_modules/bare-agent/src/retry.js), constrained to
|
|
54
|
+
// ONLY the no-HTTP-response transport class so a provider RESPONSE (4xx/5xx/
|
|
55
|
+
// 429) still rides bare-agent's own unchanged policy. One instance: `Retry`
|
|
56
|
+
// carries no per-call state, so it is safe to share across every worker.
|
|
57
|
+
// timeout:0 disables Retry's own per-attempt race (this run already has its
|
|
58
|
+
// own timing authority — the stall watchdog and the wall clock, src/stall.js
|
|
59
|
+
// / src/clock.js — and a second racing 60s clock here would just be a second,
|
|
60
|
+
// disagreeing one; bare-agent's own BA-18 idle-socket timeout is what
|
|
61
|
+
// actually bounds a hung call, and that is exactly one of the errors this
|
|
62
|
+
// retry is FOR). `backoff` is deliberately left unset: bare-agent's
|
|
63
|
+
// constructor does `options.backoff || 'exponential'`, so a literal `0` is
|
|
64
|
+
// falsy and would silently fall through to the same exponential default
|
|
65
|
+
// anyway — passing it would read as "no pause" while doing the opposite.
|
|
66
|
+
// With `maxAttempts:2` there is only ever ONE delay to compute (attempt 1),
|
|
67
|
+
// so the default exponential backoff's own formula (`2**0 * 1000`, capped at
|
|
68
|
+
// 30s) is a flat ~1s pause before the one retry — named here, not hidden
|
|
69
|
+
// behind a knob that lies about what it does.
|
|
70
|
+
const transportRetry = new Retry({ maxAttempts: TRANSPORT_MAX_ATTEMPTS, retryOn: isTransportFailure, timeout: 0 });
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wrap a Loop provider's `generate` so a single transport-class throw (fetch
|
|
74
|
+
* itself throwing — no HTTP response was ever produced) gets ONE retry via
|
|
75
|
+
* `transportRetry`, and the outcome is reported once the retry is settled.
|
|
76
|
+
* Every other property of `baseProvider` (`.name`, `.model`, `.ownsCycle`,
|
|
77
|
+
* `.policy`) passes through unchanged — bare-agent's Loop reads those
|
|
78
|
+
* directly (src/loop.js).
|
|
79
|
+
*
|
|
80
|
+
* F64 carve-out: a wall-DERIVED timeout (bare-agent's own `ETIMEDOUT`/
|
|
81
|
+
* `EDEADLINE`, thrown because `clock.callTimeoutMs()` told the provider to
|
|
82
|
+
* cut the call — `isWallTimeout`, src/clock.js) is doctrine's governance
|
|
83
|
+
* stop, never a transport casualty, and retrying it would both mis-spend a
|
|
84
|
+
* round the operator's clock already ended and corrupt `categorize`'s own
|
|
85
|
+
* classification (which reads the SAME error a moment later). So the
|
|
86
|
+
* per-call `retryOn` here is `isTransportFailure(err) && !isWallTimeout(err,
|
|
87
|
+
* clock)` — the clock's verdict always wins over the transport shape.
|
|
88
|
+
*
|
|
89
|
+
* Scope note: this wraps the ONE seam that builds every worker Loop
|
|
90
|
+
* (scout/drafter/step-worker/fix-worker, planrun.js `newLoop` ~line 2240) —
|
|
91
|
+
* and ONLY its Loop-driven (anthropic-api-shaped) provider branch. The judge
|
|
92
|
+
* loops (src/judged.js), the native/CLI session path (this file, `mkWorker`'s
|
|
93
|
+
* NATIVE branch above `ask`/`askFrom`, AND the toolless native/claude-json
|
|
94
|
+
* branch of `newLoop`'s own `loopProvider` — a retried native session
|
|
95
|
+
* re-spends a subscription turn, a different failure/cost model entirely),
|
|
96
|
+
* and the authoring scout (src/authorscout.js) are each a distinct call site
|
|
97
|
+
* outside hamr's ruling and are deliberately left unwrapped.
|
|
98
|
+
* @param {any} baseProvider
|
|
99
|
+
* @param {string} phase
|
|
100
|
+
* @param {(type: string, data?: object) => any} emit
|
|
101
|
+
* @param {(s: string) => string} scrub
|
|
102
|
+
* @param {import('./clock.js').Clock} clock
|
|
103
|
+
*/
|
|
104
|
+
function withTransportRetry(baseProvider, phase, emit, scrub, clock) {
|
|
105
|
+
return new Proxy(baseProvider, {
|
|
106
|
+
get(target, prop, receiver) {
|
|
107
|
+
if (prop !== 'generate') return Reflect.get(target, prop, receiver);
|
|
108
|
+
return async (...args) => {
|
|
109
|
+
let attempts = 0;
|
|
110
|
+
let firstErr = null;
|
|
111
|
+
const fn = async () => {
|
|
112
|
+
attempts += 1;
|
|
113
|
+
try {
|
|
114
|
+
return await target.generate(...args);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (attempts === 1) firstErr = err;
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
// `recovered` is knowable only once the retried attempt has settled
|
|
121
|
+
// (succeeded or exhausted the budget) — emit exactly once, here,
|
|
122
|
+
// after the outcome is known, never speculatively before it.
|
|
123
|
+
const report = (recovered) => {
|
|
124
|
+
if (attempts <= 1) return; // no retry happened — nothing to report
|
|
125
|
+
const message = String(firstErr?.message ?? firstErr ?? '').slice(0, 200);
|
|
126
|
+
emit('transport-retry', { phase, attempt: 1, error: scrub(message), recovered });
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
const result = await transportRetry.call(fn, { retryOn: (err) => isTransportFailure(err) && !isWallTimeout(err, clock) });
|
|
130
|
+
report(true);
|
|
131
|
+
return result;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
report(false);
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
47
140
|
const { createShellTools } = require('bare-agent/tools');
|
|
48
141
|
|
|
49
142
|
/** the scout's hard round bound — read-only survey, never a worker (v1.12 §1) */
|
|
@@ -296,6 +389,26 @@ export function closeGapBlock(gap) {
|
|
|
296
389
|
*/
|
|
297
390
|
export const BOUND_REASON_MAX = 200;
|
|
298
391
|
|
|
392
|
+
/**
|
|
393
|
+
* BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
|
|
394
|
+
* `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
|
|
395
|
+
* `pricing` on every metering payload; both metering callbacks below forward it onto the
|
|
396
|
+
* spine through THIS one helper, because two sites spelling one rule are two instruments
|
|
397
|
+
* (the ripgrep fix that landed in ci.yml but not publish.yml).
|
|
398
|
+
*
|
|
399
|
+
* Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
|
|
400
|
+
* the one place that decides what counts as a guess. And an ABSENT provenance stays
|
|
401
|
+
* absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
|
|
402
|
+
* in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
|
|
403
|
+
* invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
|
|
404
|
+
* priced", which is a different fact from "nobody told us".
|
|
405
|
+
* @param {any} arg an `onLlmResult` / `onTurn` payload
|
|
406
|
+
* @returns {{rateSource?: string|null}} the field to spread into the spine record
|
|
407
|
+
*/
|
|
408
|
+
export function rateSourceFields(arg) {
|
|
409
|
+
return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
|
|
410
|
+
}
|
|
411
|
+
|
|
299
412
|
/**
|
|
300
413
|
* What the NEXT attempt is told about the bound that cut the previous one.
|
|
301
414
|
*
|
|
@@ -374,8 +487,12 @@ export function boundedNote(bounded, rounds) {
|
|
|
374
487
|
* REQUIREMENT the plan must meet, never a replacement for the goal: the signed goal and
|
|
375
488
|
* the signed close are unchanged, and what the person added is what the machine could
|
|
376
489
|
* not see.
|
|
490
|
+
* @param {boolean|'cap'|'diff'} [readShim] the read shim's ARM. Does it carry G1? Then G1
|
|
491
|
+
* is STATED as well as enforced (the mailbox precedent again: a law only the validator
|
|
492
|
+
* knows costs a redraft per draft). The arms without it — `false` and `'diff'` — render
|
|
493
|
+
* byte-identically to the pre-shim prompt.
|
|
377
494
|
*/
|
|
378
|
-
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}, doorNote = null) {
|
|
495
|
+
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}, doorNote = null, readShim = false) {
|
|
379
496
|
const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
|
|
380
497
|
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
381
498
|
// The shape-lottery laws, stated where the check menu is offered (the mailbox
|
|
@@ -399,6 +516,16 @@ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes,
|
|
|
399
516
|
// (`?? []`: a close naming no command stages nothing, so it offers nothing)
|
|
400
517
|
const closeStages = closeStagesOf(job) ?? [];
|
|
401
518
|
const checkNames = checkMenu(closeStages).map((m) => m.name);
|
|
519
|
+
// G1, stated where the grant is offered. Only under an arm that CARRIES G1 —
|
|
520
|
+
// with the shim off, or under the diff-only arm that caps nothing, the rule
|
|
521
|
+
// does not exist and neither does this sentence (the A0 and A2 renders). The
|
|
522
|
+
// sentence also states the 24KB bound as its reason, which is the second
|
|
523
|
+
// reason it cannot render for A2: that bound is not in force there.
|
|
524
|
+
const readShimLaw = readShimArm(readShim).g1
|
|
525
|
+
? `\n A step granting "read" must also grant "recall" and "get": a read of a large file
|
|
526
|
+
returns only its next 24KB, and those two verbs are the only way to read a symbol in
|
|
527
|
+
full. A step that grants "read" without both is rejected.`
|
|
528
|
+
: '';
|
|
402
529
|
const doc = `DRAFT-PLAN
|
|
403
530
|
You are planning how to accomplish a goal in a repository, as an ordered list of bounded
|
|
404
531
|
steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
|
|
@@ -409,7 +536,7 @@ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] }
|
|
|
409
536
|
strictly in array order. Each step (no other fields exist):
|
|
410
537
|
- "id": kebab-case slug, unique
|
|
411
538
|
- "action": the step's task, precise enough for a worker that sees ONLY this step
|
|
412
|
-
- "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (read/grep are the worker's ONLY way to see the tree — there is no shell; write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)
|
|
539
|
+
- "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (read/grep are the worker's ONLY way to see the tree — there is no shell; write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)${readShimLaw}
|
|
413
540
|
- "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
|
|
414
541
|
- "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
|
|
415
542
|
- "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
|
|
@@ -518,6 +645,17 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
518
645
|
* @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
|
|
519
646
|
* reserved — a scout that spends every round on tools gets one toolless round to write its
|
|
520
647
|
* survey, because the bound halts it mid-tool-use and text is its only deliverable)
|
|
648
|
+
* @param {boolean|'cap'|'diff'} [opts.readShim=false] THE READ SHIM (src/readshim.js) — the
|
|
649
|
+
* ARM to run, one of the Phase 2 pre-registration's four: `false` (A0, off — the default),
|
|
650
|
+
* `'cap'` (A1: cap + pointer + next-unseen-slice + G1), `'diff'` (A2: the diff lever alone,
|
|
651
|
+
* no cap, no pointer, no G1), `true` (A3: every lever). Anything else THROWS at the entry
|
|
652
|
+
* below rather than coercing into a truthy shim — a mis-spelled arm that silently ran A3
|
|
653
|
+
* is a corrupted battery row, invisible afterwards.
|
|
654
|
+
* OFF is byte-identical to the pre-shim run in every observable, G1 included: the frozen
|
|
655
|
+
* A0 baseline arm must be exactly today's behaviour, and a guard firing under a disabled
|
|
656
|
+
* shim would quietly make the baseline a treatment arm. The default-flip waits on a paid
|
|
657
|
+
* contrast that has not been approved (the `layerRoot` precedent, F41 — an unproven lever
|
|
658
|
+
* ships OFF and earns its default).
|
|
521
659
|
* @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
|
|
522
660
|
* scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
|
|
523
661
|
* the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
|
|
@@ -667,8 +805,51 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
667
805
|
* 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
|
|
668
806
|
* 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
669
807
|
*/
|
|
670
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
|
|
808
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
|
|
809
|
+
// MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
|
|
810
|
+
// every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
|
|
811
|
+
// worker) — one accumulator closed over by all of them, because the shim's ledger
|
|
812
|
+
// is deliberately per-worker (F-class reset boundary) while this readout is
|
|
813
|
+
// per-run. `wrapRead`'s call sites feed it via `onCount`; nothing here re-derives
|
|
814
|
+
// a count, it only sums what the shim already measured. Read, never written, by
|
|
815
|
+
// anything outside the `finally` below.
|
|
816
|
+
const memoryCacheCounts = { pointered: 0, capped: 0, bytesWithheld: 0 };
|
|
671
817
|
workdir = resolve(workdir);
|
|
818
|
+
// The read shim's ARM, resolved at the door — before the workdir is read, before
|
|
819
|
+
// the scout, before a single token. An unrecognised spelling throws HERE, where
|
|
820
|
+
// it costs nothing, rather than coercing into a truthy shim and running A3 under
|
|
821
|
+
// an A2 label for the rest of a paid row (the blind-instrument class: the result
|
|
822
|
+
// would be wrong and would look fine). Only the guard runs here; the flag itself
|
|
823
|
+
// is still threaded onward as written.
|
|
824
|
+
readShimArm(readShim);
|
|
825
|
+
// G1's OTHER half, refused at the same $0 door. `validatePlan` reds `read-blind`
|
|
826
|
+
// when a step grants `read` without the retrieval pair — but a step cannot grant
|
|
827
|
+
// what the SIGNED CEILING does not offer, so against a spec whose `tools` lack
|
|
828
|
+
// `recall`/`get` EVERY draft reds identically, and the drafter is paid for each
|
|
829
|
+
// doomed cycle before the run fails. The condition is knowable here, before a
|
|
830
|
+
// token: the arm is the operator's argument and the ceiling is already signed.
|
|
831
|
+
//
|
|
832
|
+
// Same class as the unknown-arm throw above and deliberately NOT a red: a red is
|
|
833
|
+
// a verdict on the AGENT's work, and nothing the agent authored is wrong here —
|
|
834
|
+
// the operator asked for a shim the signature cannot satisfy. Tighten-only, and
|
|
835
|
+
// it narrows nothing that works: the only configuration it rejects is one that
|
|
836
|
+
// already fails 100% of the time, just later and for money.
|
|
837
|
+
//
|
|
838
|
+
// Keyed on `arm.g1`, not on "is the shim on", for the same reason `plan.js` is:
|
|
839
|
+
// the diff-only arm caps nothing, so there is nothing to be blind about.
|
|
840
|
+
if (readShimArm(readShim).g1) {
|
|
841
|
+
const signedCeiling = Array.isArray(job?.tools) ? job.tools : [...TOOL_MENU];
|
|
842
|
+
const short = RETRIEVAL_PAIR.filter((v) => !signedCeiling.includes(v));
|
|
843
|
+
if (short.length) {
|
|
844
|
+
throw new TypeError(`readShim: this arm caps reads, so a step granting "read" must also grant ${RETRIEVAL_PAIR.join(' and ')} — but the signed ceiling [${signedCeiling.join(', ')}] offers no ${short.join('/')}, so no legal plan exists and every draft would red as "read-blind". Re-sign the spec with ${short.join(' and ')}, or run with the shim off.`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// MEMORY-CACHE's own `try`: everything from here on is a run that actually
|
|
848
|
+
// BEGAN (both $0 refusals above — the unknown-arm throw and the G1 throw —
|
|
849
|
+
// already happened and never touch this block), so `finally` below is the
|
|
850
|
+
// one place the accumulated counts are read out, no matter which of the
|
|
851
|
+
// paths below this point returns or throws.
|
|
852
|
+
try {
|
|
672
853
|
// ONE spelling of the redaction, housed next to the inventory it reads
|
|
673
854
|
// (src/validate.js) — the same helper the isolate verbs scrub the litectx store
|
|
674
855
|
// with. Two hand-spelled copies of "what a secret looks like" is exactly how
|
|
@@ -1152,6 +1333,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1152
1333
|
onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
|
|
1153
1334
|
stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
|
|
1154
1335
|
costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
|
|
1336
|
+
// BA-21 provenance, through the SAME helper the worker rounds use — this record
|
|
1337
|
+
// is on `spendProvenance`'s read list (src/ledger.js), so a judge-round that
|
|
1338
|
+
// never carried the field would read UNKNOWN forever inside the readout built to
|
|
1339
|
+
// expose exactly this seam. NAMED, not papered over: the judge cost payload
|
|
1340
|
+
// (src/kinds.js `onJudgeCost({...})`) does not carry `rateSource` today, so it
|
|
1341
|
+
// still reads UNKNOWN — correctly — until that payload forwards it too.
|
|
1342
|
+
...rateSourceFields(c),
|
|
1155
1343
|
}),
|
|
1156
1344
|
})
|
|
1157
1345
|
: runStages(stages, scrub, closeOpts));
|
|
@@ -1855,11 +2043,56 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1855
2043
|
};
|
|
1856
2044
|
const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
|
|
1857
2045
|
const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
|
|
2046
|
+
// THE READ SHIM (arm-gated, default OFF): a per-worker delivery ledger over the
|
|
2047
|
+
// read tool — cap at READ_SHIM_CAP, pointer only once the CURRENT content has been
|
|
2048
|
+
// delivered whole, next-unseen-slice otherwise, diff on a changed re-read. WHICH of
|
|
2049
|
+
// those are live is the arm (`readShimArm`, Phase 2's A0/A1/A2/A3); off is no wrapper at
|
|
2050
|
+
// all. Same seam and the same per-worker lifetime argument as the native cap below:
|
|
2051
|
+
// `createShellTools()` above builds fresh tool objects for THIS worker, so the ledger
|
|
2052
|
+
// dies with the worker, which is the reset the archive replay segmented on.
|
|
2053
|
+
//
|
|
2054
|
+
// COMPOSITION ON NATIVE, decided: a CAPPING arm REPLACES the native wrapper, never layers
|
|
2055
|
+
// over it. Both bound the same seam at the same 24 KB (READ_SHIM_CAP === NATIVE_READ_CAP,
|
|
2056
|
+
// deliberately one number) and both hand back a trusted steer at the same two verbs, so
|
|
2057
|
+
// stacking them would truncate twice and append two notices — and worse, the shim on the
|
|
2058
|
+
// OUTSIDE would ledger the native wrapper's already-truncated text as the whole file and
|
|
2059
|
+
// then answer a re-read with a pointer, which is precisely the lie this module exists to
|
|
2060
|
+
// prevent. The shim is the strictly stronger wrapper (it keeps the CLI display bound AND
|
|
2061
|
+
// continues where the worker left off), so with a CAPPING arm ON it takes the seam alone.
|
|
2062
|
+
// An arm that brings NO cap (A2) is the other case and is handled at the block below —
|
|
2063
|
+
// it must not take the native bound away, because losing a bound is not one of its levers.
|
|
2064
|
+
const shimArm = readShimArm(readShim);
|
|
2065
|
+
// ONE shim per worker, holding ONE ledger, wrapping BOTH seams the cap touches:
|
|
2066
|
+
// the read tool below, and `ctx_get`'s stale-pointer answer further down. They
|
|
2067
|
+
// are one shim because the cap creates the second problem — its strategy line
|
|
2068
|
+
// sends a capped worker to ctx_recall/ctx_get for a whole function, and a stale
|
|
2069
|
+
// pointer there is the cap's own dead end, not litectx's.
|
|
2070
|
+
const shim = createReadShim({
|
|
2071
|
+
arm: shimArm,
|
|
2072
|
+
// MEMORY-CACHE: this worker's shim reports into the RUN-level accumulator
|
|
2073
|
+
// declared at the top of `runPlan` — one shim per worker, one sum per run.
|
|
2074
|
+
onCount: (kind, bytes) => {
|
|
2075
|
+
if (kind === 'pointered') memoryCacheCounts.pointered++;
|
|
2076
|
+
else memoryCacheCounts.capped++;
|
|
2077
|
+
memoryCacheCounts.bytesWithheld += bytes;
|
|
2078
|
+
},
|
|
2079
|
+
});
|
|
2080
|
+
const rdTool = shell.find((/** @type {{name: string}} */ t) => t.name === TOOL_BY_VERB.read);
|
|
1858
2081
|
// F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
|
|
1859
2082
|
// TRUSTED truncation notice steering to ctx_get — the CLI's own truncation blinds the worker
|
|
1860
2083
|
// (spilled + injection-flagged). Fresh tool objects per mkWorker call, so mutating is per-worker.
|
|
1861
|
-
|
|
1862
|
-
|
|
2084
|
+
//
|
|
2085
|
+
// The condition is the shim's CAP, not the shim: an arm that caps nothing (A2, the diff)
|
|
2086
|
+
// takes the native wrapper's cap away from a native run and hands the seam back to the CLI's
|
|
2087
|
+
// silent truncation — a REGRESSION against A0 on that surface, produced by a lever that is
|
|
2088
|
+
// not about capping at all. So the native bound stays whenever the shim brings none, and it
|
|
2089
|
+
// is applied FIRST, INSIDE the shim: the shim's own doctrine is that it ledgers the text the
|
|
2090
|
+
// worker actually received (the tool's own maxBytes bound is already handled this way), so a
|
|
2091
|
+
// diff against a natively-truncated delivery is a diff against bytes the worker really holds.
|
|
2092
|
+
// Outside, it would truncate the shim's diff mid-hunk. Under A1/A3 this block does not run at
|
|
2093
|
+
// all, so the order is unobservable there and the composition decision below is unchanged.
|
|
2094
|
+
if (native && !shimArm.cap) {
|
|
2095
|
+
const rd = rdTool;
|
|
1863
2096
|
if (rd) {
|
|
1864
2097
|
const inner = rd.execute;
|
|
1865
2098
|
rd.execute = async (/** @type {any} */ args) => {
|
|
@@ -1872,8 +2105,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1872
2105
|
};
|
|
1873
2106
|
}
|
|
1874
2107
|
}
|
|
2108
|
+
// …and the shim goes on last, so it is the OUTER wrapper wherever both apply.
|
|
2109
|
+
if (shimArm.on && rdTool) shim.wrapRead(rdTool);
|
|
2110
|
+
// The stale-pointer serve rides ONLY with a capping arm — `serveStale` refuses
|
|
2111
|
+
// on any other arm anyway, and passing the hook regardless would still change
|
|
2112
|
+
// A0's `ctx_get` object, which has to stay the untouched baseline.
|
|
1875
2113
|
const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
|
|
1876
|
-
? createCtxTools(lc, workdir, emitCtx).filter((t) => grantedNames.has(t.name))
|
|
2114
|
+
? createCtxTools(lc, workdir, emitCtx, shimArm.cap ? { onStalePointer: shim.serveStale } : {}).filter((t) => grantedNames.has(t.name))
|
|
1877
2115
|
: [];
|
|
1878
2116
|
// LC-3 (litectx 0.31.0): cooperative yield — index() is async but its work is sync CPU,
|
|
1879
2117
|
// and the default pass holds THIS event loop (the fuse's timers, the wall clock, the lag
|
|
@@ -1888,8 +2126,21 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1888
2126
|
// isolate/retrieval paragraphs named other components' verbs outright. The
|
|
1889
2127
|
// assembly lives in tools.js beside the prose it composes, and is asserted
|
|
1890
2128
|
// byte-identical to the full-component paragraphs on a full grant.
|
|
1891
|
-
|
|
1892
|
-
|
|
2129
|
+
// One bound, one strategy line: with a CAPPING arm on, the shim OWNS the read seam on
|
|
2130
|
+
// both surfaces (it replaced the native wrapper above), so it speaks for the cap and
|
|
2131
|
+
// the native line stands down — two notices describing one bound is how a worker learns
|
|
2132
|
+
// to distrust both. Off, this renders exactly as before.
|
|
2133
|
+
//
|
|
2134
|
+
// The lines are assembled from the SAME two facts the wrappers were: whichever bound is
|
|
2135
|
+
// actually installed speaks for itself, and the arm's own line rides along. A2 on the API
|
|
2136
|
+
// surface therefore gets the diff sentence and NOTHING about a 24KB limit — a persona
|
|
2137
|
+
// describing machinery that is off is a lie to the worker, and a worker rationing reads
|
|
2138
|
+
// against an imaginary cap is a different treatment than the arm names. A2 on native gets
|
|
2139
|
+
// both, because on native both bounds really are installed.
|
|
2140
|
+
const readLines = grantedNames.has(TOOL_BY_VERB.read)
|
|
2141
|
+
? (native && !shimArm.cap ? NATIVE_READ_STRATEGY : '') + readShimStrategy(shimArm)
|
|
2142
|
+
: '';
|
|
2143
|
+
const system = PERSONA_TOOLS + strategyFor(granted) + readLines;
|
|
1893
2144
|
/** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
|
|
1894
2145
|
const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
|
|
1895
2146
|
|
|
@@ -1903,7 +2154,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1903
2154
|
// WITH tools take this path: a native session with NO tools fires no
|
|
1904
2155
|
// onTurn and reports no cost (live-verified), so the toolless drafter runs
|
|
1905
2156
|
// the metered claude-json TEXT path below instead (never unmetered spend).
|
|
1906
|
-
/** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
|
|
2157
|
+
/** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string}} arg */
|
|
1907
2158
|
const nativeMetered = async (arg) => {
|
|
1908
2159
|
const session = (arg?.kind ?? 'turn') === 'session';
|
|
1909
2160
|
// per-turn events are ATTRIBUTION ONLY (`worker-turn`, never accounted —
|
|
@@ -1914,6 +2165,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1914
2165
|
emit(session ? 'worker-round' : 'worker-turn', {
|
|
1915
2166
|
phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
|
|
1916
2167
|
costUsd: session ? (arg?.costUsd ?? null) : null, pricing: arg?.pricing ?? null,
|
|
2168
|
+
// BA-21 provenance. The session event forwards whatever upstream reported; a
|
|
2169
|
+
// per-turn event says `null` — the same deliberate statement `costUsd` makes
|
|
2170
|
+
// one line up. A native turn is unpriced BY DESIGN (the CLI prices the SESSION),
|
|
2171
|
+
// so no rate was consulted and there is no guess to have made. Stated here
|
|
2172
|
+
// rather than forwarded because it is OUR fact about this surface, not one the
|
|
2173
|
+
// provider payload carries.
|
|
2174
|
+
...(session ? rateSourceFields(arg) : { rateSource: null }),
|
|
1917
2175
|
tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
|
|
1918
2176
|
usage: usageOf(arg?.usage),
|
|
1919
2177
|
});
|
|
@@ -1968,9 +2226,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1968
2226
|
// claude-json TEXT path reports a real per-call cost that the Loop's
|
|
1969
2227
|
// onLlmResult meters exactly like an API round, so the drafter's spend is
|
|
1970
2228
|
// never invisible (F6/F44). The gate policy is wired but idle (no tools).
|
|
2229
|
+
// F115's retry wraps ONLY the API-shaped branch — a native/claude-json session
|
|
2230
|
+
// (below) has its own failure and cost model (a retried native session
|
|
2231
|
+
// re-spends a subscription turn, not a metered fetch) and is out of scope.
|
|
1971
2232
|
const loopProvider = native
|
|
1972
2233
|
? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
|
|
1973
|
-
: (workerProvider ?? provider);
|
|
2234
|
+
: withTransportRetry(workerProvider ?? provider, phase, emit, scrub, clock);
|
|
1974
2235
|
// F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
|
|
1975
2236
|
// not call duration (provider-http.js `req.setTimeout` resets on every byte),
|
|
1976
2237
|
// so a connection that stays alive while producing nothing is invisible to it:
|
|
@@ -2012,7 +2273,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2012
2273
|
const newLoop = (gen) => {
|
|
2013
2274
|
/** @type {any} */
|
|
2014
2275
|
let self = null;
|
|
2015
|
-
/** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
|
|
2276
|
+
/** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string}} arg */
|
|
2016
2277
|
const metered = async (arg) => {
|
|
2017
2278
|
// The meter fires for EVERY generation, live or abandoned. An orphaned round
|
|
2018
2279
|
// was really billed, and spend that no instrument sees is the one thing this
|
|
@@ -2027,6 +2288,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2027
2288
|
emit('worker-round', {
|
|
2028
2289
|
phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
|
|
2029
2290
|
costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
|
|
2291
|
+
// BA-21 provenance, forwarded verbatim: `pricing` says WHETHER the round was
|
|
2292
|
+
// priced, `rateSource` says who stood behind the rate. Reporting only — the
|
|
2293
|
+
// pricing-red halt still keys on cost alone (src/run.js).
|
|
2294
|
+
...rateSourceFields(arg),
|
|
2030
2295
|
tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
|
|
2031
2296
|
usage: usageOf(arg?.usage),
|
|
2032
2297
|
});
|
|
@@ -2276,7 +2541,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2276
2541
|
};
|
|
2277
2542
|
const draftPlan = async (/** @type {any[]|null} */ reds) => {
|
|
2278
2543
|
drafter.setIteration(reds ? 'redraft' : 'draft');
|
|
2279
|
-
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null), []);
|
|
2544
|
+
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null, readShim), []);
|
|
2280
2545
|
return extractArtifact(r.text).code ?? '';
|
|
2281
2546
|
};
|
|
2282
2547
|
// Scrubbed HERE, once, before any consumer reads them — the judge() precedent
|
|
@@ -2289,7 +2554,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2289
2554
|
// emit sites: scrubbing here makes the leak inexpressible, scrubbing each
|
|
2290
2555
|
// consumer makes it something the next consumer has to remember.
|
|
2291
2556
|
const validate = (/** @type {string} */ t) => {
|
|
2292
|
-
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
|
|
2557
|
+
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, readShim, ...checkFacts });
|
|
2293
2558
|
return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
|
|
2294
2559
|
};
|
|
2295
2560
|
let text = await draftPlan(null);
|
|
@@ -2320,7 +2585,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2320
2585
|
// burn the very work resume exists to keep (hamr: "why would i want to waste
|
|
2321
2586
|
// more money on something i already started"). Omission = rules inactive, by
|
|
2322
2587
|
// the validator's contract.
|
|
2323
|
-
|
|
2588
|
+
// `readShim` IS passed, unlike the shape-lottery opts above: G1 is not a drafting
|
|
2589
|
+
// law about plan quality but a precondition of the machinery that is about to run —
|
|
2590
|
+
// a capped worker with no retrieval verb is blind, whether its plan is fresh or
|
|
2591
|
+
// reloaded. A plan drafted under the shim already passed it, so the only way this
|
|
2592
|
+
// refuses paid work is an operator flipping the flag between legs, which is a spec
|
|
2593
|
+
// change deserving the refusal. Off, it is inert, and this line renders as before.
|
|
2594
|
+
const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu, readShim });
|
|
2324
2595
|
// scrubbed at the boundary for `validate()`'s own reason: a `parse-error` detail
|
|
2325
2596
|
// quotes a window of the SOURCE, and the spine is append-only forever
|
|
2326
2597
|
const reds = pv.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
|
|
@@ -3337,4 +3608,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
3337
3608
|
// the verdict was arrived at.
|
|
3338
3609
|
if (fixOutcome === 'green') await emitReviewDoor('green', lastCloseVerdict);
|
|
3339
3610
|
return fixOutcome === 'green' ? 'green' : 'escalated';
|
|
3611
|
+
} finally {
|
|
3612
|
+
// Emitted on EVERY exit (return or throw) — `finally` fires exactly once,
|
|
3613
|
+
// right before this call returns to `runJob`, which is where every other
|
|
3614
|
+
// caller's `job-end` lands next. Armed-only: an unarmed run leaves no trace
|
|
3615
|
+
// (absence reported as absence, never a fabricated zero line, PRD antigen).
|
|
3616
|
+
// No cost fields — this is not a spend record and spend-slicing instruments
|
|
3617
|
+
// are unaffected by it (F6/F12's ACCOUNTED_ROUND_TYPES list is untouched).
|
|
3618
|
+
if (readShimArm(readShim).on) {
|
|
3619
|
+
emit('memory-cache', {
|
|
3620
|
+
pointered: memoryCacheCounts.pointered,
|
|
3621
|
+
capped: memoryCacheCounts.capped,
|
|
3622
|
+
bytesWithheld: memoryCacheCounts.bytesWithheld,
|
|
3623
|
+
// bytes→tokens is an ESTIMATE (÷4, the same rough conversion used
|
|
3624
|
+
// elsewhere) — the field is named `approx…` so no consumer reads it
|
|
3625
|
+
// as measured the way `bytesWithheld` itself is.
|
|
3626
|
+
approxTokens: Math.round(memoryCacheCounts.bytesWithheld / 4),
|
|
3627
|
+
});
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3340
3630
|
}
|