@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/coding-agent.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import { killChildProcess, spawnDetached } from './process.js'
|
|
5
|
-
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
|
|
3
|
+
import { runCapturedCommand } from './captured-command.js'
|
|
6
4
|
import type {
|
|
7
5
|
AgentJob,
|
|
8
6
|
AgentResult,
|
|
@@ -11,6 +9,7 @@ import type {
|
|
|
11
9
|
ReferenceRepoSpec,
|
|
12
10
|
RepoSpec,
|
|
13
11
|
SkillSpec,
|
|
12
|
+
McpServerSpec,
|
|
14
13
|
} from './job.js'
|
|
15
14
|
import {
|
|
16
15
|
branchAheadOfBase,
|
|
@@ -94,6 +93,8 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
94
93
|
webToolsGuidance?: string
|
|
95
94
|
/** Enable proxy-backed web search for this run (see {@link AgentRunSpec.webSearchProxy}). */
|
|
96
95
|
webSearchProxy?: boolean
|
|
96
|
+
/** Backend serves the phase-tagged completions route (see {@link AgentRunSpec.proxyPhasePath}). */
|
|
97
|
+
proxyPhasePath?: boolean
|
|
97
98
|
/** Per-knob progress-guard overrides (loosen-only), set per agent kind by the backend. */
|
|
98
99
|
guardLimits?: Partial<ProgressGuardLimits>
|
|
99
100
|
/**
|
|
@@ -140,12 +141,18 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
140
141
|
*/
|
|
141
142
|
reproduction?: ReproductionSpec
|
|
142
143
|
/**
|
|
143
|
-
*
|
|
144
|
-
* into {@link runAgentInWorkspace}, which installs
|
|
145
|
-
* `CLAUDE_CONFIG_DIR` for a leased-credential
|
|
146
|
-
* else (Pi, codex, and ambient
|
|
144
|
+
* The skills to make available for this run — a `skill` step's pick and/or the running kind's
|
|
145
|
+
* declared playbooks. Threaded into {@link runAgentInWorkspace}, which installs them
|
|
146
|
+
* harness-aware: natively under the ISOLATED `CLAUDE_CONFIG_DIR` for a leased-credential
|
|
147
|
+
* claude-code run, `.cat-context/skill/<name>/` for everything else (Pi, codex, and ambient
|
|
148
|
+
* claude-code, which has no isolated config dir). Absent ⇒ no skills.
|
|
147
149
|
*/
|
|
148
|
-
|
|
150
|
+
skills?: SkillSpec[]
|
|
151
|
+
/**
|
|
152
|
+
* Tool servers (MCP) to wire into the agent CLI for this run. Forwarded verbatim — the backend
|
|
153
|
+
* has already dropped anything this harness cannot serve. Absent ⇒ built-in tools only.
|
|
154
|
+
*/
|
|
155
|
+
mcpServers?: McpServerSpec[]
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
/** The outcome of a coding agent run, before each caller maps it to its own result shape. */
|
|
@@ -179,6 +186,8 @@ export interface CodingAgentOutcome {
|
|
|
179
186
|
exitCode: number
|
|
180
187
|
validationOutputTail?: string
|
|
181
188
|
iteration?: number
|
|
189
|
+
/** The work-branch HEAD the command was judged against (absent when it could not be read). */
|
|
190
|
+
headSha?: string
|
|
182
191
|
}
|
|
183
192
|
/**
|
|
184
193
|
* The pre-PR validation loop's LAST attempt (present only when {@link CodingAgentSpec.validationChecks}
|
|
@@ -345,12 +354,14 @@ export async function runCodingAgent(
|
|
|
345
354
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
346
355
|
ambientAuth: spec.ambientAuth,
|
|
347
356
|
proxyBaseUrl: spec.proxyBaseUrl,
|
|
357
|
+
proxyPhasePath: spec.proxyPhasePath,
|
|
348
358
|
sessionToken: spec.sessionToken,
|
|
349
359
|
serviceDirectory,
|
|
350
360
|
webToolsGuidance: spec.webToolsGuidance,
|
|
351
361
|
webSearchProxy: spec.webSearchProxy,
|
|
352
362
|
guardLimits: spec.guardLimits,
|
|
353
|
-
...(spec.
|
|
363
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
364
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
354
365
|
},
|
|
355
366
|
opts,
|
|
356
367
|
)
|
|
@@ -718,7 +729,7 @@ async function finalizeCodingRun(args: {
|
|
|
718
729
|
// Runs regardless of whether this pass pushed — a no-op iteration must still be able
|
|
719
730
|
// to report that the criterion is (already) met. The harness runs it, never the model.
|
|
720
731
|
if (spec.validation) {
|
|
721
|
-
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
|
|
732
|
+
outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts)
|
|
722
733
|
}
|
|
723
734
|
// Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
|
|
724
735
|
// reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
|
|
@@ -796,22 +807,57 @@ function mergeAgentPasses<T extends Awaited<ReturnType<typeof runAgentInWorkspac
|
|
|
796
807
|
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
797
808
|
* Overridable via env for tests; defaults to 15 minutes.
|
|
798
809
|
*/
|
|
799
|
-
function ralphValidationTimeoutMs(): number {
|
|
810
|
+
export function ralphValidationTimeoutMs(): number {
|
|
800
811
|
const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS)
|
|
801
812
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
|
|
802
813
|
}
|
|
803
814
|
|
|
815
|
+
/**
|
|
816
|
+
* How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
|
|
817
|
+
* The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
|
|
818
|
+
* — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
|
|
819
|
+
* events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
|
|
820
|
+
* watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
|
|
821
|
+
* validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
|
|
822
|
+
* a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
|
|
823
|
+
* settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
|
|
824
|
+
* always fed it; this one did not. Overridable via env for tests.
|
|
825
|
+
*/
|
|
826
|
+
export function ralphHeartbeatMs(): number {
|
|
827
|
+
const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS)
|
|
828
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Bound on the validation output tail that crosses the wire. Deliberately smaller than
|
|
833
|
+
* `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
|
|
834
|
+
* the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
|
|
835
|
+
* log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
|
|
836
|
+
*/
|
|
837
|
+
export const RALPH_VALIDATION_TAIL_CHARS = 4_000
|
|
838
|
+
|
|
804
839
|
/**
|
|
805
840
|
* Ralph loop: run the programmatic completion command in the checkout and return its exit
|
|
806
|
-
* code
|
|
807
|
-
* done signal (0 = the criterion is met) — computed
|
|
808
|
-
* by the model, which is the whole point of a
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
841
|
+
* code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
|
|
842
|
+
* The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
|
|
843
|
+
* here by the harness, never self-reported by the model, which is the whole point of a
|
|
844
|
+
* programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
|
|
845
|
+
* trust boundary as the coding agent) — there is no host/backend execution.
|
|
846
|
+
*
|
|
847
|
+
* The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
|
|
848
|
+
* command, rather than the near-verbatim copy this used to be. That copy had drifted in two
|
|
849
|
+
* ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
|
|
850
|
+
* margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
|
|
851
|
+
* an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
|
|
852
|
+
* it published the full 16k capture where both siblings deliberately bound the wire tail.
|
|
853
|
+
*
|
|
854
|
+
* `headSha` is what lets the engine tell a loop that is iterating from one that is merely
|
|
855
|
+
* repeating: two consecutive failing iterations against an unchanged head means the agent
|
|
856
|
+
* committed nothing, and the loop is ended early instead of spending the rest of its budget.
|
|
857
|
+
* Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
|
|
813
858
|
*/
|
|
814
|
-
async function runRalphValidation(
|
|
859
|
+
export async function runRalphValidation(
|
|
860
|
+
repoDir: string,
|
|
815
861
|
cwd: string,
|
|
816
862
|
validation: { command: string; iteration?: number },
|
|
817
863
|
logger: Logger,
|
|
@@ -821,66 +867,50 @@ async function runRalphValidation(
|
|
|
821
867
|
exitCode: number
|
|
822
868
|
validationOutputTail?: string
|
|
823
869
|
iteration?: number
|
|
870
|
+
headSha?: string
|
|
824
871
|
}> {
|
|
825
|
-
const timeoutMs = ralphValidationTimeoutMs()
|
|
826
872
|
logger.info('coding-agent(ralph): running validation command', {
|
|
827
873
|
iteration: validation.iteration,
|
|
828
874
|
})
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
875
|
+
// Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
|
|
876
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs())
|
|
877
|
+
heartbeat.unref?.()
|
|
878
|
+
let captured
|
|
879
|
+
try {
|
|
880
|
+
captured = await runCapturedCommand({
|
|
833
881
|
cwd,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
882
|
+
command: validation.command,
|
|
883
|
+
timeoutMs: ralphValidationTimeoutMs(),
|
|
884
|
+
reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
|
|
885
|
+
logLabel: 'coding-agent(ralph): validation',
|
|
886
|
+
logFields: { iteration: validation.iteration },
|
|
887
|
+
logger,
|
|
888
|
+
opts,
|
|
840
889
|
})
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
opts.signal?.removeEventListener('abort', onAbort)
|
|
852
|
-
const trimmed = out.trim()
|
|
853
|
-
const tail = trimmed ? redactSecrets(trimmed) : undefined
|
|
854
|
-
logger.info('coding-agent(ralph): validation finished', {
|
|
855
|
-
exitCode,
|
|
856
|
-
iteration: validation.iteration,
|
|
857
|
-
})
|
|
858
|
-
resolve({
|
|
859
|
-
validationPassed: exitCode === 0,
|
|
860
|
-
exitCode,
|
|
861
|
-
...(tail ? { validationOutputTail: tail } : {}),
|
|
862
|
-
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
863
|
-
})
|
|
864
|
-
}
|
|
865
|
-
const timer = setTimeout(() => {
|
|
866
|
-
logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs })
|
|
867
|
-
killChildProcess(child, undefined, logger)
|
|
868
|
-
finish(124) // conventional timeout exit code (a non-zero fail)
|
|
869
|
-
}, timeoutMs)
|
|
870
|
-
timer.unref?.()
|
|
871
|
-
const onAbort = (): void => {
|
|
872
|
-
killChildProcess(child, undefined, logger)
|
|
873
|
-
finish(130) // aborted (a non-zero fail)
|
|
874
|
-
}
|
|
875
|
-
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
876
|
-
child.on('error', (err) => {
|
|
877
|
-
logger.warn('coding-agent(ralph): validation command failed to spawn', {
|
|
878
|
-
error: err instanceof Error ? err.message : String(err),
|
|
879
|
-
})
|
|
880
|
-
finish(127) // spawn error / command not found (a non-zero fail)
|
|
890
|
+
} finally {
|
|
891
|
+
clearInterval(heartbeat)
|
|
892
|
+
}
|
|
893
|
+
// The commit the criterion was judged against. Read AFTER the command so a validation that
|
|
894
|
+
// itself commits (a formatter check that rewrites files, say) is attributed to what it left.
|
|
895
|
+
// Best-effort: an unreadable head only costs the engine's no-progress guard, never the
|
|
896
|
+
// verdict — but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
|
|
897
|
+
const headSha = await headCommit(repoDir, opts.signal).catch((err: unknown) => {
|
|
898
|
+
logger.warn('coding-agent(ralph): could not read the work-branch head', {
|
|
899
|
+
error: err instanceof Error ? err.message : String(err),
|
|
881
900
|
})
|
|
882
|
-
|
|
901
|
+
return ''
|
|
902
|
+
})
|
|
903
|
+
logger.info('coding-agent(ralph): validation finished', {
|
|
904
|
+
exitCode: captured.exitCode,
|
|
905
|
+
iteration: validation.iteration,
|
|
883
906
|
})
|
|
907
|
+
return {
|
|
908
|
+
validationPassed: captured.passed,
|
|
909
|
+
exitCode: captured.exitCode,
|
|
910
|
+
...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
|
|
911
|
+
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
912
|
+
...(headSha ? { headSha } : {}),
|
|
913
|
+
}
|
|
884
914
|
}
|
|
885
915
|
|
|
886
916
|
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
@@ -1025,11 +1055,16 @@ export async function runMultiRepoCoding(
|
|
|
1025
1055
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1026
1056
|
ambientAuth: job.ambientAuth,
|
|
1027
1057
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
1058
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
1028
1059
|
sessionToken: job.sessionToken,
|
|
1029
1060
|
webToolsGuidance: job.webToolsGuidance,
|
|
1030
1061
|
webSearchProxy: job.webSearch,
|
|
1031
1062
|
guardLimits: job.guardLimits,
|
|
1032
1063
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
1064
|
+
// Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
|
|
1065
|
+
// are properties of the AGENT KIND, not of the checkout layout.
|
|
1066
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
1067
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
1033
1068
|
multiRepo: true,
|
|
1034
1069
|
},
|
|
1035
1070
|
opts,
|
package/src/job.ts
CHANGED
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
type ReproductionReport,
|
|
13
13
|
type ReproductionSpec,
|
|
14
14
|
} from './reproduction-proof.js'
|
|
15
|
+
import {
|
|
16
|
+
parseMcpServerSpecs,
|
|
17
|
+
parseSkillSpecs,
|
|
18
|
+
type McpServerSpec,
|
|
19
|
+
type SkillResourceSpec,
|
|
20
|
+
type SkillSpec,
|
|
21
|
+
} from './agent-capabilities.js'
|
|
22
|
+
|
|
23
|
+
// Re-exported so the job body stays the one import site for a harness handler describing a job.
|
|
24
|
+
export type { McpServerSpec, SkillResourceSpec, SkillSpec }
|
|
15
25
|
|
|
16
26
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
17
27
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
@@ -28,6 +38,20 @@ export interface HarnessAuthFields {
|
|
|
28
38
|
harness?: HarnessKind
|
|
29
39
|
/** Worker LLM proxy base URL, including /v1 (Pi harness only). */
|
|
30
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
|
|
31
55
|
/** Signed, model-locked proxy session token (Pi harness only). */
|
|
32
56
|
sessionToken?: string
|
|
33
57
|
/** Leased subscription credential (Claude Code OAuth token / Codex auth.json). */
|
|
@@ -212,6 +236,8 @@ function parseHarnessAuth(o: Record<string, unknown>): HarnessAuthFields {
|
|
|
212
236
|
harness,
|
|
213
237
|
proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
|
|
214
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 } : {}),
|
|
215
241
|
}
|
|
216
242
|
}
|
|
217
243
|
|
|
@@ -632,26 +658,6 @@ export interface ContextFileSpec {
|
|
|
632
658
|
content: string
|
|
633
659
|
}
|
|
634
660
|
|
|
635
|
-
/** One materialisable resource file of a skill (repo-sourced Claude Skills). */
|
|
636
|
-
export interface SkillResourceSpec {
|
|
637
|
-
/** Path within the skill directory, e.g. `templates/report.md` (subdirs preserved, no traversal). */
|
|
638
|
-
relPath: string
|
|
639
|
-
content: string
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* A repo-sourced Claude Skill to make available for a `skill` step. Materialised HARNESS-AWARE:
|
|
644
|
-
* `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resources) for the claude-code CLI to load
|
|
645
|
-
* natively, or `.cat-context/skill/<relPath>` for the Pi/codex checkout (their prompt carries the
|
|
646
|
-
* instructions). A dedicated top-level body field (like `packageRegistries`), never a context file.
|
|
647
|
-
*/
|
|
648
|
-
export interface SkillSpec {
|
|
649
|
-
name: string
|
|
650
|
-
description: string
|
|
651
|
-
instructions: string
|
|
652
|
-
resources: SkillResourceSpec[]
|
|
653
|
-
}
|
|
654
|
-
|
|
655
661
|
/** How an explore agent's reply is consumed. */
|
|
656
662
|
export interface AgentOutputSpec {
|
|
657
663
|
/** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
|
|
@@ -739,11 +745,20 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
739
745
|
*/
|
|
740
746
|
packageRegistries?: PackageRegistrySpec[]
|
|
741
747
|
/**
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
748
|
+
* The skills to make available for this run (see {@link SkillSpec}) — a `skill` step's picked
|
|
749
|
+
* skill and/or the playbooks the running agent kind declares. Materialised harness-aware before
|
|
750
|
+
* the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/` for claude-code, or
|
|
751
|
+
* `.cat-context/skill/<name>/<relPath>` for Pi/codex. Absent ⇒ no skills installed.
|
|
752
|
+
*/
|
|
753
|
+
skills?: SkillSpec[]
|
|
754
|
+
/**
|
|
755
|
+
* Tool servers (MCP) to wire into the agent CLI for this run (see {@link McpServerSpec}). The
|
|
756
|
+
* backend has already dropped anything this harness cannot serve, so every entry here is
|
|
757
|
+
* expected to work. SECRET-BEARING (`env`/`headers` carry resolved credentials), so the config
|
|
758
|
+
* files written from it live outside the checkout and are never logged. Absent ⇒ the CLI's
|
|
759
|
+
* built-in tools only.
|
|
745
760
|
*/
|
|
746
|
-
|
|
761
|
+
mcpServers?: McpServerSpec[]
|
|
747
762
|
/**
|
|
748
763
|
* Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
|
|
749
764
|
* band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
|
|
@@ -942,6 +957,12 @@ export interface AgentResult {
|
|
|
942
957
|
exitCode: number
|
|
943
958
|
validationOutputTail?: string
|
|
944
959
|
iteration?: number
|
|
960
|
+
/**
|
|
961
|
+
* The work-branch HEAD the command was judged against. The engine compares it across
|
|
962
|
+
* consecutive failing iterations to end a loop that has stopped committing anything,
|
|
963
|
+
* instead of spending the rest of its budget re-learning that. Absent when unreadable.
|
|
964
|
+
*/
|
|
965
|
+
headSha?: string
|
|
945
966
|
}
|
|
946
967
|
/**
|
|
947
968
|
* Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
|
|
@@ -1029,71 +1050,6 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
|
|
|
1029
1050
|
return files
|
|
1030
1051
|
}
|
|
1031
1052
|
|
|
1032
|
-
/**
|
|
1033
|
-
* Sanitize a skill resource's relative path: keep the subdirectory structure (so
|
|
1034
|
-
* `templates/report.md` materialises nested) but reject anything that could escape the skill
|
|
1035
|
-
* directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
|
|
1036
|
-
* for an unsafe path (the resource is then dropped).
|
|
1037
|
-
*/
|
|
1038
|
-
function sanitizeSkillRelPath(value: unknown): string | undefined {
|
|
1039
|
-
if (typeof value !== 'string') return undefined
|
|
1040
|
-
const segments = value.replace(/\\/g, '/').split('/')
|
|
1041
|
-
const clean: string[] = []
|
|
1042
|
-
for (const seg of segments) {
|
|
1043
|
-
if (seg === '' || seg === '.') continue
|
|
1044
|
-
if (seg === '..') return undefined
|
|
1045
|
-
// Same character class as a context-file name, per segment.
|
|
1046
|
-
const c = seg.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1047
|
-
if (!c || c === '.' || c === '..' || c.startsWith('.')) return undefined
|
|
1048
|
-
clean.push(c)
|
|
1049
|
-
}
|
|
1050
|
-
return clean.length ? clean.join('/') : undefined
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
/**
|
|
1054
|
-
* Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
|
|
1055
|
-
* purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
|
|
1056
|
-
* default keeps the skill installable rather than dropping it — which, on the claude-code path,
|
|
1057
|
-
* would leave the prompt pointing at a skill that was never installed (a blind run).
|
|
1058
|
-
*/
|
|
1059
|
-
const FALLBACK_SKILL_NAME = 'skill'
|
|
1060
|
-
|
|
1061
|
-
/** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
|
|
1062
|
-
function sanitizeSkillName(value: unknown): string | undefined {
|
|
1063
|
-
if (typeof value !== 'string') return undefined
|
|
1064
|
-
const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
1065
|
-
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1066
|
-
if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
|
|
1067
|
-
return cleaned
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/** Validate the optional `skill` field, or undefined when absent/malformed. */
|
|
1071
|
-
function parseSkillSpec(value: unknown): SkillSpec | undefined {
|
|
1072
|
-
if (typeof value !== 'object' || value === null) return undefined
|
|
1073
|
-
const o = value as Record<string, unknown>
|
|
1074
|
-
const instructions = typeof o.instructions === 'string' ? o.instructions : undefined
|
|
1075
|
-
// No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
|
|
1076
|
-
// folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
|
|
1077
|
-
// directory, so fall back to a safe default rather than dropping the whole skill.
|
|
1078
|
-
if (!instructions) return undefined
|
|
1079
|
-
const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME
|
|
1080
|
-
const description = typeof o.description === 'string' ? o.description : ''
|
|
1081
|
-
const resources: SkillResourceSpec[] = []
|
|
1082
|
-
if (Array.isArray(o.resources)) {
|
|
1083
|
-
const used = new Set<string>()
|
|
1084
|
-
for (const entry of o.resources) {
|
|
1085
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
1086
|
-
const e = entry as Record<string, unknown>
|
|
1087
|
-
const relPath = sanitizeSkillRelPath(e.relPath)
|
|
1088
|
-
if (!relPath || used.has(relPath)) continue
|
|
1089
|
-
if (typeof e.content !== 'string') continue
|
|
1090
|
-
used.add(relPath)
|
|
1091
|
-
resources.push({ relPath, content: e.content })
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
return { name, description, instructions, resources }
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
1053
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
1098
1054
|
function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
|
|
1099
1055
|
if (typeof value !== 'object' || value === null) return undefined
|
|
@@ -1335,7 +1291,8 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1335
1291
|
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
1336
1292
|
contextFiles: parseContextFiles(o.contextFiles),
|
|
1337
1293
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1338
|
-
|
|
1294
|
+
skills: parseSkillSpecs(o.skills),
|
|
1295
|
+
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
1339
1296
|
testSecrets: parseTestSecrets(o.testSecrets),
|
|
1340
1297
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1341
1298
|
validation: parseValidationSpec(o.validation),
|
|
@@ -1374,7 +1331,8 @@ interface ParsedAgentJobParts {
|
|
|
1374
1331
|
bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
|
|
1375
1332
|
contextFiles: ReturnType<typeof parseContextFiles>
|
|
1376
1333
|
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1377
|
-
|
|
1334
|
+
skills: ReturnType<typeof parseSkillSpecs>
|
|
1335
|
+
mcpServers: ReturnType<typeof parseMcpServerSpecs>
|
|
1378
1336
|
testSecrets: ReturnType<typeof parseTestSecrets>
|
|
1379
1337
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1380
1338
|
validation: ReturnType<typeof parseValidationSpec>
|
|
@@ -1428,7 +1386,8 @@ function assembleAgentJob(
|
|
|
1428
1386
|
bootstrap,
|
|
1429
1387
|
contextFiles,
|
|
1430
1388
|
packageRegistries,
|
|
1431
|
-
|
|
1389
|
+
skills,
|
|
1390
|
+
mcpServers,
|
|
1432
1391
|
testSecrets,
|
|
1433
1392
|
guardLimits,
|
|
1434
1393
|
validation,
|
|
@@ -1452,7 +1411,8 @@ function assembleAgentJob(
|
|
|
1452
1411
|
...(output ? { output } : {}),
|
|
1453
1412
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
1454
1413
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
1455
|
-
...(
|
|
1414
|
+
...(skills ? { skills } : {}),
|
|
1415
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
1456
1416
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
1457
1417
|
...(infra ? { infra } : {}),
|
|
1458
1418
|
...(pr ? { pr } : {}),
|
package/src/pi-workspace.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import type { RepoSpec
|
|
4
|
+
import type { RepoSpec } from './job.js'
|
|
5
|
+
import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
|
|
5
6
|
import { readEffortReport } from './effort.js'
|
|
6
7
|
import { log } from './logger.js'
|
|
7
8
|
import {
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
CONTEXT_DIR,
|
|
13
14
|
materializeContextFiles,
|
|
14
15
|
materializeSkillResources,
|
|
16
|
+
phasedProxyBaseUrl,
|
|
15
17
|
runPi,
|
|
16
18
|
webSearchConfigFromEnv,
|
|
17
19
|
webSearchProxyEnv,
|
|
@@ -172,6 +174,12 @@ export interface AgentRunSpec {
|
|
|
172
174
|
ambientAuth?: boolean
|
|
173
175
|
/** Pi proxy base URL (Pi harness only). */
|
|
174
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
|
|
175
183
|
/** Pi proxy session token (Pi harness only). */
|
|
176
184
|
sessionToken?: string
|
|
177
185
|
/**
|
|
@@ -206,12 +214,20 @@ export interface AgentRunSpec {
|
|
|
206
214
|
*/
|
|
207
215
|
contextFiles?: ContextFileInfo[]
|
|
208
216
|
/**
|
|
209
|
-
*
|
|
210
|
-
* the
|
|
211
|
-
*
|
|
212
|
-
* folded-in instructions).
|
|
217
|
+
* The skills to make available for this run — a `skill` step's picked skill and/or the playbooks
|
|
218
|
+
* the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
|
|
219
|
+
* natively into the config dir's `skills/`; for Pi/codex the resource files are materialised
|
|
220
|
+
* under `.cat-context/skill/<name>/` (their prompt already carries the folded-in instructions).
|
|
221
|
+
* Absent ⇒ no skills.
|
|
222
|
+
*/
|
|
223
|
+
skills?: SkillSpec[]
|
|
224
|
+
/**
|
|
225
|
+
* Tool servers (MCP) to wire into the agent CLI. Served by the subscription harnesses only —
|
|
226
|
+
* Pi has no MCP client, and the BACKEND is what decides that (it drops an unservable server and
|
|
227
|
+
* tells the agent so), which is why this path simply forwards whatever it is given rather than
|
|
228
|
+
* re-deciding. Absent ⇒ the CLI's built-in tools only.
|
|
213
229
|
*/
|
|
214
|
-
|
|
230
|
+
mcpServers?: McpServerSpec[]
|
|
215
231
|
/**
|
|
216
232
|
* Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
|
|
217
233
|
* backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
|
|
@@ -268,14 +284,14 @@ export async function runAgentInWorkspace(
|
|
|
268
284
|
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
269
285
|
const contextFiles = spec.contextFiles ?? []
|
|
270
286
|
await materializeContextFiles(spec.dir, contextFiles)
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
if (spec.
|
|
278
|
-
await materializeSkillResources(spec.dir, spec.
|
|
287
|
+
// Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
|
|
288
|
+
// there. Everything else reads the checkout, so materialise each skill's resources under
|
|
289
|
+
// `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
|
|
290
|
+
// Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
|
|
291
|
+
// runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
|
|
292
|
+
// Resource-free skills are a no-op here.
|
|
293
|
+
if (spec.skills?.length && !installsSkillNatively(spec)) {
|
|
294
|
+
await materializeSkillResources(spec.dir, spec.skills)
|
|
279
295
|
}
|
|
280
296
|
|
|
281
297
|
// Subscription harnesses (Claude Code / Codex) authenticate with the leased
|
|
@@ -296,7 +312,8 @@ export async function runAgentInWorkspace(
|
|
|
296
312
|
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
297
313
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
298
314
|
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
299
|
-
...(spec.
|
|
315
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
316
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
300
317
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
301
318
|
signal: opts.signal,
|
|
302
319
|
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
@@ -346,7 +363,15 @@ export async function runAgentInWorkspace(
|
|
|
346
363
|
hasBlueprints,
|
|
347
364
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
348
365
|
})
|
|
349
|
-
|
|
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
|
+
})
|
|
350
375
|
const { signal, onActivity, onProgress, onSpan } = opts
|
|
351
376
|
const piOutcome = await runPi({
|
|
352
377
|
cwd: spec.dir,
|
|
@@ -367,12 +392,12 @@ export async function runAgentInWorkspace(
|
|
|
367
392
|
}
|
|
368
393
|
|
|
369
394
|
/**
|
|
370
|
-
* Whether the claude-code runner will install this run's
|
|
371
|
-
*
|
|
395
|
+
* Whether the claude-code runner will install this run's skills natively (into the CLI's config
|
|
396
|
+
* dir) rather than the caller materialising them into the checkout. True ONLY for a
|
|
372
397
|
* leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
|
|
373
|
-
* uses the developer's own `~/.claude`, which the runner will not write a
|
|
374
|
-
*
|
|
375
|
-
*
|
|
398
|
+
* uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
|
|
399
|
+
* outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
|
|
400
|
+
* would overwrite each other's.
|
|
376
401
|
*/
|
|
377
402
|
export function installsSkillNatively(
|
|
378
403
|
spec: Pick<AgentRunSpec, 'harness' | 'ambientAuth'>,
|