@cat-factory/executor-harness 1.64.4 → 1.68.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 +33 -0
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +111 -25
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +9 -139
- package/dist/bootstrap-mode.js +141 -0
- package/dist/claude-call-aggregator.js +6 -3
- package/dist/claude-stream.js +12 -6
- package/dist/coding-agent.js +85 -67
- package/dist/inline.js +29 -1
- package/dist/job.js +6 -75
- package/dist/pi-workspace.js +15 -14
- package/dist/pi.js +24 -16
- package/dist/subagents.js +14 -3
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +155 -44
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +8 -165
- package/src/bootstrap-mode.ts +174 -0
- package/src/claude-call-aggregator.ts +8 -4
- package/src/claude-stream.ts +15 -7
- package/src/coding-agent.ts +102 -71
- package/src/inline.ts +34 -1
- package/src/job.ts +51 -94
- package/src/pi-workspace.ts +30 -20
- package/src/pi.ts +38 -17
- package/src/subagents.ts +17 -3
package/src/agent-runner.ts
CHANGED
|
@@ -14,9 +14,17 @@ import {
|
|
|
14
14
|
type PiRunStats,
|
|
15
15
|
type TodoProgress,
|
|
16
16
|
} from './pi.js'
|
|
17
|
+
import {
|
|
18
|
+
claudeAllowedToolPatterns,
|
|
19
|
+
codexMcpConfigToml,
|
|
20
|
+
mcpServerSecretValues,
|
|
21
|
+
writeClaudeMcpConfig,
|
|
22
|
+
type McpServerSpec,
|
|
23
|
+
type SkillSpec,
|
|
24
|
+
} from './agent-capabilities.js'
|
|
17
25
|
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
18
26
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
19
|
-
import { redact, secretsToRedact } from './redact.js'
|
|
27
|
+
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
|
|
20
28
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
21
29
|
import {
|
|
22
30
|
createTaskPlanTracker,
|
|
@@ -76,18 +84,19 @@ export interface SubscriptionRunOptions {
|
|
|
76
84
|
*/
|
|
77
85
|
ambientAuth?: boolean
|
|
78
86
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
87
|
+
* The skills to install natively before launch. The claude-code runner writes each to
|
|
88
|
+
* `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resource files) so the CLI loads them — but ONLY
|
|
89
|
+
* when it owns an isolated config home, i.e. NOT under `ambientAuth`. The codex runner ignores
|
|
90
|
+
* them outright. Every case that skips the native install reads the checkout's
|
|
91
|
+
* `.cat-context/skill/<name>/`, materialised by the caller.
|
|
84
92
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
skills?: SkillSpec[]
|
|
94
|
+
/**
|
|
95
|
+
* Tool servers (MCP) to wire into the CLI for this run. Written to a PER-RUN config the CLI is
|
|
96
|
+
* pointed at — never a HOME-global one, which a second concurrent job would clobber and which
|
|
97
|
+
* carries this job's credentials. Absent ⇒ the CLI's built-in tools only.
|
|
98
|
+
*/
|
|
99
|
+
mcpServers?: McpServerSpec[]
|
|
91
100
|
/**
|
|
92
101
|
* Extra environment for the CLI child, scoped to this job (the tester's secrets, a
|
|
93
102
|
* private-registry npmrc pointer). Merged over the inherited `process.env` at spawn, so the
|
|
@@ -320,10 +329,7 @@ export function carryClaudeSystemPrompt(
|
|
|
320
329
|
* would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
|
|
321
330
|
* valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
|
|
322
331
|
*/
|
|
323
|
-
async function writeNativeSkill(
|
|
324
|
-
skillsRoot: string,
|
|
325
|
-
skill: NonNullable<SubscriptionRunOptions['skill']>,
|
|
326
|
-
): Promise<void> {
|
|
332
|
+
async function writeNativeSkill(skillsRoot: string, skill: SkillSpec): Promise<void> {
|
|
327
333
|
const dir = join(skillsRoot, skill.name)
|
|
328
334
|
await mkdir(dir, { recursive: true })
|
|
329
335
|
const name = JSON.stringify(skill.name)
|
|
@@ -337,6 +343,49 @@ async function writeNativeSkill(
|
|
|
337
343
|
}
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
|
|
348
|
+
* return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
|
|
349
|
+
*
|
|
350
|
+
* Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
|
|
351
|
+
* ambient run on a developer's own machine can never silently hand the agent their personal ones.
|
|
352
|
+
* And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
|
|
353
|
+
* whole-session, not MCP-scoped, so `claudeAllowedToolPatterns` re-grants the CLI's built-in
|
|
354
|
+
* file/bash tools in the same list; see it for why that holds whichever way the run's permission
|
|
355
|
+
* mode treats an allow-list.
|
|
356
|
+
*
|
|
357
|
+
* The config carries this job's resolved credentials, so it goes in the isolated config home when
|
|
358
|
+
* we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
|
|
359
|
+
* commit) and never a shared HOME path (a concurrent job would clobber it).
|
|
360
|
+
*/
|
|
361
|
+
async function setUpClaudeMcp(
|
|
362
|
+
servers: McpServerSpec[] | undefined,
|
|
363
|
+
configHome: string | undefined,
|
|
364
|
+
): Promise<{ args: string[]; cleanup: () => Promise<void> }> {
|
|
365
|
+
const noop = { args: [], cleanup: async () => {} }
|
|
366
|
+
if (!servers?.length) return noop
|
|
367
|
+
// Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
|
|
368
|
+
// that tail is carried onto the step's diagnostics.
|
|
369
|
+
registerKnownSecrets(mcpServerSecretValues(servers))
|
|
370
|
+
const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')))
|
|
371
|
+
const owned = home === configHome ? undefined : home
|
|
372
|
+
const cleanup = async (): Promise<void> => {
|
|
373
|
+
if (owned) await rm(owned, { recursive: true, force: true }).catch(() => {})
|
|
374
|
+
}
|
|
375
|
+
const configPath = await writeClaudeMcpConfig(home, servers)
|
|
376
|
+
if (!configPath) return { args: [], cleanup }
|
|
377
|
+
const allowedTools = claudeAllowedToolPatterns(servers)
|
|
378
|
+
return {
|
|
379
|
+
args: [
|
|
380
|
+
'--mcp-config',
|
|
381
|
+
configPath,
|
|
382
|
+
'--strict-mcp-config',
|
|
383
|
+
...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
|
|
384
|
+
],
|
|
385
|
+
cleanup,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
340
389
|
export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
341
390
|
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
342
391
|
let summary = ''
|
|
@@ -518,17 +567,23 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
518
567
|
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
519
568
|
}
|
|
520
569
|
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
if (
|
|
529
|
-
|
|
570
|
+
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
571
|
+
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
572
|
+
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
573
|
+
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
574
|
+
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
575
|
+
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
576
|
+
// still names the skills.
|
|
577
|
+
if (configHome) {
|
|
578
|
+
for (const skill of opts.skills ?? []) {
|
|
579
|
+
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
|
|
580
|
+
}
|
|
530
581
|
}
|
|
531
582
|
|
|
583
|
+
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
584
|
+
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
585
|
+
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome)
|
|
586
|
+
|
|
532
587
|
const env = buildClaudeEnv(opts, configHome)
|
|
533
588
|
|
|
534
589
|
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
@@ -572,6 +627,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
572
627
|
'bypassPermissions',
|
|
573
628
|
'--model',
|
|
574
629
|
opts.model,
|
|
630
|
+
...mcp.args,
|
|
575
631
|
...appendArgs,
|
|
576
632
|
],
|
|
577
633
|
},
|
|
@@ -613,6 +669,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
613
669
|
throw err
|
|
614
670
|
} finally {
|
|
615
671
|
await subagents?.stop()
|
|
672
|
+
// The ambient-mode MCP config dir (credential-bearing) never outlives the run.
|
|
673
|
+
await mcp.cleanup()
|
|
616
674
|
if (configHome) {
|
|
617
675
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
618
676
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
@@ -747,7 +805,11 @@ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number
|
|
|
747
805
|
export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
748
806
|
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
749
807
|
let summary = ''
|
|
750
|
-
|
|
808
|
+
// The running CUMULATIVE total, kept in its reported (inclusive) form plus the cached share
|
|
809
|
+
// it contains. `PiRunOutcome.usage` needs the inclusive figure — it is the key-rotation
|
|
810
|
+
// weight — while the fallback call metric below needs the split, so both are derived from
|
|
811
|
+
// this one value rather than one being reconstructed from the other.
|
|
812
|
+
let cumulative: CodexCumulativeUsage | undefined
|
|
751
813
|
|
|
752
814
|
// Codex reads its credentials from $CODEX_HOME/auth.json with file-backed
|
|
753
815
|
// storage. CRITICAL: this home must live OUTSIDE the cloned checkout (`opts.cwd`)
|
|
@@ -775,7 +837,20 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
775
837
|
const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'))
|
|
776
838
|
if (codexHome) {
|
|
777
839
|
await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
|
|
778
|
-
|
|
840
|
+
// Tool servers (MCP) ride the SAME per-run config.toml, so they are scoped to this job and
|
|
841
|
+
// torn down with the home. Under AMBIENT auth there is no per-run home — and writing servers
|
|
842
|
+
// into the developer's own `~/.codex/config.toml` would outlive the run and race a concurrent
|
|
843
|
+
// job — so an ambient codex run gets no MCP servers; the backend states them as unavailable
|
|
844
|
+
// the same way it does for a harness with no MCP client at all.
|
|
845
|
+
// Registered before the CLI starts, for the same reason the claude path does it: a server that
|
|
846
|
+
// fails to launch puts its own command line into the stderr tail we keep.
|
|
847
|
+
if (opts.mcpServers?.length) registerKnownSecrets(mcpServerSecretValues(opts.mcpServers))
|
|
848
|
+
const mcpToml = opts.mcpServers?.length ? codexMcpConfigToml(opts.mcpServers) : ''
|
|
849
|
+
await writeFile(
|
|
850
|
+
join(codexHome, 'config.toml'),
|
|
851
|
+
`cli_auth_credentials_store = "file"\n${mcpToml ? `\n${mcpToml}` : ''}`,
|
|
852
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
853
|
+
)
|
|
779
854
|
}
|
|
780
855
|
|
|
781
856
|
// Codex has no system-prompt flag, so fold the composed role + best-practice
|
|
@@ -812,7 +887,7 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
812
887
|
const progress = codexPlanProgress(event)
|
|
813
888
|
if (progress && opts.onProgress) opts.onProgress(progress)
|
|
814
889
|
const turnUsage = codexUsage(event)
|
|
815
|
-
if (turnUsage)
|
|
890
|
+
if (turnUsage) cumulative = turnUsage
|
|
816
891
|
// A `token_count` event closes a model turn: pair its per-turn usage with the
|
|
817
892
|
// assistant text seen since the previous turn as one telemetry call.
|
|
818
893
|
const perTurn = codexLastTurnUsage(event)
|
|
@@ -826,7 +901,8 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
826
901
|
responseText: redactBody(pendingText, secrets),
|
|
827
902
|
reasoningText: '',
|
|
828
903
|
inputTokens: perTurn.inputTokens,
|
|
829
|
-
|
|
904
|
+
cacheReadTokens: perTurn.cacheReadTokens,
|
|
905
|
+
cacheWriteTokens: perTurn.cacheWriteTokens,
|
|
830
906
|
outputTokens: perTurn.outputTokens,
|
|
831
907
|
finishReason: null,
|
|
832
908
|
},
|
|
@@ -862,7 +938,11 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
862
938
|
|
|
863
939
|
// Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
|
|
864
940
|
// single call from the cumulative total + final text so the run is still observable.
|
|
865
|
-
|
|
941
|
+
// The cumulative total is inclusive of its cached share exactly as a per-turn one is, so
|
|
942
|
+
// it is split the same way rather than being filed wholesale as fresh — which would report
|
|
943
|
+
// a cache-heavy run as if nothing had been cached, the one reading this telemetry exists
|
|
944
|
+
// to rule out.
|
|
945
|
+
if (calls.length === 0 && (cumulative || summary)) {
|
|
866
946
|
publishCallMetric(
|
|
867
947
|
calls,
|
|
868
948
|
{
|
|
@@ -871,14 +951,23 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
871
951
|
messageCount: messages.length,
|
|
872
952
|
responseText: redactBody(summary, secrets),
|
|
873
953
|
reasoningText: '',
|
|
874
|
-
inputTokens:
|
|
875
|
-
|
|
876
|
-
|
|
954
|
+
inputTokens: Math.max(
|
|
955
|
+
0,
|
|
956
|
+
(cumulative?.inputTokens ?? 0) - (cumulative?.cachedInputTokens ?? 0),
|
|
957
|
+
),
|
|
958
|
+
cacheReadTokens: cumulative?.cachedInputTokens ?? 0,
|
|
959
|
+
// Codex reports no separate cache-WRITE class; 0 rather than guessed.
|
|
960
|
+
cacheWriteTokens: 0,
|
|
961
|
+
outputTokens: cumulative?.outputTokens ?? 0,
|
|
877
962
|
finishReason: null,
|
|
878
963
|
},
|
|
879
964
|
opts.onCallMetric,
|
|
880
965
|
)
|
|
881
966
|
}
|
|
967
|
+
// The outcome's usage is the key-rotation WEIGHT, so it keeps the inclusive input count.
|
|
968
|
+
const usage = cumulative
|
|
969
|
+
? { inputTokens: cumulative.inputTokens, outputTokens: cumulative.outputTokens }
|
|
970
|
+
: undefined
|
|
882
971
|
return {
|
|
883
972
|
summary,
|
|
884
973
|
stats,
|
|
@@ -949,6 +1038,19 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
|
|
|
949
1038
|
return toProgress(items)
|
|
950
1039
|
}
|
|
951
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* Codex's running cumulative usage, kept in the form the CLI reports it: `inputTokens` is the
|
|
1043
|
+
* TOTAL prompt count (OpenAI semantics) with `cachedInputTokens` a SUBSET already inside it,
|
|
1044
|
+
* never a bucket to add on top. The cached share is carried rather than discarded so a
|
|
1045
|
+
* consumer that needs the fresh figure can subtract it at the point of use, instead of the
|
|
1046
|
+
* only two readings of this number being "inclusive" and "lost".
|
|
1047
|
+
*/
|
|
1048
|
+
interface CodexCumulativeUsage {
|
|
1049
|
+
inputTokens: number
|
|
1050
|
+
cachedInputTokens: number
|
|
1051
|
+
outputTokens: number
|
|
1052
|
+
}
|
|
1053
|
+
|
|
952
1054
|
/**
|
|
953
1055
|
* Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
|
|
954
1056
|
* reports a running CUMULATIVE total on `token_count` events under
|
|
@@ -956,12 +1058,8 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
|
|
|
956
1058
|
* other shapes put it on `usage` / `info.usage` directly. We read the cumulative
|
|
957
1059
|
* total when present so the caller can simply overwrite (not sum) — summing
|
|
958
1060
|
* cumulative totals across events would multiply-count. Checked most-likely first.
|
|
959
|
-
* `input_tokens` is the TOTAL prompt count (OpenAI semantics: `cached_input_tokens`
|
|
960
|
-
* is a subset already inside it), so it is NOT summed with the cached share.
|
|
961
1061
|
*/
|
|
962
|
-
function codexUsage(
|
|
963
|
-
event: Record<string, unknown>,
|
|
964
|
-
): { inputTokens: number; outputTokens: number } | undefined {
|
|
1062
|
+
function codexUsage(event: Record<string, unknown>): CodexCumulativeUsage | undefined {
|
|
965
1063
|
const info = isObject(event.info) ? (event.info as Record<string, unknown>) : undefined
|
|
966
1064
|
const raw =
|
|
967
1065
|
(info && isObject(info.total_token_usage) ? info.total_token_usage : undefined) ??
|
|
@@ -972,20 +1070,28 @@ function codexUsage(
|
|
|
972
1070
|
const input = numberOf(raw.input_tokens)
|
|
973
1071
|
const output = numberOf(raw.output_tokens)
|
|
974
1072
|
if (input === 0 && output === 0) return undefined
|
|
975
|
-
return {
|
|
1073
|
+
return {
|
|
1074
|
+
inputTokens: input,
|
|
1075
|
+
cachedInputTokens: numberOf(raw.cached_input_tokens),
|
|
1076
|
+
outputTokens: output,
|
|
1077
|
+
}
|
|
976
1078
|
}
|
|
977
1079
|
|
|
978
1080
|
/**
|
|
979
1081
|
* Per-TURN Codex token usage off a `token_count` event's `info.last_token_usage` (the
|
|
980
1082
|
* delta for the turn just completed, as opposed to `codexUsage`'s cumulative total).
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
1083
|
+
*
|
|
1084
|
+
* OpenAI semantics: `input_tokens` is the turn's WHOLE prompt count and already INCLUDES
|
|
1085
|
+
* the cached share, so the fresh figure is the difference. Clamped at 0 because the two
|
|
1086
|
+
* counts come off the same event and a vendor inconsistency must not mint a negative token
|
|
1087
|
+
* count. Codex reports no separate cache-WRITE class, so that class is 0 here rather than
|
|
1088
|
+
* guessed.
|
|
984
1089
|
*/
|
|
985
1090
|
function codexLastTurnUsage(event: Record<string, unknown>):
|
|
986
1091
|
| {
|
|
987
1092
|
inputTokens: number
|
|
988
|
-
|
|
1093
|
+
cacheReadTokens: number
|
|
1094
|
+
cacheWriteTokens: number
|
|
989
1095
|
outputTokens: number
|
|
990
1096
|
}
|
|
991
1097
|
| undefined {
|
|
@@ -996,7 +1102,12 @@ function codexLastTurnUsage(event: Record<string, unknown>):
|
|
|
996
1102
|
const cached = numberOf(raw.cached_input_tokens)
|
|
997
1103
|
const output = numberOf(raw.output_tokens)
|
|
998
1104
|
if (input === 0 && output === 0) return undefined
|
|
999
|
-
return {
|
|
1105
|
+
return {
|
|
1106
|
+
inputTokens: Math.max(0, input - cached),
|
|
1107
|
+
cacheReadTokens: cached,
|
|
1108
|
+
cacheWriteTokens: 0,
|
|
1109
|
+
outputTokens: output,
|
|
1110
|
+
}
|
|
1000
1111
|
}
|
|
1001
1112
|
|
|
1002
1113
|
/** Dispatch to the configured subscription harness runner. */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AgentJob, AgentResult, McpServerSpec, SkillSpec } from './job.js'
|
|
2
|
+
import type { EffortReport } from './effort.js'
|
|
3
|
+
|
|
4
|
+
// Small helpers shared by every agent MODE (explore / coding / bootstrap / preview). They live
|
|
5
|
+
// apart from `agent.ts` so the bootstrap mode — a whole flow of its own — could move to its own
|
|
6
|
+
// module without either file importing the other.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
10
|
+
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
11
|
+
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function mergeEffort(
|
|
14
|
+
result: AgentResult,
|
|
15
|
+
effortReport: EffortReport | undefined,
|
|
16
|
+
): AgentResult {
|
|
17
|
+
return effortReport ? { ...result, effortReport } : result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The agent-capability fields (skills + tool servers) every agent-running flow forwards to
|
|
22
|
+
* {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow cannot silently
|
|
23
|
+
* be the one that drops a kind's declared playbook or tool server — the failure mode is invisible
|
|
24
|
+
* (the agent simply works without it) and would only show up as degraded output.
|
|
25
|
+
*/
|
|
26
|
+
export function agentCapabilities(job: AgentJob): {
|
|
27
|
+
skills?: SkillSpec[]
|
|
28
|
+
mcpServers?: McpServerSpec[]
|
|
29
|
+
} {
|
|
30
|
+
return {
|
|
31
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
32
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
|
-
import { mkdir, mkdtemp,
|
|
3
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
4
4
|
import { execFile } from 'node:child_process'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import type {
|
|
@@ -20,17 +20,14 @@ import {
|
|
|
20
20
|
conflictDiff,
|
|
21
21
|
fetchPullRequestHead,
|
|
22
22
|
fetchReferenceBranches,
|
|
23
|
-
hasAgentChanges,
|
|
24
23
|
headCommit,
|
|
25
24
|
mergeBranch,
|
|
26
25
|
prepareExistingCheckout,
|
|
27
26
|
pushBranch,
|
|
28
|
-
reinitAndPush,
|
|
29
27
|
unmergedPaths,
|
|
30
28
|
} from './git.js'
|
|
31
29
|
import { inferVcsProvider, openPullRequest } from './vcs-api.js'
|
|
32
30
|
import type { PiRunStats, RunDiagnostics } from './pi.js'
|
|
33
|
-
import type { EffortReport } from './effort.js'
|
|
34
31
|
import { applyPrDescription } from './pr-description.js'
|
|
35
32
|
import {
|
|
36
33
|
makeDirClaimer,
|
|
@@ -39,6 +36,8 @@ import {
|
|
|
39
36
|
runMultiRepoCoding,
|
|
40
37
|
} from './coding-agent.js'
|
|
41
38
|
import { validationFailureMessage } from './validation-checks.js'
|
|
39
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
40
|
+
import { runBootstrap } from './bootstrap-mode.js'
|
|
42
41
|
import {
|
|
43
42
|
acquireRepoCheckout,
|
|
44
43
|
agentNeverActed,
|
|
@@ -313,15 +312,6 @@ async function cloneServiceCheckout(
|
|
|
313
312
|
return deriveWorkDir(dir, job.repo.serviceDirectory)
|
|
314
313
|
}
|
|
315
314
|
|
|
316
|
-
/**
|
|
317
|
-
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
318
|
-
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
319
|
-
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
320
|
-
*/
|
|
321
|
-
function mergeEffort(result: AgentResult, effortReport: EffortReport | undefined): AgentResult {
|
|
322
|
-
return effortReport ? { ...result, effortReport } : result
|
|
323
|
-
}
|
|
324
|
-
|
|
325
315
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
326
316
|
export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
|
|
327
317
|
// An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
|
|
@@ -608,6 +598,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
608
598
|
webSearchProxy: job.webSearch,
|
|
609
599
|
contextFiles: job.contextFiles,
|
|
610
600
|
guardLimits: job.guardLimits,
|
|
601
|
+
...agentCapabilities(job),
|
|
611
602
|
},
|
|
612
603
|
agentOpts,
|
|
613
604
|
)
|
|
@@ -836,6 +827,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
836
827
|
webSearchProxy: job.webSearch,
|
|
837
828
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
838
829
|
guardLimits: job.guardLimits,
|
|
830
|
+
...agentCapabilities(job),
|
|
839
831
|
multiRepo: true,
|
|
840
832
|
},
|
|
841
833
|
opts,
|
|
@@ -954,8 +946,8 @@ function buildSingleRepoCodingSpec(
|
|
|
954
946
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
955
947
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
956
948
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
957
|
-
//
|
|
958
|
-
...(job
|
|
949
|
+
// Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
|
|
950
|
+
...agentCapabilities(job),
|
|
959
951
|
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
960
952
|
...(job.validation
|
|
961
953
|
? {
|
|
@@ -1228,6 +1220,7 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1228
1220
|
sessionToken: job.sessionToken,
|
|
1229
1221
|
contextFiles: job.contextFiles,
|
|
1230
1222
|
guardLimits: job.guardLimits,
|
|
1223
|
+
...agentCapabilities(job),
|
|
1231
1224
|
},
|
|
1232
1225
|
opts,
|
|
1233
1226
|
)
|
|
@@ -1328,156 +1321,6 @@ function unresolvedReason(
|
|
|
1328
1321
|
)
|
|
1329
1322
|
}
|
|
1330
1323
|
|
|
1331
|
-
/**
|
|
1332
|
-
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
1333
|
-
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
1334
|
-
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
1335
|
-
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
1336
|
-
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
1337
|
-
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
1338
|
-
*/
|
|
1339
|
-
async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
1340
|
-
const { signal } = opts
|
|
1341
|
-
const boot = job.bootstrap!
|
|
1342
|
-
const fromScratch = boot.fromScratch === true
|
|
1343
|
-
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
|
|
1344
|
-
return withWorkspace('boot', async (dir) => {
|
|
1345
|
-
if (!fromScratch) {
|
|
1346
|
-
opts.onPhase?.('clone')
|
|
1347
|
-
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
1348
|
-
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
1349
|
-
})
|
|
1350
|
-
await cloneRepo({
|
|
1351
|
-
repo: { ...job.repo, baseBranch: job.branch },
|
|
1352
|
-
ghToken: job.ghToken,
|
|
1353
|
-
dir,
|
|
1354
|
-
signal,
|
|
1355
|
-
})
|
|
1356
|
-
} else {
|
|
1357
|
-
logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
opts.onPhase?.('agent')
|
|
1361
|
-
logger.info('agent(bootstrap): running agent')
|
|
1362
|
-
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1363
|
-
await runAgentInWorkspace(
|
|
1364
|
-
{
|
|
1365
|
-
dir,
|
|
1366
|
-
systemPrompt: job.systemPrompt,
|
|
1367
|
-
userPrompt: job.userPrompt,
|
|
1368
|
-
model: job.model,
|
|
1369
|
-
harness: job.harness,
|
|
1370
|
-
subscriptionToken: job.subscriptionToken,
|
|
1371
|
-
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1372
|
-
ambientAuth: job.ambientAuth,
|
|
1373
|
-
proxyBaseUrl: job.proxyBaseUrl,
|
|
1374
|
-
sessionToken: job.sessionToken,
|
|
1375
|
-
guardLimits: job.guardLimits,
|
|
1376
|
-
},
|
|
1377
|
-
opts,
|
|
1378
|
-
)
|
|
1379
|
-
|
|
1380
|
-
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
1381
|
-
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
1382
|
-
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
1383
|
-
// agent did) instead of pushing nothing.
|
|
1384
|
-
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1385
|
-
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
1386
|
-
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
1387
|
-
return mergeEffort(
|
|
1388
|
-
{
|
|
1389
|
-
summary,
|
|
1390
|
-
stats,
|
|
1391
|
-
error,
|
|
1392
|
-
failureCause: 'agent',
|
|
1393
|
-
...(usage ? { usage } : {}),
|
|
1394
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1395
|
-
},
|
|
1396
|
-
effortReport,
|
|
1397
|
-
)
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
opts.onPhase?.('push')
|
|
1401
|
-
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
|
|
1402
|
-
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
1403
|
-
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
1404
|
-
await reinitAndPush({
|
|
1405
|
-
dir,
|
|
1406
|
-
target: boot.target,
|
|
1407
|
-
ghToken: job.ghToken,
|
|
1408
|
-
message: fromScratch
|
|
1409
|
-
? 'Bootstrap new repository'
|
|
1410
|
-
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1411
|
-
})
|
|
1412
|
-
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
1413
|
-
return mergeEffort(
|
|
1414
|
-
{
|
|
1415
|
-
defaultBranch: boot.target.defaultBranch,
|
|
1416
|
-
summary,
|
|
1417
|
-
stats,
|
|
1418
|
-
...(usage ? { usage } : {}),
|
|
1419
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1420
|
-
},
|
|
1421
|
-
effortReport,
|
|
1422
|
-
)
|
|
1423
|
-
})
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
1428
|
-
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
1429
|
-
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
1430
|
-
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
1431
|
-
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
1432
|
-
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
1433
|
-
*/
|
|
1434
|
-
export async function producedRepoContent(
|
|
1435
|
-
dir: string,
|
|
1436
|
-
hasReference: boolean,
|
|
1437
|
-
signal?: AbortSignal,
|
|
1438
|
-
): Promise<boolean> {
|
|
1439
|
-
if (hasReference) return hasAgentChanges(dir, signal)
|
|
1440
|
-
return containsAnyFile(dir)
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
1445
|
-
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
1446
|
-
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
1447
|
-
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
1448
|
-
* entry before the check).
|
|
1449
|
-
*/
|
|
1450
|
-
async function containsAnyFile(dir: string): Promise<boolean> {
|
|
1451
|
-
const handle = await opendir(dir)
|
|
1452
|
-
try {
|
|
1453
|
-
for await (const entry of handle) {
|
|
1454
|
-
if (entry.isFile()) return true
|
|
1455
|
-
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
|
|
1456
|
-
}
|
|
1457
|
-
} catch {
|
|
1458
|
-
// A directory that vanished mid-walk has nothing to contribute.
|
|
1459
|
-
}
|
|
1460
|
-
return false
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
1464
|
-
function bootstrapNoOpReason(
|
|
1465
|
-
hasReference: boolean,
|
|
1466
|
-
stats: PiRunStats,
|
|
1467
|
-
summary: string,
|
|
1468
|
-
stderrTail: string | undefined,
|
|
1469
|
-
): string {
|
|
1470
|
-
const what = hasReference
|
|
1471
|
-
? 'made no changes to the reference architecture'
|
|
1472
|
-
: 'scaffolded no files'
|
|
1473
|
-
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
|
|
1474
|
-
return (
|
|
1475
|
-
`the bootstrapper agent ${what} ` +
|
|
1476
|
-
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
1477
|
-
agentOutputTail(stderrTail, summary)
|
|
1478
|
-
)
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
1324
|
/** Human-readable reason a read-only run produced no usable output. */
|
|
1482
1325
|
function noOutputReason(stats: PiRunStats, stderrTail: string | undefined): string {
|
|
1483
1326
|
const cause = agentNeverActed(stats)
|