@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/src/pi.ts
CHANGED
|
@@ -33,6 +33,66 @@ import {
|
|
|
33
33
|
*/
|
|
34
34
|
export const PI_MAX_OUTPUT_TOKENS = 32_768
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Longest phase label the backend keeps. Mirrors kernel's `MAX_PHASE_CHARS`; see
|
|
38
|
+
* {@link normalizeProxyPhase} for why this is a copy rather than an import.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_PHASE_CHARS = 32
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalise a phase label to what the backend will actually store: trimmed, lowercased,
|
|
44
|
+
* `[a-z0-9-]` only, bounded. `''` when the label is not a phase at all.
|
|
45
|
+
*
|
|
46
|
+
* A deliberate COPY of kernel's `normalizeCallPhase` — the container image is built from `src/`
|
|
47
|
+
* plus typescript alone, so the harness can carry no runtime dependency on a workspace package
|
|
48
|
+
* (the same constraint that forced `src/host-markdown.ts`). A copy that can drift is worse than
|
|
49
|
+
* no copy: if the harness rejected a label the backend would have accepted, the call would take
|
|
50
|
+
* the plain path and land unattributed, and if it accepted one the backend rejects it would
|
|
51
|
+
* spend a request on a segment destined for `''`. `test/llm-phase.conformity.test.ts` pins the
|
|
52
|
+
* two to identical verdicts over a corpus, so the alphabet can only be changed in both.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeProxyPhase(phase: string | undefined): string {
|
|
55
|
+
if (typeof phase !== 'string') return ''
|
|
56
|
+
const trimmed = phase.trim().toLowerCase()
|
|
57
|
+
if (!trimmed || trimmed.length > MAX_PHASE_CHARS) return ''
|
|
58
|
+
return /^[a-z0-9-]+$/.test(trimmed) ? trimmed : ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Point Pi's provider at the phase-tagged completions path for the pass about to run, so the
|
|
63
|
+
* backend can stamp WHICH slice of the run spent each call (the agent's own loop vs a pre-PR
|
|
64
|
+
* validation repair round vs a reproduction-proof repair round) — see
|
|
65
|
+
* `docs/initiatives/token-burn-instrumentation.md`. The harness drives those loops, so it is
|
|
66
|
+
* the only component that knows; reconstructing the boundary downstream from wall-clock
|
|
67
|
+
* timestamps is exactly the brittle inference this avoids.
|
|
68
|
+
*
|
|
69
|
+
* A URL segment because the harness does not make these requests: Pi does, from a config whose
|
|
70
|
+
* only per-run knobs are the base URL and the token — there is no per-request header to set.
|
|
71
|
+
*
|
|
72
|
+
* `supported` is the BACKEND's declaration that it serves the phase-tagged route, carried on the
|
|
73
|
+
* job body exactly as `webSearch` carries "point the search tool at my `/web-search`". Without it
|
|
74
|
+
* this function would encode a routing shape the receiving backend may not have: a runner pool
|
|
75
|
+
* pins its OWN harness image (`RunnerPoolManifest`), and `LOCAL_HARNESS_IMAGE` overrides the
|
|
76
|
+
* recommended pin outright, so "the image and the backend are a matched set" holds for the
|
|
77
|
+
* Cloudflare deployment and nowhere else. An image ahead of its backend would 404 EVERY model
|
|
78
|
+
* call — a dead run, not degraded telemetry. Absent/false ⇒ the plain path, and the calls land
|
|
79
|
+
* in the backend's unattributed slice.
|
|
80
|
+
*
|
|
81
|
+
* Pure so the join is unit-testable without spawning anything.
|
|
82
|
+
*/
|
|
83
|
+
export function phasedProxyBaseUrl(
|
|
84
|
+
proxyBaseUrl: string,
|
|
85
|
+
phase: string | undefined,
|
|
86
|
+
supported: boolean | undefined,
|
|
87
|
+
): string {
|
|
88
|
+
if (!supported) return proxyBaseUrl
|
|
89
|
+
// A label the backend would discard would be sent only to be thrown away, so send the plain
|
|
90
|
+
// path instead — the call is then honestly unattributed rather than attributed to nothing.
|
|
91
|
+
const normalized = normalizeProxyPhase(phase)
|
|
92
|
+
if (!normalized) return proxyBaseUrl
|
|
93
|
+
return `${proxyBaseUrl.replace(/\/+$/, '')}/phase/${normalized}`
|
|
94
|
+
}
|
|
95
|
+
|
|
36
96
|
/** Write the Pi provider config that routes all model calls through the proxy. */
|
|
37
97
|
export async function writePiModelsConfig(opts: {
|
|
38
98
|
model: string
|
|
@@ -253,29 +313,37 @@ export async function materializeContextFiles(
|
|
|
253
313
|
}
|
|
254
314
|
}
|
|
255
315
|
|
|
256
|
-
/** Subdirectory of {@link CONTEXT_DIR} where a
|
|
316
|
+
/** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
|
|
257
317
|
export const SKILL_CONTEXT_SUBDIR = 'skill'
|
|
258
318
|
|
|
259
319
|
/**
|
|
260
|
-
* Materialise
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
320
|
+
* Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
|
|
321
|
+
* — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
|
|
322
|
+
* (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
|
|
323
|
+
* skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
|
|
324
|
+
* which keys off ambient auth as well as the harness).
|
|
325
|
+
*
|
|
326
|
+
* Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
|
|
327
|
+
* the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
|
|
328
|
+
* overwrite each other — silently handing the agent the wrong template. The names were sanitized
|
|
329
|
+
* to a single safe path segment at the job boundary, as were the resource sub-paths (no
|
|
330
|
+
* traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
|
|
331
|
+
* `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
|
|
267
332
|
*/
|
|
268
333
|
export async function materializeSkillResources(
|
|
269
334
|
cwd: string,
|
|
270
|
-
|
|
335
|
+
skills: { name: string; resources: { relPath: string; content: string }[] }[],
|
|
271
336
|
): Promise<void> {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
337
|
+
const withResources = skills.filter((s) => s.resources.length)
|
|
338
|
+
if (!withResources.length) return
|
|
339
|
+
for (const skill of withResources) {
|
|
340
|
+
const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name)
|
|
341
|
+
await mkdir(dir, { recursive: true })
|
|
342
|
+
for (const r of skill.resources) {
|
|
343
|
+
const dest = join(dir, r.relPath)
|
|
344
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
345
|
+
await writeFile(dest, r.content, 'utf8')
|
|
346
|
+
}
|
|
279
347
|
}
|
|
280
348
|
const gitRoot = await findGitRoot(cwd)
|
|
281
349
|
if (!gitRoot) return
|
|
@@ -540,6 +608,17 @@ export interface HarnessCallMetric {
|
|
|
540
608
|
* falls back to the array index, which is what it always used before streaming existed.
|
|
541
609
|
*/
|
|
542
610
|
seq?: number
|
|
611
|
+
/**
|
|
612
|
+
* The run PHASE that spent this call (`agent` / `validation-repair` / `reproduction-repair` /
|
|
613
|
+
* …), stamped by the job registry from the same marker the handlers set as they enter each
|
|
614
|
+
* phase — so the phase axis on `llm_call_metrics` comes from the component that owns the
|
|
615
|
+
* boundary rather than from a downstream guess
|
|
616
|
+
* (`docs/initiatives/token-burn-instrumentation.md`).
|
|
617
|
+
*
|
|
618
|
+
* Stamped on the SAME object as {@link seq}, so the live drain and the terminal result can
|
|
619
|
+
* never disagree about which phase billed a call.
|
|
620
|
+
*/
|
|
621
|
+
phase?: string
|
|
543
622
|
}
|
|
544
623
|
|
|
545
624
|
/**
|
package/src/runner.ts
CHANGED
|
@@ -66,6 +66,15 @@ export interface RunOptions {
|
|
|
66
66
|
* per-phase wall-clock is logged on completion. Free-form; unknown phases just show verbatim.
|
|
67
67
|
*/
|
|
68
68
|
onPhase?: (phase: string) => void
|
|
69
|
+
/**
|
|
70
|
+
* The phase most recently marked via {@link onPhase} — the read side of the same marker, for
|
|
71
|
+
* work that has to TELL the backend which phase it is in rather than merely record it. Today
|
|
72
|
+
* that is the Pi path, whose calls are metered server-side by the LLM proxy: the harness tags
|
|
73
|
+
* the proxy URL with this so a repair round's spend is attributable
|
|
74
|
+
* (`docs/initiatives/token-burn-instrumentation.md`). Absent ⇒ no phase is carried and those
|
|
75
|
+
* calls land in the backend's unattributed slice.
|
|
76
|
+
*/
|
|
77
|
+
currentPhase?: () => string
|
|
69
78
|
/** A per-job child logger carrying the run's correlation fields (jobId, repo, branch, …). */
|
|
70
79
|
log?: Logger
|
|
71
80
|
/**
|
|
@@ -468,9 +477,17 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
468
477
|
// instance for its terminal result, so both channels carry the same `seq` and the
|
|
469
478
|
// backend mints one stable row id per call.
|
|
470
479
|
call.seq = entry.callMetricSeq++
|
|
480
|
+
// …and the phase the job is in RIGHT NOW, which is what spent the call: the handlers
|
|
481
|
+
// mark `validation-repair` / `reproduction-repair` around each repair pass, so a
|
|
482
|
+
// looped run's telemetry says which loop the tokens went to instead of filing every
|
|
483
|
+
// turn under one undifferentiated "agent"
|
|
484
|
+
// (`docs/initiatives/token-burn-instrumentation.md`). Stamped at EMIT time, not at
|
|
485
|
+
// drain time: a poll can land long after the phase moved on.
|
|
486
|
+
call.phase = phase
|
|
471
487
|
entry.callMetricBuffer.push(call)
|
|
472
488
|
},
|
|
473
489
|
onPhase: (next) => markPhase(next),
|
|
490
|
+
currentPhase: () => phase,
|
|
474
491
|
log: jobLog,
|
|
475
492
|
})
|
|
476
493
|
markPhase('done')
|
package/src/structured-output.ts
CHANGED
|
@@ -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
|
|
|
5
5
|
// A reusable abstraction for the "agent returns a structured JSON document as its
|
|
6
6
|
// final assistant message" pattern (requirements, blueprint, merger — and any future
|
|
@@ -52,10 +52,23 @@ export interface StructuredOutputSpec<T> {
|
|
|
52
52
|
parse: (text: string) => T | null
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The run phase a structured-output repair call is billed to. A constant, not a `currentPhase`
|
|
57
|
+
* read: this call is made by the harness itself (the agent has already finished and left text
|
|
58
|
+
* that won't parse), so it belongs to no pass the registry marks.
|
|
59
|
+
*/
|
|
60
|
+
const STRUCTURED_REPAIR_PHASE = 'structured-repair'
|
|
61
|
+
|
|
55
62
|
/** Runtime wiring to reach the LLM proxy for the repair call. */
|
|
56
63
|
export interface ProxyAccess {
|
|
57
64
|
/** Pi-harness proxy base URL; absent for subscription harnesses (no proxy repair). */
|
|
58
65
|
proxyBaseUrl?: string
|
|
66
|
+
/**
|
|
67
|
+
* The backend serves the phase-tagged completions route, so the repair call can be attributed
|
|
68
|
+
* to {@link STRUCTURED_REPAIR_PHASE} rather than piling into the unattributed slice
|
|
69
|
+
* (see {@link HarnessAuthFields.proxyPhasePath}).
|
|
70
|
+
*/
|
|
71
|
+
proxyPhasePath?: boolean
|
|
59
72
|
/** Pi-harness proxy session token; absent for subscription harnesses. */
|
|
60
73
|
sessionToken?: string
|
|
61
74
|
model: string
|
|
@@ -266,7 +279,16 @@ async function callRepair<T>(
|
|
|
266
279
|
if (!access.proxyBaseUrl || !access.sessionToken) {
|
|
267
280
|
throw new Error('structured-output repair requires the LLM proxy (Pi harness)')
|
|
268
281
|
}
|
|
269
|
-
|
|
282
|
+
// A repair round is its own slice of the run's burn, not part of the agent's loop that
|
|
283
|
+
// produced the unparseable text — and unlike the phases the registry marks, this call is made
|
|
284
|
+
// by the HARNESS itself, so its phase is a constant rather than a read of `currentPhase`
|
|
285
|
+
// (docs/initiatives/token-burn-instrumentation.md).
|
|
286
|
+
const repairBaseUrl = phasedProxyBaseUrl(
|
|
287
|
+
access.proxyBaseUrl,
|
|
288
|
+
STRUCTURED_REPAIR_PHASE,
|
|
289
|
+
access.proxyPhasePath,
|
|
290
|
+
)
|
|
291
|
+
const url = `${repairBaseUrl.replace(/\/+$/, '')}/chat/completions`
|
|
270
292
|
const messages = [
|
|
271
293
|
{ role: 'system', content: REPAIR_SYSTEM },
|
|
272
294
|
{
|