@cat-factory/executor-harness 1.66.0 → 1.70.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/README.md +39 -1
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +72 -11
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +14 -139
- package/dist/bootstrap-mode.js +142 -0
- package/dist/coding-agent.js +87 -67
- package/dist/job.js +8 -75
- package/dist/pi-workspace.js +25 -16
- package/dist/pi.js +81 -16
- package/dist/runner.js +8 -0
- package/dist/structured-output.js +13 -2
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +97 -26
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +13 -165
- package/src/bootstrap-mode.ts +175 -0
- package/src/coding-agent.ts +106 -71
- package/src/job.ts +53 -93
- package/src/pi-workspace.ts +46 -21
- package/src/pi.ts +95 -16
- package/src/runner.ts +17 -0
- package/src/structured-output.ts +24 -2
package/dist/job.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseValidationChecksSpec, } from './validation-checks.js';
|
|
2
2
|
import { parseReproductionSpec, } from './reproduction-proof.js';
|
|
3
|
+
import { parseMcpServerSpecs, parseSkillSpecs, } from './agent-capabilities.js';
|
|
3
4
|
function str(value, path) {
|
|
4
5
|
if (typeof value !== 'string' || value.length === 0) {
|
|
5
6
|
throw new Error(`Invalid job: '${path}' must be a non-empty string`);
|
|
@@ -95,6 +96,8 @@ function parseHarnessAuth(o) {
|
|
|
95
96
|
harness,
|
|
96
97
|
proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
|
|
97
98
|
sessionToken: str(o.sessionToken, 'sessionToken'),
|
|
99
|
+
// Opt-IN, so a backend that doesn't serve the phase route (or predates it) is the default.
|
|
100
|
+
...(o.proxyPhasePath === true ? { proxyPhasePath: true } : {}),
|
|
98
101
|
};
|
|
99
102
|
}
|
|
100
103
|
/**
|
|
@@ -411,78 +414,6 @@ function parseContextFiles(value) {
|
|
|
411
414
|
}
|
|
412
415
|
return files;
|
|
413
416
|
}
|
|
414
|
-
/**
|
|
415
|
-
* Sanitize a skill resource's relative path: keep the subdirectory structure (so
|
|
416
|
-
* `templates/report.md` materialises nested) but reject anything that could escape the skill
|
|
417
|
-
* directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
|
|
418
|
-
* for an unsafe path (the resource is then dropped).
|
|
419
|
-
*/
|
|
420
|
-
function sanitizeSkillRelPath(value) {
|
|
421
|
-
if (typeof value !== 'string')
|
|
422
|
-
return undefined;
|
|
423
|
-
const segments = value.replace(/\\/g, '/').split('/');
|
|
424
|
-
const clean = [];
|
|
425
|
-
for (const seg of segments) {
|
|
426
|
-
if (seg === '' || seg === '.')
|
|
427
|
-
continue;
|
|
428
|
-
if (seg === '..')
|
|
429
|
-
return undefined;
|
|
430
|
-
// Same character class as a context-file name, per segment.
|
|
431
|
-
const c = seg.replace(/[^A-Za-z0-9._-]/g, '');
|
|
432
|
-
if (!c || c === '.' || c === '..' || c.startsWith('.'))
|
|
433
|
-
return undefined;
|
|
434
|
-
clean.push(c);
|
|
435
|
-
}
|
|
436
|
-
return clean.length ? clean.join('/') : undefined;
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
|
|
440
|
-
* purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
|
|
441
|
-
* default keeps the skill installable rather than dropping it — which, on the claude-code path,
|
|
442
|
-
* would leave the prompt pointing at a skill that was never installed (a blind run).
|
|
443
|
-
*/
|
|
444
|
-
const FALLBACK_SKILL_NAME = 'skill';
|
|
445
|
-
/** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
|
|
446
|
-
function sanitizeSkillName(value) {
|
|
447
|
-
if (typeof value !== 'string')
|
|
448
|
-
return undefined;
|
|
449
|
-
const base = value.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
450
|
-
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '');
|
|
451
|
-
if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.'))
|
|
452
|
-
return undefined;
|
|
453
|
-
return cleaned;
|
|
454
|
-
}
|
|
455
|
-
/** Validate the optional `skill` field, or undefined when absent/malformed. */
|
|
456
|
-
function parseSkillSpec(value) {
|
|
457
|
-
if (typeof value !== 'object' || value === null)
|
|
458
|
-
return undefined;
|
|
459
|
-
const o = value;
|
|
460
|
-
const instructions = typeof o.instructions === 'string' ? o.instructions : undefined;
|
|
461
|
-
// No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
|
|
462
|
-
// folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
|
|
463
|
-
// directory, so fall back to a safe default rather than dropping the whole skill.
|
|
464
|
-
if (!instructions)
|
|
465
|
-
return undefined;
|
|
466
|
-
const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME;
|
|
467
|
-
const description = typeof o.description === 'string' ? o.description : '';
|
|
468
|
-
const resources = [];
|
|
469
|
-
if (Array.isArray(o.resources)) {
|
|
470
|
-
const used = new Set();
|
|
471
|
-
for (const entry of o.resources) {
|
|
472
|
-
if (typeof entry !== 'object' || entry === null)
|
|
473
|
-
continue;
|
|
474
|
-
const e = entry;
|
|
475
|
-
const relPath = sanitizeSkillRelPath(e.relPath);
|
|
476
|
-
if (!relPath || used.has(relPath))
|
|
477
|
-
continue;
|
|
478
|
-
if (typeof e.content !== 'string')
|
|
479
|
-
continue;
|
|
480
|
-
used.add(relPath);
|
|
481
|
-
resources.push({ relPath, content: e.content });
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
return { name, description, instructions, resources };
|
|
485
|
-
}
|
|
486
417
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
487
418
|
function parseAgentInfraSpec(value) {
|
|
488
419
|
if (typeof value !== 'object' || value === null)
|
|
@@ -669,7 +600,8 @@ export function parseAgentJob(input) {
|
|
|
669
600
|
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
670
601
|
contextFiles: parseContextFiles(o.contextFiles),
|
|
671
602
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
672
|
-
|
|
603
|
+
skills: parseSkillSpecs(o.skills),
|
|
604
|
+
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
673
605
|
testSecrets: parseTestSecrets(o.testSecrets),
|
|
674
606
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
675
607
|
validation: parseValidationSpec(o.validation),
|
|
@@ -731,7 +663,7 @@ function parseAgentPrSpec(raw) {
|
|
|
731
663
|
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
732
664
|
*/
|
|
733
665
|
function assembleAgentJob(o, mode, agentField, parts) {
|
|
734
|
-
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries,
|
|
666
|
+
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
|
|
735
667
|
const repo = (o.repo ?? {});
|
|
736
668
|
return {
|
|
737
669
|
jobId: str(o.jobId, 'jobId'),
|
|
@@ -748,7 +680,8 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
748
680
|
...(output ? { output } : {}),
|
|
749
681
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
750
682
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
751
|
-
...(
|
|
683
|
+
...(skills ? { skills } : {}),
|
|
684
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
752
685
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
753
686
|
...(infra ? { infra } : {}),
|
|
754
687
|
...(pr ? { pr } : {}),
|
package/dist/pi-workspace.js
CHANGED
|
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readEffortReport } from './effort.js';
|
|
5
5
|
import { log } from './logger.js';
|
|
6
|
-
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
6
|
+
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, phasedProxyBaseUrl, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
7
7
|
import { mergeGuardLimits, progressGuardLimitsFromEnv, } from './progress-guard.js';
|
|
8
8
|
import { runSubscriptionHarness } from './agent-runner.js';
|
|
9
9
|
// The thin base every container agent shares: an ephemeral working directory, and
|
|
@@ -142,14 +142,14 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
142
142
|
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
143
143
|
const contextFiles = spec.contextFiles ?? [];
|
|
144
144
|
await materializeContextFiles(spec.dir, contextFiles);
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
if (spec.
|
|
152
|
-
await materializeSkillResources(spec.dir, spec.
|
|
145
|
+
// Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
|
|
146
|
+
// there. Everything else reads the checkout, so materialise each skill's resources under
|
|
147
|
+
// `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
|
|
148
|
+
// Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
|
|
149
|
+
// runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
|
|
150
|
+
// Resource-free skills are a no-op here.
|
|
151
|
+
if (spec.skills?.length && !installsSkillNatively(spec)) {
|
|
152
|
+
await materializeSkillResources(spec.dir, spec.skills);
|
|
153
153
|
}
|
|
154
154
|
// Subscription harnesses (Claude Code / Codex) authenticate with the leased
|
|
155
155
|
// token and talk direct to the vendor — no proxy config, no AGENTS.md. The
|
|
@@ -169,7 +169,8 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
169
169
|
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
170
170
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
171
171
|
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
172
|
-
...(spec.
|
|
172
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
173
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
173
174
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
174
175
|
signal: opts.signal,
|
|
175
176
|
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
@@ -220,7 +221,15 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
220
221
|
hasBlueprints,
|
|
221
222
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
222
223
|
});
|
|
223
|
-
|
|
224
|
+
// Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
|
|
225
|
+
// the phase this pass runs under is carried on the URL it is pointed at. Resolved per pass
|
|
226
|
+
// (this whole function re-runs for every repair round), which is what makes a repair round's
|
|
227
|
+
// spend distinguishable from the first pass's. Only when the BACKEND said it serves that
|
|
228
|
+
// route, since a runner pool or `LOCAL_HARNESS_IMAGE` can pair this image with an older one.
|
|
229
|
+
await writePiModelsConfig({
|
|
230
|
+
model: spec.model,
|
|
231
|
+
proxyBaseUrl: phasedProxyBaseUrl(proxyBaseUrl, opts.currentPhase?.(), spec.proxyPhasePath),
|
|
232
|
+
});
|
|
224
233
|
const { signal, onActivity, onProgress, onSpan } = opts;
|
|
225
234
|
const piOutcome = await runPi({
|
|
226
235
|
cwd: spec.dir,
|
|
@@ -240,12 +249,12 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
240
249
|
return withEffortReport(spec.dir, piOutcome);
|
|
241
250
|
}
|
|
242
251
|
/**
|
|
243
|
-
* Whether the claude-code runner will install this run's
|
|
244
|
-
*
|
|
252
|
+
* Whether the claude-code runner will install this run's skills natively (into the CLI's config
|
|
253
|
+
* dir) rather than the caller materialising them into the checkout. True ONLY for a
|
|
245
254
|
* leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
|
|
246
|
-
* uses the developer's own `~/.claude`, which the runner will not write a
|
|
247
|
-
*
|
|
248
|
-
*
|
|
255
|
+
* uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
|
|
256
|
+
* outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
|
|
257
|
+
* would overwrite each other's.
|
|
249
258
|
*/
|
|
250
259
|
export function installsSkillNatively(spec) {
|
|
251
260
|
return spec.harness === 'claude-code' && !spec.ambientAuth;
|
package/dist/pi.js
CHANGED
|
@@ -24,6 +24,63 @@ import { ProgressGuard, progressGuardLimitsFromEnv, toolCallSignal, } from './pr
|
|
|
24
24
|
* {@link runDiagnostics} flagging the rare case where even 32k is not enough.
|
|
25
25
|
*/
|
|
26
26
|
export const PI_MAX_OUTPUT_TOKENS = 32_768;
|
|
27
|
+
/**
|
|
28
|
+
* Longest phase label the backend keeps. Mirrors kernel's `MAX_PHASE_CHARS`; see
|
|
29
|
+
* {@link normalizeProxyPhase} for why this is a copy rather than an import.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_PHASE_CHARS = 32;
|
|
32
|
+
/**
|
|
33
|
+
* Normalise a phase label to what the backend will actually store: trimmed, lowercased,
|
|
34
|
+
* `[a-z0-9-]` only, bounded. `''` when the label is not a phase at all.
|
|
35
|
+
*
|
|
36
|
+
* A deliberate COPY of kernel's `normalizeCallPhase` — the container image is built from `src/`
|
|
37
|
+
* plus typescript alone, so the harness can carry no runtime dependency on a workspace package
|
|
38
|
+
* (the same constraint that forced `src/host-markdown.ts`). A copy that can drift is worse than
|
|
39
|
+
* no copy: if the harness rejected a label the backend would have accepted, the call would take
|
|
40
|
+
* the plain path and land unattributed, and if it accepted one the backend rejects it would
|
|
41
|
+
* spend a request on a segment destined for `''`. `test/llm-phase.conformity.test.ts` pins the
|
|
42
|
+
* two to identical verdicts over a corpus, so the alphabet can only be changed in both.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeProxyPhase(phase) {
|
|
45
|
+
if (typeof phase !== 'string')
|
|
46
|
+
return '';
|
|
47
|
+
const trimmed = phase.trim().toLowerCase();
|
|
48
|
+
if (!trimmed || trimmed.length > MAX_PHASE_CHARS)
|
|
49
|
+
return '';
|
|
50
|
+
return /^[a-z0-9-]+$/.test(trimmed) ? trimmed : '';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Point Pi's provider at the phase-tagged completions path for the pass about to run, so the
|
|
54
|
+
* backend can stamp WHICH slice of the run spent each call (the agent's own loop vs a pre-PR
|
|
55
|
+
* validation repair round vs a reproduction-proof repair round) — see
|
|
56
|
+
* `docs/initiatives/token-burn-instrumentation.md`. The harness drives those loops, so it is
|
|
57
|
+
* the only component that knows; reconstructing the boundary downstream from wall-clock
|
|
58
|
+
* timestamps is exactly the brittle inference this avoids.
|
|
59
|
+
*
|
|
60
|
+
* A URL segment because the harness does not make these requests: Pi does, from a config whose
|
|
61
|
+
* only per-run knobs are the base URL and the token — there is no per-request header to set.
|
|
62
|
+
*
|
|
63
|
+
* `supported` is the BACKEND's declaration that it serves the phase-tagged route, carried on the
|
|
64
|
+
* job body exactly as `webSearch` carries "point the search tool at my `/web-search`". Without it
|
|
65
|
+
* this function would encode a routing shape the receiving backend may not have: a runner pool
|
|
66
|
+
* pins its OWN harness image (`RunnerPoolManifest`), and `LOCAL_HARNESS_IMAGE` overrides the
|
|
67
|
+
* recommended pin outright, so "the image and the backend are a matched set" holds for the
|
|
68
|
+
* Cloudflare deployment and nowhere else. An image ahead of its backend would 404 EVERY model
|
|
69
|
+
* call — a dead run, not degraded telemetry. Absent/false ⇒ the plain path, and the calls land
|
|
70
|
+
* in the backend's unattributed slice.
|
|
71
|
+
*
|
|
72
|
+
* Pure so the join is unit-testable without spawning anything.
|
|
73
|
+
*/
|
|
74
|
+
export function phasedProxyBaseUrl(proxyBaseUrl, phase, supported) {
|
|
75
|
+
if (!supported)
|
|
76
|
+
return proxyBaseUrl;
|
|
77
|
+
// A label the backend would discard would be sent only to be thrown away, so send the plain
|
|
78
|
+
// path instead — the call is then honestly unattributed rather than attributed to nothing.
|
|
79
|
+
const normalized = normalizeProxyPhase(phase);
|
|
80
|
+
if (!normalized)
|
|
81
|
+
return proxyBaseUrl;
|
|
82
|
+
return `${proxyBaseUrl.replace(/\/+$/, '')}/phase/${normalized}`;
|
|
83
|
+
}
|
|
27
84
|
/** Write the Pi provider config that routes all model calls through the proxy. */
|
|
28
85
|
export async function writePiModelsConfig(opts) {
|
|
29
86
|
const dir = join(homedir(), '.pi', 'agent');
|
|
@@ -203,26 +260,34 @@ export async function materializeContextFiles(cwd, files) {
|
|
|
203
260
|
// No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
|
|
204
261
|
}
|
|
205
262
|
}
|
|
206
|
-
/** Subdirectory of {@link CONTEXT_DIR} where a
|
|
263
|
+
/** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
|
|
207
264
|
export const SKILL_CONTEXT_SUBDIR = 'skill';
|
|
208
265
|
/**
|
|
209
|
-
* Materialise
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
266
|
+
* Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
|
|
267
|
+
* — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
|
|
268
|
+
* (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
|
|
269
|
+
* skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
|
|
270
|
+
* which keys off ambient auth as well as the harness).
|
|
271
|
+
*
|
|
272
|
+
* Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
|
|
273
|
+
* the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
|
|
274
|
+
* overwrite each other — silently handing the agent the wrong template. The names were sanitized
|
|
275
|
+
* to a single safe path segment at the job boundary, as were the resource sub-paths (no
|
|
276
|
+
* traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
|
|
277
|
+
* `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
|
|
216
278
|
*/
|
|
217
|
-
export async function materializeSkillResources(cwd,
|
|
218
|
-
|
|
279
|
+
export async function materializeSkillResources(cwd, skills) {
|
|
280
|
+
const withResources = skills.filter((s) => s.resources.length);
|
|
281
|
+
if (!withResources.length)
|
|
219
282
|
return;
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
283
|
+
for (const skill of withResources) {
|
|
284
|
+
const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name);
|
|
285
|
+
await mkdir(dir, { recursive: true });
|
|
286
|
+
for (const r of skill.resources) {
|
|
287
|
+
const dest = join(dir, r.relPath);
|
|
288
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
289
|
+
await writeFile(dest, r.content, 'utf8');
|
|
290
|
+
}
|
|
226
291
|
}
|
|
227
292
|
const gitRoot = await findGitRoot(cwd);
|
|
228
293
|
if (!gitRoot)
|
package/dist/runner.js
CHANGED
|
@@ -224,9 +224,17 @@ export class JobRegistry {
|
|
|
224
224
|
// instance for its terminal result, so both channels carry the same `seq` and the
|
|
225
225
|
// backend mints one stable row id per call.
|
|
226
226
|
call.seq = entry.callMetricSeq++;
|
|
227
|
+
// …and the phase the job is in RIGHT NOW, which is what spent the call: the handlers
|
|
228
|
+
// mark `validation-repair` / `reproduction-repair` around each repair pass, so a
|
|
229
|
+
// looped run's telemetry says which loop the tokens went to instead of filing every
|
|
230
|
+
// turn under one undifferentiated "agent"
|
|
231
|
+
// (`docs/initiatives/token-burn-instrumentation.md`). Stamped at EMIT time, not at
|
|
232
|
+
// drain time: a poll can land long after the phase moved on.
|
|
233
|
+
call.phase = phase;
|
|
227
234
|
entry.callMetricBuffer.push(call);
|
|
228
235
|
},
|
|
229
236
|
onPhase: (next) => markPhase(next),
|
|
237
|
+
currentPhase: () => phase,
|
|
230
238
|
log: jobLog,
|
|
231
239
|
});
|
|
232
240
|
markPhase('done');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { redact, redactSecrets, secretsToRedact } from './redact.js';
|
|
2
2
|
import { log } from './logger.js';
|
|
3
|
-
import { PI_MAX_OUTPUT_TOKENS } from './pi.js';
|
|
3
|
+
import { PI_MAX_OUTPUT_TOKENS, phasedProxyBaseUrl } from './pi.js';
|
|
4
4
|
// A reusable abstraction for the "agent returns a structured JSON document as its
|
|
5
5
|
// final assistant message" pattern (requirements, blueprint, merger — and any future
|
|
6
6
|
// kind). An agent of this kind emits its result as text, not a tool call, and the
|
|
@@ -31,6 +31,12 @@ const REPAIR_SYSTEM = 'You repair malformed JSON. You are given text that was me
|
|
|
31
31
|
'JSON object but does not parse. Return ONLY the corrected JSON object: no prose, ' +
|
|
32
32
|
'no markdown code fences, no commentary, and never repeat or duplicate any tokens. ' +
|
|
33
33
|
'Preserve the original content faithfully; only fix the JSON structure.';
|
|
34
|
+
/**
|
|
35
|
+
* The run phase a structured-output repair call is billed to. A constant, not a `currentPhase`
|
|
36
|
+
* read: this call is made by the harness itself (the agent has already finished and left text
|
|
37
|
+
* that won't parse), so it belongs to no pass the registry marks.
|
|
38
|
+
*/
|
|
39
|
+
const STRUCTURED_REPAIR_PHASE = 'structured-repair';
|
|
34
40
|
/**
|
|
35
41
|
* Largest immediately-repeated run length we look for. The corruption duplicates
|
|
36
42
|
* whole model tokens, which carry whitespace/punctuation context and run to ~10-15
|
|
@@ -195,7 +201,12 @@ async function callRepair(badText, spec, access) {
|
|
|
195
201
|
if (!access.proxyBaseUrl || !access.sessionToken) {
|
|
196
202
|
throw new Error('structured-output repair requires the LLM proxy (Pi harness)');
|
|
197
203
|
}
|
|
198
|
-
|
|
204
|
+
// A repair round is its own slice of the run's burn, not part of the agent's loop that
|
|
205
|
+
// produced the unparseable text — and unlike the phases the registry marks, this call is made
|
|
206
|
+
// by the HARNESS itself, so its phase is a constant rather than a read of `currentPhase`
|
|
207
|
+
// (docs/initiatives/token-burn-instrumentation.md).
|
|
208
|
+
const repairBaseUrl = phasedProxyBaseUrl(access.proxyBaseUrl, STRUCTURED_REPAIR_PHASE, access.proxyPhasePath);
|
|
209
|
+
const url = `${repairBaseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
199
210
|
const messages = [
|
|
200
211
|
{ role: 'system', content: REPAIR_SYSTEM },
|
|
201
212
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.70.0",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"hono": "^4.12.32",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/kernel": "0.
|
|
30
|
-
"@cat-factory/server": "0.
|
|
31
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/kernel": "0.179.0",
|
|
30
|
+
"@cat-factory/server": "0.167.0",
|
|
31
|
+
"@cat-factory/spend": "0.12.108"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|