@cat-factory/executor-harness 1.68.0 → 1.72.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 +6 -1
- package/dist/agent.js +5 -0
- package/dist/bootstrap-mode.js +1 -0
- package/dist/coding-agent.js +2 -0
- package/dist/job.js +4 -0
- package/dist/pi-workspace.js +10 -2
- package/dist/pi.js +57 -0
- package/dist/runner.js +8 -0
- package/dist/server.js +5 -0
- package/dist/structured-output.js +13 -2
- package/package.json +4 -4
- package/src/agent.ts +5 -0
- package/src/bootstrap-mode.ts +1 -0
- package/src/coding-agent.ts +4 -0
- package/src/job.ts +29 -0
- package/src/pi-workspace.ts +16 -1
- package/src/pi.ts +71 -0
- package/src/runner.ts +17 -0
- package/src/server.ts +5 -0
- package/src/structured-output.ts +24 -2
package/README.md
CHANGED
|
@@ -57,7 +57,12 @@ The implementation job (`POST /run`) is the canonical sequence:
|
|
|
57
57
|
to Pi's **global** context file `~/.pi/agent/AGENTS.md` (outside the checkout,
|
|
58
58
|
so it never lands in a commit and never clobbers a repo's own `AGENTS.md` —
|
|
59
59
|
Pi reads and concatenates both), and point Pi at the Worker's LLM proxy via
|
|
60
|
-
`~/.pi/agent/models.json` (provider `proxy`, `api: openai-completions`)
|
|
60
|
+
`~/.pi/agent/models.json` (provider `proxy`, `api: openai-completions`) — at the
|
|
61
|
+
phase-tagged completions path for the pass about to run (`.../phase/<phase>`) when the job
|
|
62
|
+
body's `proxyPhasePath` says the backend serves it, which is how a repair round's model spend
|
|
63
|
+
stays distinguishable from the first pass's in telemetry; without that flag the plain path is
|
|
64
|
+
used and the calls are recorded as unattributed
|
|
65
|
+
(see [token-burn instrumentation](../../../docs/initiatives/token-burn-instrumentation.md)),
|
|
61
66
|
3. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
|
|
62
67
|
4. **validate** the checkout, when the job body carries `validationChecks` — the service's
|
|
63
68
|
configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
|
package/dist/agent.js
CHANGED
|
@@ -183,6 +183,7 @@ async function resolveReplyCustom(job, summary, signal) {
|
|
|
183
183
|
subscriptionToken: job.subscriptionToken,
|
|
184
184
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
185
185
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
186
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
186
187
|
sessionToken: job.sessionToken,
|
|
187
188
|
model: job.model,
|
|
188
189
|
jobId: job.jobId,
|
|
@@ -488,6 +489,7 @@ async function runExploreMode(job, opts) {
|
|
|
488
489
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
489
490
|
ambientAuth: job.ambientAuth,
|
|
490
491
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
492
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
491
493
|
sessionToken: job.sessionToken,
|
|
492
494
|
serviceDirectory,
|
|
493
495
|
// Read-only: it inspects and reports, making no edits — so the no-progress
|
|
@@ -678,6 +680,7 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
678
680
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
679
681
|
ambientAuth: job.ambientAuth,
|
|
680
682
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
683
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
681
684
|
sessionToken: job.sessionToken,
|
|
682
685
|
// Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
|
|
683
686
|
expectsEdits: false,
|
|
@@ -780,6 +783,7 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
780
783
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
781
784
|
ambientAuth: job.ambientAuth,
|
|
782
785
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
786
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
783
787
|
sessionToken: job.sessionToken,
|
|
784
788
|
commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
|
|
785
789
|
webToolsGuidance: job.webToolsGuidance,
|
|
@@ -1032,6 +1036,7 @@ async function runConflictResolution(job, opts) {
|
|
|
1032
1036
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1033
1037
|
ambientAuth: job.ambientAuth,
|
|
1034
1038
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
1039
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
1035
1040
|
sessionToken: job.sessionToken,
|
|
1036
1041
|
contextFiles: job.contextFiles,
|
|
1037
1042
|
guardLimits: job.guardLimits,
|
package/dist/bootstrap-mode.js
CHANGED
|
@@ -52,6 +52,7 @@ export async function runBootstrap(job, opts) {
|
|
|
52
52
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
53
53
|
ambientAuth: job.ambientAuth,
|
|
54
54
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
55
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
55
56
|
sessionToken: job.sessionToken,
|
|
56
57
|
guardLimits: job.guardLimits,
|
|
57
58
|
...agentCapabilities(job),
|
package/dist/coding-agent.js
CHANGED
|
@@ -146,6 +146,7 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
146
146
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
147
147
|
ambientAuth: spec.ambientAuth,
|
|
148
148
|
proxyBaseUrl: spec.proxyBaseUrl,
|
|
149
|
+
proxyPhasePath: spec.proxyPhasePath,
|
|
149
150
|
sessionToken: spec.sessionToken,
|
|
150
151
|
serviceDirectory,
|
|
151
152
|
webToolsGuidance: spec.webToolsGuidance,
|
|
@@ -716,6 +717,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
716
717
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
717
718
|
ambientAuth: job.ambientAuth,
|
|
718
719
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
720
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
719
721
|
sessionToken: job.sessionToken,
|
|
720
722
|
webToolsGuidance: job.webToolsGuidance,
|
|
721
723
|
webSearchProxy: job.webSearch,
|
package/dist/job.js
CHANGED
|
@@ -96,6 +96,8 @@ function parseHarnessAuth(o) {
|
|
|
96
96
|
harness,
|
|
97
97
|
proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
|
|
98
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 } : {}),
|
|
99
101
|
};
|
|
100
102
|
}
|
|
101
103
|
/**
|
|
@@ -701,6 +703,8 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
701
703
|
*/
|
|
702
704
|
function collectOptionalRequestFields(o) {
|
|
703
705
|
return {
|
|
706
|
+
...(typeof o.workspaceId === 'string' && o.workspaceId ? { workspaceId: o.workspaceId } : {}),
|
|
707
|
+
...(typeof o.executionId === 'string' && o.executionId ? { executionId: o.executionId } : {}),
|
|
704
708
|
...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
|
|
705
709
|
...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
|
|
706
710
|
...(o.webSearch === true ? { webSearch: true } : {}),
|
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
|
|
@@ -221,7 +221,15 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
221
221
|
hasBlueprints,
|
|
222
222
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
223
223
|
});
|
|
224
|
-
|
|
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
|
+
});
|
|
225
233
|
const { signal, onActivity, onProgress, onSpan } = opts;
|
|
226
234
|
const piOutcome = await runPi({
|
|
227
235
|
cwd: spec.dir,
|
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');
|
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');
|
package/dist/server.js
CHANGED
|
@@ -65,6 +65,11 @@ describe) {
|
|
|
65
65
|
// image carries no runtime deps.
|
|
66
66
|
const KINDS = {
|
|
67
67
|
agent: defineKind(parseAgentJob, handleAgent, (job) => ({
|
|
68
|
+
// The backend's correlation ids first, so EVERY line this job emits joins to the run in the
|
|
69
|
+
// backend's own logs. Absent on a body from a backend older than the field — the rest of the
|
|
70
|
+
// description is unaffected.
|
|
71
|
+
...(job.workspaceId ? { workspaceId: job.workspaceId } : {}),
|
|
72
|
+
...(job.executionId ? { executionId: job.executionId } : {}),
|
|
68
73
|
mode: job.mode,
|
|
69
74
|
repo: `${job.repo.owner}/${job.repo.name}`,
|
|
70
75
|
branch: job.branch,
|
|
@@ -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.72.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.184.0",
|
|
30
|
+
"@cat-factory/server": "0.172.0",
|
|
31
|
+
"@cat-factory/spend": "0.12.113"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
package/src/agent.ts
CHANGED
|
@@ -255,6 +255,7 @@ async function resolveReplyCustom(
|
|
|
255
255
|
subscriptionToken: job.subscriptionToken,
|
|
256
256
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
257
257
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
258
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
258
259
|
sessionToken: job.sessionToken,
|
|
259
260
|
model: job.model,
|
|
260
261
|
jobId: job.jobId,
|
|
@@ -589,6 +590,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
589
590
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
590
591
|
ambientAuth: job.ambientAuth,
|
|
591
592
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
593
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
592
594
|
sessionToken: job.sessionToken,
|
|
593
595
|
serviceDirectory,
|
|
594
596
|
// Read-only: it inspects and reports, making no edits — so the no-progress
|
|
@@ -820,6 +822,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
820
822
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
821
823
|
ambientAuth: job.ambientAuth,
|
|
822
824
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
825
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
823
826
|
sessionToken: job.sessionToken,
|
|
824
827
|
// Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
|
|
825
828
|
expectsEdits: false,
|
|
@@ -938,6 +941,7 @@ function buildSingleRepoCodingSpec(
|
|
|
938
941
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
939
942
|
ambientAuth: job.ambientAuth,
|
|
940
943
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
944
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
941
945
|
sessionToken: job.sessionToken,
|
|
942
946
|
commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
|
|
943
947
|
webToolsGuidance: job.webToolsGuidance,
|
|
@@ -1217,6 +1221,7 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1217
1221
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1218
1222
|
ambientAuth: job.ambientAuth,
|
|
1219
1223
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
1224
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
1220
1225
|
sessionToken: job.sessionToken,
|
|
1221
1226
|
contextFiles: job.contextFiles,
|
|
1222
1227
|
guardLimits: job.guardLimits,
|
package/src/bootstrap-mode.ts
CHANGED
|
@@ -65,6 +65,7 @@ export async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
65
65
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
66
66
|
ambientAuth: job.ambientAuth,
|
|
67
67
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
68
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
68
69
|
sessionToken: job.sessionToken,
|
|
69
70
|
guardLimits: job.guardLimits,
|
|
70
71
|
...agentCapabilities(job),
|
package/src/coding-agent.ts
CHANGED
|
@@ -93,6 +93,8 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
93
93
|
webToolsGuidance?: string
|
|
94
94
|
/** Enable proxy-backed web search for this run (see {@link AgentRunSpec.webSearchProxy}). */
|
|
95
95
|
webSearchProxy?: boolean
|
|
96
|
+
/** Backend serves the phase-tagged completions route (see {@link AgentRunSpec.proxyPhasePath}). */
|
|
97
|
+
proxyPhasePath?: boolean
|
|
96
98
|
/** Per-knob progress-guard overrides (loosen-only), set per agent kind by the backend. */
|
|
97
99
|
guardLimits?: Partial<ProgressGuardLimits>
|
|
98
100
|
/**
|
|
@@ -352,6 +354,7 @@ export async function runCodingAgent(
|
|
|
352
354
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
353
355
|
ambientAuth: spec.ambientAuth,
|
|
354
356
|
proxyBaseUrl: spec.proxyBaseUrl,
|
|
357
|
+
proxyPhasePath: spec.proxyPhasePath,
|
|
355
358
|
sessionToken: spec.sessionToken,
|
|
356
359
|
serviceDirectory,
|
|
357
360
|
webToolsGuidance: spec.webToolsGuidance,
|
|
@@ -1052,6 +1055,7 @@ export async function runMultiRepoCoding(
|
|
|
1052
1055
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1053
1056
|
ambientAuth: job.ambientAuth,
|
|
1054
1057
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
1058
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
1055
1059
|
sessionToken: job.sessionToken,
|
|
1056
1060
|
webToolsGuidance: job.webToolsGuidance,
|
|
1057
1061
|
webSearchProxy: job.webSearch,
|
package/src/job.ts
CHANGED
|
@@ -38,6 +38,20 @@ export interface HarnessAuthFields {
|
|
|
38
38
|
harness?: HarnessKind
|
|
39
39
|
/** Worker LLM proxy base URL, including /v1 (Pi harness only). */
|
|
40
40
|
proxyBaseUrl?: string
|
|
41
|
+
/**
|
|
42
|
+
* The backend declaring that it serves the phase-tagged completions route
|
|
43
|
+
* (`${proxyBaseUrl}/phase/<phase>/chat/completions`), so this run may attribute each model
|
|
44
|
+
* call to the phase that spent it (`docs/initiatives/token-burn-instrumentation.md`). The
|
|
45
|
+
* same shape as {@link AgentJob.webSearch}: the backend states what IT serves, and the
|
|
46
|
+
* harness points Pi accordingly.
|
|
47
|
+
*
|
|
48
|
+
* Not a capability handshake — the harness never asks and never adapts to an answer. It
|
|
49
|
+
* exists because the harness image and the backend are only a matched set on the Cloudflare
|
|
50
|
+
* deployment: a runner pool pins its own image and `LOCAL_HARNESS_IMAGE` overrides the
|
|
51
|
+
* recommended pin, so an image ahead of its backend would otherwise 404 every model call.
|
|
52
|
+
* Absent ⇒ the plain path, and the run's calls are recorded as unattributed.
|
|
53
|
+
*/
|
|
54
|
+
proxyPhasePath?: boolean
|
|
41
55
|
/** Signed, model-locked proxy session token (Pi harness only). */
|
|
42
56
|
sessionToken?: string
|
|
43
57
|
/** Leased subscription credential (Claude Code OAuth token / Codex auth.json). */
|
|
@@ -222,6 +236,8 @@ function parseHarnessAuth(o: Record<string, unknown>): HarnessAuthFields {
|
|
|
222
236
|
harness,
|
|
223
237
|
proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
|
|
224
238
|
sessionToken: str(o.sessionToken, 'sessionToken'),
|
|
239
|
+
// Opt-IN, so a backend that doesn't serve the phase route (or predates it) is the default.
|
|
240
|
+
...(o.proxyPhasePath === true ? { proxyPhasePath: true } : {}),
|
|
225
241
|
}
|
|
226
242
|
}
|
|
227
243
|
|
|
@@ -687,6 +703,17 @@ export interface ValidationSpec {
|
|
|
687
703
|
|
|
688
704
|
export interface AgentJob extends HarnessAuthFields {
|
|
689
705
|
jobId: string
|
|
706
|
+
/**
|
|
707
|
+
* The backend run this job belongs to, bound onto the per-job logger beside `jobId` and used
|
|
708
|
+
* for NOTHING else. The container is the far side of the platform's longest seam: the backend
|
|
709
|
+
* knows a run as `executionId` and the harness knew it only as `jobId`, so a container log line
|
|
710
|
+
* could not be joined to the run that dispatched it except through the
|
|
711
|
+
* `${executionId}-${agentKind}` job-id naming convention. Optional because a body predating the
|
|
712
|
+
* field (or a hand-rolled acceptance fixture) must still run — an absent id costs correlation,
|
|
713
|
+
* never the job.
|
|
714
|
+
*/
|
|
715
|
+
workspaceId?: string
|
|
716
|
+
executionId?: string
|
|
690
717
|
mode: AgentMode
|
|
691
718
|
systemPrompt: string
|
|
692
719
|
userPrompt: string
|
|
@@ -1419,6 +1446,8 @@ function assembleAgentJob(
|
|
|
1419
1446
|
*/
|
|
1420
1447
|
function collectOptionalRequestFields(o: Record<string, unknown>): Partial<AgentJob> {
|
|
1421
1448
|
return {
|
|
1449
|
+
...(typeof o.workspaceId === 'string' && o.workspaceId ? { workspaceId: o.workspaceId } : {}),
|
|
1450
|
+
...(typeof o.executionId === 'string' && o.executionId ? { executionId: o.executionId } : {}),
|
|
1422
1451
|
...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
|
|
1423
1452
|
...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
|
|
1424
1453
|
...(o.webSearch === true ? { webSearch: true } : {}),
|
package/src/pi-workspace.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
CONTEXT_DIR,
|
|
14
14
|
materializeContextFiles,
|
|
15
15
|
materializeSkillResources,
|
|
16
|
+
phasedProxyBaseUrl,
|
|
16
17
|
runPi,
|
|
17
18
|
webSearchConfigFromEnv,
|
|
18
19
|
webSearchProxyEnv,
|
|
@@ -173,6 +174,12 @@ export interface AgentRunSpec {
|
|
|
173
174
|
ambientAuth?: boolean
|
|
174
175
|
/** Pi proxy base URL (Pi harness only). */
|
|
175
176
|
proxyBaseUrl?: string
|
|
177
|
+
/**
|
|
178
|
+
* The backend serves the phase-tagged completions route, so this pass may tag the URL it
|
|
179
|
+
* points Pi at with the phase it is running under (see {@link HarnessAuthFields.proxyPhasePath}
|
|
180
|
+
* and `phasedProxyBaseUrl`). Absent ⇒ the plain path.
|
|
181
|
+
*/
|
|
182
|
+
proxyPhasePath?: boolean
|
|
176
183
|
/** Pi proxy session token (Pi harness only). */
|
|
177
184
|
sessionToken?: string
|
|
178
185
|
/**
|
|
@@ -356,7 +363,15 @@ export async function runAgentInWorkspace(
|
|
|
356
363
|
hasBlueprints,
|
|
357
364
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
358
365
|
})
|
|
359
|
-
|
|
366
|
+
// Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
|
|
367
|
+
// the phase this pass runs under is carried on the URL it is pointed at. Resolved per pass
|
|
368
|
+
// (this whole function re-runs for every repair round), which is what makes a repair round's
|
|
369
|
+
// spend distinguishable from the first pass's. Only when the BACKEND said it serves that
|
|
370
|
+
// route, since a runner pool or `LOCAL_HARNESS_IMAGE` can pair this image with an older one.
|
|
371
|
+
await writePiModelsConfig({
|
|
372
|
+
model: spec.model,
|
|
373
|
+
proxyBaseUrl: phasedProxyBaseUrl(proxyBaseUrl, opts.currentPhase?.(), spec.proxyPhasePath),
|
|
374
|
+
})
|
|
360
375
|
const { signal, onActivity, onProgress, onSpan } = opts
|
|
361
376
|
const piOutcome = await runPi({
|
|
362
377
|
cwd: spec.dir,
|
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
|
|
@@ -548,6 +608,17 @@ export interface HarnessCallMetric {
|
|
|
548
608
|
* falls back to the array index, which is what it always used before streaming existed.
|
|
549
609
|
*/
|
|
550
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
|
|
551
622
|
}
|
|
552
623
|
|
|
553
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/server.ts
CHANGED
|
@@ -82,6 +82,11 @@ function defineKind<TJob extends { jobId: string }, TResult extends JobResultBas
|
|
|
82
82
|
// image carries no runtime deps.
|
|
83
83
|
const KINDS: Record<string, KindEntry> = {
|
|
84
84
|
agent: defineKind(parseAgentJob, handleAgent, (job) => ({
|
|
85
|
+
// The backend's correlation ids first, so EVERY line this job emits joins to the run in the
|
|
86
|
+
// backend's own logs. Absent on a body from a backend older than the field — the rest of the
|
|
87
|
+
// description is unaffected.
|
|
88
|
+
...(job.workspaceId ? { workspaceId: job.workspaceId } : {}),
|
|
89
|
+
...(job.executionId ? { executionId: job.executionId } : {}),
|
|
85
90
|
mode: job.mode,
|
|
86
91
|
repo: `${job.repo.owner}/${job.repo.name}`,
|
|
87
92
|
branch: job.branch,
|
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
|
{
|