@cat-factory/executor-harness 1.62.0 → 1.64.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/dist/agent-runner.js +81 -8
- package/dist/claude-stream.js +18 -0
- package/dist/embed.js +2 -1
- package/dist/pi-workspace.js +35 -2
- package/dist/pi.js +15 -184
- package/dist/progress-guard.js +211 -0
- package/dist/subagents.js +1 -50
- package/package.json +4 -4
- package/src/agent-runner.ts +88 -8
- package/src/claude-stream.ts +19 -0
- package/src/coding-agent.ts +1 -1
- package/src/embed.ts +5 -3
- package/src/pi-workspace.ts +40 -4
- package/src/pi.ts +26 -252
- package/src/progress-guard.ts +285 -0
- package/src/subagents.ts +7 -16
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { SUBAGENT_TOOL_NAMES } from './claude-stream.js';
|
|
2
|
+
// The harness's no-progress guard: the live anti-rabbithole bound every agent run is held to,
|
|
3
|
+
// plus the tool-name vocabulary it classifies calls with and the limits it reads from the
|
|
4
|
+
// environment. Extracted from `pi.ts` when the guard stopped being Pi's: it now also drives the
|
|
5
|
+
// claude-code subscription runner (`agent-runner.ts` feeds it via `observeSignal`), so the two
|
|
6
|
+
// harnesses share ONE definition of "this run has stopped making progress" — and the tool-name
|
|
7
|
+
// sets below deliberately cover both CLIs' vocabularies.
|
|
8
|
+
/**
|
|
9
|
+
* Tool-call signal read off a streamed Pi event, or undefined if not a tool call. Exported for
|
|
10
|
+
* `runPi`'s span emitter, which reads the same event for its per-tool trace spans.
|
|
11
|
+
*/
|
|
12
|
+
export function toolCallSignal(event) {
|
|
13
|
+
// `tool_execution_end` is the canonical per-call stream event (statsFromEvents
|
|
14
|
+
// counts the same one), so the guard reads it and nothing else — no double count.
|
|
15
|
+
if (event.type !== 'tool_execution_end')
|
|
16
|
+
return undefined;
|
|
17
|
+
const name = typeof event.toolName === 'string' ? event.toolName : '';
|
|
18
|
+
return { name, isError: event.isError === true };
|
|
19
|
+
}
|
|
20
|
+
// `satisfies` (not a type annotation) so each property keeps its concrete `number`
|
|
21
|
+
// type — `maxConsecutiveWebCalls` is optional on the interface (callers may omit it),
|
|
22
|
+
// but the defaults always define it, so consumers reading it off here get a `number`.
|
|
23
|
+
export const DEFAULT_PROGRESS_GUARD_LIMITS = {
|
|
24
|
+
// Counts only non-exploration, non-planning calls (see EXPLORATION_TOOLS), so the
|
|
25
|
+
// ceiling can be generous without risking a false kill on a read-heavy large task.
|
|
26
|
+
maxToolCallsWithoutEdit: 40,
|
|
27
|
+
maxConsecutiveErrors: 12,
|
|
28
|
+
// A genuine research burst is a handful of searches; an uninterrupted run of this
|
|
29
|
+
// many web calls (with no read/edit/bash between) is a search loop, not progress.
|
|
30
|
+
maxConsecutiveWebCalls: 25,
|
|
31
|
+
};
|
|
32
|
+
// Tool names that mutate files, so a call to one clears the no-edit suspicion. Kept
|
|
33
|
+
// broad on purpose: different models/extensions name the same capability differently
|
|
34
|
+
// (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
|
|
35
|
+
// and a false "no edits" reading would kill a run that IS making changes. Matched
|
|
36
|
+
// case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
|
|
37
|
+
// recognised here — broaden or move to a working-tree signal if that becomes common.
|
|
38
|
+
const FILE_EDIT_TOOLS = new Set([
|
|
39
|
+
'edit',
|
|
40
|
+
'write',
|
|
41
|
+
'apply_patch',
|
|
42
|
+
'patch',
|
|
43
|
+
'str_replace',
|
|
44
|
+
'multiedit',
|
|
45
|
+
'create',
|
|
46
|
+
// Claude Code tool names (the guard now runs on the claude-code stream too): Edit/Write/
|
|
47
|
+
// MultiEdit already match above; NotebookEdit is its own tool.
|
|
48
|
+
'notebookedit',
|
|
49
|
+
]);
|
|
50
|
+
// Planning/bookkeeping tools that are neither file edits nor the environment-probing
|
|
51
|
+
// the no-edit bound targets — the todo list the agent maintains as it works. These do
|
|
52
|
+
// NOT count toward `maxToolCallsWithoutEdit`: a run that diligently updates a long
|
|
53
|
+
// todo list before its first edit (common on a large task) would otherwise be killed
|
|
54
|
+
// for "no edits" purely from planning calls. They still reset the consecutive-error
|
|
55
|
+
// streak (a successful call means the agent isn't wedged). Matched case-insensitively.
|
|
56
|
+
// `todo` is Pi's tool; `TodoWrite` and the incremental `TaskCreate`/`TaskUpdate` pair are
|
|
57
|
+
// Claude Code's plan vocabularies — all pure bookkeeping, exempt from the no-edit bound.
|
|
58
|
+
const PLANNING_TOOLS = new Set(['todo', 'todowrite', 'taskcreate', 'taskupdate']);
|
|
59
|
+
// A subagent dispatch (Claude Code's `Agent`/`Task`) is exempt from the no-edit bound because
|
|
60
|
+
// the parent stream CANNOT see the edits it makes: only the dispatch and its terminal
|
|
61
|
+
// tool_result appear there, while every Edit/Write the subagent performs happens on a transcript
|
|
62
|
+
// the guard never reads (`subagents.ts` watches those separately, for usage/progress only). So a
|
|
63
|
+
// coder that fans its implementation out across subagents looks, to this guard, like a run making
|
|
64
|
+
// dozens of action calls and zero edits — and would be killed for making excellent progress.
|
|
65
|
+
// Counting them as edits instead would be worse (a read-only research subagent would then clear
|
|
66
|
+
// the suspicion the bound exists to hold), so they are neutral: they neither count toward the
|
|
67
|
+
// bound nor satisfy it. Sourced from the same set the slice tracker matches on, lower-cased for
|
|
68
|
+
// this module's case-insensitive comparison.
|
|
69
|
+
const SUBAGENT_DISPATCH_TOOLS = new Set([...SUBAGENT_TOOL_NAMES].map((name) => name.toLowerCase()));
|
|
70
|
+
// Read-only exploration tools: reading/searching the repo is legitimate work-up to an
|
|
71
|
+
// edit, NOT the environment-probing the no-edit bound targets, so they don't count
|
|
72
|
+
// toward `maxToolCallsWithoutEdit` (a large task may read/search dozens of files
|
|
73
|
+
// before its first edit). The bound thus counts only "action" calls — chiefly `bash`
|
|
74
|
+
// (the credential rabbit-hole's vector) — that have yet to produce an edit. Kept broad
|
|
75
|
+
// since models/extensions name the same capability differently. Matched case-insensitively.
|
|
76
|
+
const EXPLORATION_TOOLS = new Set([
|
|
77
|
+
'read',
|
|
78
|
+
'grep',
|
|
79
|
+
'search',
|
|
80
|
+
'glob',
|
|
81
|
+
'ls',
|
|
82
|
+
'list',
|
|
83
|
+
'find',
|
|
84
|
+
'tree',
|
|
85
|
+
'cat',
|
|
86
|
+
'view',
|
|
87
|
+
'head',
|
|
88
|
+
'tail',
|
|
89
|
+
'stat',
|
|
90
|
+
// rpiv-web-tools (Pi) + Claude Code's WebSearch/WebFetch: querying/reading the web is
|
|
91
|
+
// read-only research up to an edit, not the environment-probing the no-edit bound targets.
|
|
92
|
+
'web_search',
|
|
93
|
+
'web_fetch',
|
|
94
|
+
'websearch',
|
|
95
|
+
'webfetch',
|
|
96
|
+
]);
|
|
97
|
+
// The web-tool calls, tracked separately so an unbounded run of them (with no other tool
|
|
98
|
+
// call between) can be caught as a search loop — see `maxConsecutiveWebCalls`. Covers both
|
|
99
|
+
// Pi's `web_search`/`web_fetch` and Claude Code's `WebSearch`/`WebFetch`.
|
|
100
|
+
const WEB_TOOLS = new Set(['web_search', 'web_fetch', 'websearch', 'webfetch']);
|
|
101
|
+
/** Read {@link ProgressGuardLimits} from the environment, falling back to the defaults. */
|
|
102
|
+
export function progressGuardLimitsFromEnv(env = process.env) {
|
|
103
|
+
const num = (raw, fallback) => {
|
|
104
|
+
const n = Number(raw);
|
|
105
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
maxToolCallsWithoutEdit: num(env.JOB_MAX_TOOLCALLS_WITHOUT_EDIT, DEFAULT_PROGRESS_GUARD_LIMITS.maxToolCallsWithoutEdit),
|
|
109
|
+
maxConsecutiveErrors: num(env.JOB_MAX_CONSECUTIVE_TOOL_ERRORS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveErrors),
|
|
110
|
+
maxConsecutiveWebCalls: num(env.JOB_MAX_CONSECUTIVE_WEB_CALLS, DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Apply per-knob overrides onto a base set of guard limits, ENFORCING loosen-only: an
|
|
115
|
+
* override can only RAISE a knob (more headroom), never lower it below the base. A
|
|
116
|
+
* larger value is more lenient for every knob (more no-edit tool calls / errors / web
|
|
117
|
+
* calls tolerated), so each result is `max(base, override)`. This is a hard guarantee,
|
|
118
|
+
* not a convention — a tuning entry (built-in or a custom kind's, which reaches this via
|
|
119
|
+
* an untrusted job body) that supplies a value TIGHTER than the base is clamped back up
|
|
120
|
+
* to the base rather than aborting a legitimately-progressing run. An absent/undefined
|
|
121
|
+
* knob keeps the base value untouched.
|
|
122
|
+
*/
|
|
123
|
+
export function mergeGuardLimits(base, overrides) {
|
|
124
|
+
if (!overrides)
|
|
125
|
+
return base;
|
|
126
|
+
const loosen = (b, o) => typeof o === 'number' ? Math.max(b, o) : b;
|
|
127
|
+
return {
|
|
128
|
+
maxToolCallsWithoutEdit: loosen(base.maxToolCallsWithoutEdit, overrides.maxToolCallsWithoutEdit),
|
|
129
|
+
maxConsecutiveErrors: loosen(base.maxConsecutiveErrors, overrides.maxConsecutiveErrors),
|
|
130
|
+
// `maxConsecutiveWebCalls` is optional on the interface (callers may omit it), so
|
|
131
|
+
// fall back to the default before loosening — keeps `loosen`'s base a concrete number.
|
|
132
|
+
maxConsecutiveWebCalls: loosen(base.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls, overrides.maxConsecutiveWebCalls),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
|
|
137
|
+
* reason the moment a run has plainly stopped making progress, so the harness can
|
|
138
|
+
* kill Pi early instead of letting it burn the whole budget (and then surface a
|
|
139
|
+
* useful failure instead of a generic "no file changes"). Pure and incremental so
|
|
140
|
+
* it can be unit-tested over a fixed event sequence.
|
|
141
|
+
*/
|
|
142
|
+
export class ProgressGuard {
|
|
143
|
+
limits;
|
|
144
|
+
expectsEdits;
|
|
145
|
+
toolCalls = 0;
|
|
146
|
+
edits = 0;
|
|
147
|
+
consecutiveErrors = 0;
|
|
148
|
+
consecutiveWebCalls = 0;
|
|
149
|
+
constructor(limits,
|
|
150
|
+
/** When false (assess-only runs like the merger), the no-edit bound is skipped. */
|
|
151
|
+
expectsEdits = true) {
|
|
152
|
+
this.limits = limits;
|
|
153
|
+
this.expectsEdits = expectsEdits;
|
|
154
|
+
}
|
|
155
|
+
/** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
|
|
156
|
+
observe(event) {
|
|
157
|
+
const tool = toolCallSignal(event);
|
|
158
|
+
if (!tool)
|
|
159
|
+
return null;
|
|
160
|
+
return this.observeSignal(tool);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Feed one already-parsed tool-call signal (name + error flag), returning a diagnostic reason
|
|
164
|
+
* when the run should abort, else null. Split out of {@link observe} so a caller whose stream
|
|
165
|
+
* is NOT Pi's `tool_execution_end` envelope — the claude-code runner, which correlates a
|
|
166
|
+
* `tool_use` block's name with its `tool_result`'s `is_error` — can drive the SAME guard logic
|
|
167
|
+
* without synthesising a fake Pi event.
|
|
168
|
+
*/
|
|
169
|
+
observeSignal(tool) {
|
|
170
|
+
const name = tool.name.toLowerCase();
|
|
171
|
+
// The error streak tracks ANY tool call (a planning call still proves the agent
|
|
172
|
+
// isn't wedged in a failing-op loop), so it's updated before the planning skip.
|
|
173
|
+
this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0;
|
|
174
|
+
if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
|
|
175
|
+
return (`no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
|
|
176
|
+
`retrying a failing operation rather than making progress. Aborting.`);
|
|
177
|
+
}
|
|
178
|
+
// Web search/fetch loop: web tools are read-only (they don't count toward the
|
|
179
|
+
// no-edit bound), so guard them separately — an uninterrupted streak of them is a
|
|
180
|
+
// research rabbit-hole. Any non-web tool call resets the streak.
|
|
181
|
+
if (WEB_TOOLS.has(name)) {
|
|
182
|
+
this.consecutiveWebCalls++;
|
|
183
|
+
const webCap = this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls;
|
|
184
|
+
if (this.consecutiveWebCalls >= webCap) {
|
|
185
|
+
return (`no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
|
|
186
|
+
`any other action — the agent is stuck researching instead of doing the work. Aborting.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
this.consecutiveWebCalls = 0;
|
|
191
|
+
}
|
|
192
|
+
// Planning, read-only exploration and subagent-dispatch calls don't count toward the
|
|
193
|
+
// no-edit bound (see PLANNING_TOOLS / EXPLORATION_TOOLS / SUBAGENT_DISPATCH_TOOLS) —
|
|
194
|
+
// only "action" calls without an edit do.
|
|
195
|
+
if (PLANNING_TOOLS.has(name) ||
|
|
196
|
+
EXPLORATION_TOOLS.has(name) ||
|
|
197
|
+
SUBAGENT_DISPATCH_TOOLS.has(name)) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
this.toolCalls++;
|
|
201
|
+
if (FILE_EDIT_TOOLS.has(name))
|
|
202
|
+
this.edits++;
|
|
203
|
+
if (this.expectsEdits &&
|
|
204
|
+
this.edits === 0 &&
|
|
205
|
+
this.toolCalls >= this.limits.maxToolCallsWithoutEdit) {
|
|
206
|
+
return (`no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
|
|
207
|
+
`probing the environment without implementing anything. Aborting before it burns the whole run.`);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
package/dist/subagents.js
CHANGED
|
@@ -1,57 +1,8 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
|
|
4
|
+
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody, SUBAGENT_TOOL_NAMES, } from './claude-stream.js';
|
|
5
5
|
import { publishCallMetric } from './pi.js';
|
|
6
|
-
// ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
|
|
7
|
-
// it fans the work out across parallel `Task` subagents. Two things then go dark to the
|
|
8
|
-
// harness, which only reads the PARENT process's stream-json stdout:
|
|
9
|
-
//
|
|
10
|
-
// - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
|
|
11
|
-
// review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
|
|
12
|
-
// - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
|
|
13
|
-
// transcript under the CLI's config home and never reaches the parent stream, so
|
|
14
|
-
// the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
|
|
15
|
-
//
|
|
16
|
-
// This module closes both without disabling the (context-bounding, ADR-0023-wanted)
|
|
17
|
-
// subagent parallelism:
|
|
18
|
-
//
|
|
19
|
-
// - {@link createSliceTracker} derives the slice plan + per-slice progress from the
|
|
20
|
-
// PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
|
|
21
|
-
// DO appear there (only the subagent's intermediate turns don't), so slices/progress
|
|
22
|
-
// need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
|
|
23
|
-
// parent's own plan (ADR 0027 Defect B);
|
|
24
|
-
// - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
|
|
25
|
-
// heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
|
|
26
|
-
// the run's telemetry (D3).
|
|
27
|
-
//
|
|
28
|
-
// The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
|
|
29
|
-
// 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
|
|
30
|
-
// `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
|
|
31
|
-
// session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
|
|
32
|
-
// `projects` root and DISCOVERS the `subagents/` dir by walking (see
|
|
33
|
-
// {@link findSubagentTranscripts}).
|
|
34
|
-
//
|
|
35
|
-
// Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
|
|
36
|
-
// so a missing directory, an unreadable file, or an unparseable line is swallowed and the
|
|
37
|
-
// harness falls back to today's parent-stream-only behaviour.
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Slice / progress tracking off the PARENT stream (D2.1)
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
/**
|
|
42
|
-
* The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
|
|
43
|
-
* shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
|
|
44
|
-
* `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
|
|
45
|
-
* harness runs against whatever CLI the image happens to bundle, and matching only the old name
|
|
46
|
-
* is what left a CLI 2.1.x pr-review reporting no slices at all.
|
|
47
|
-
*
|
|
48
|
-
* Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
|
|
49
|
-
* FALSE signal rather than merely no signal — if a future build were to name a plain task-list
|
|
50
|
-
* tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
|
|
51
|
-
* build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
|
|
52
|
-
* `progress.ts`), and dropping legacy coverage is the more likely regression.
|
|
53
|
-
*/
|
|
54
|
-
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
|
|
55
6
|
export function createSliceTracker() {
|
|
56
7
|
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
57
8
|
const slices = new Map();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.64.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/
|
|
31
|
-
"@cat-factory/
|
|
29
|
+
"@cat-factory/kernel": "0.170.0",
|
|
30
|
+
"@cat-factory/server": "0.160.0",
|
|
31
|
+
"@cat-factory/spend": "0.12.99"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type PiRunStats,
|
|
20
20
|
type TodoProgress,
|
|
21
21
|
} from './pi.js'
|
|
22
|
+
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
22
23
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
23
24
|
import { redact, secretsToRedact } from './redact.js'
|
|
24
25
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
@@ -101,6 +102,17 @@ export interface SubscriptionRunOptions {
|
|
|
101
102
|
extraEnv?: Record<string, string>
|
|
102
103
|
/** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
|
|
103
104
|
signal?: AbortSignal
|
|
105
|
+
/**
|
|
106
|
+
* Fully-resolved no-progress guard limits (env defaults merged loosen-only with the kind's
|
|
107
|
+
* tuning + any complexity-scaled allowance). When set, the claude-code runner runs the SAME
|
|
108
|
+
* {@link ProgressGuard} as Pi over the CLI's tool stream and kills a run that has plainly
|
|
109
|
+
* stopped making progress (no-edit probing, error-retry loop, web rabbit-hole) rather than
|
|
110
|
+
* letting it burn the whole wall-clock budget. Omitted ⇒ the guard is disabled for this run
|
|
111
|
+
* (only the external watchdog bounds it), preserving the pre-guard behaviour.
|
|
112
|
+
*/
|
|
113
|
+
guardLimits?: ProgressGuardLimits
|
|
114
|
+
/** Whether this run is expected to edit files (false for assess-only runs); gates the no-edit bound. */
|
|
115
|
+
expectsEdits?: boolean
|
|
104
116
|
/** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
|
|
105
117
|
onActivity?: () => void
|
|
106
118
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
@@ -154,7 +166,7 @@ function streamCli(
|
|
|
154
166
|
opts: SubscriptionRunOptions,
|
|
155
167
|
env: Record<string, string>,
|
|
156
168
|
secrets: string[],
|
|
157
|
-
onEvent: (event: Record<string, unknown
|
|
169
|
+
onEvent: (event: Record<string, unknown>, meta?: { final?: boolean }) => void,
|
|
158
170
|
): Promise<{ stderrTail: string }> {
|
|
159
171
|
const { command, args } = cli
|
|
160
172
|
return new Promise((resolve, reject) => {
|
|
@@ -178,7 +190,12 @@ function streamCli(
|
|
|
178
190
|
|
|
179
191
|
const killChild = (): void => killChildProcess(child)
|
|
180
192
|
|
|
181
|
-
|
|
193
|
+
// `final` marks the at-close flush of a trailing unterminated line: the CLI has already
|
|
194
|
+
// exited, so an observer must not act on that record in a way that KILLS the run (mirrors
|
|
195
|
+
// `runPi`'s `runGuard = false` flush — without it, a guard tripping on the last buffered
|
|
196
|
+
// record could turn a clean exit into a spurious "no progress" failure). The record's
|
|
197
|
+
// progress/telemetry signal is still delivered; only kill decisions are suppressed.
|
|
198
|
+
const processLine = (line: string, final = false): void => {
|
|
182
199
|
if (!line.startsWith('{')) return
|
|
183
200
|
let event: Record<string, unknown>
|
|
184
201
|
try {
|
|
@@ -187,7 +204,7 @@ function streamCli(
|
|
|
187
204
|
return
|
|
188
205
|
}
|
|
189
206
|
try {
|
|
190
|
-
onEvent(event)
|
|
207
|
+
onEvent(event, { final })
|
|
191
208
|
} catch {
|
|
192
209
|
// A faulty observer must never break the run.
|
|
193
210
|
}
|
|
@@ -226,10 +243,13 @@ function streamCli(
|
|
|
226
243
|
})
|
|
227
244
|
child.on('close', (code) => {
|
|
228
245
|
opts.signal?.removeEventListener('abort', onAbort)
|
|
229
|
-
if (lineBuffer.trim()) processLine(lineBuffer.trim())
|
|
230
246
|
const stderrTail = redact(stderr, secrets).slice(-700)
|
|
247
|
+
if (lineBuffer.trim()) processLine(lineBuffer.trim(), true)
|
|
231
248
|
if (aborted) {
|
|
232
|
-
|
|
249
|
+
// Carry the tail on the rejection so a caller that REPLACES this generic message with a
|
|
250
|
+
// more specific cause (the no-progress guard's diagnostic) can still append it — the
|
|
251
|
+
// stderr is often the only evidence of what the CLI was doing when it was killed.
|
|
252
|
+
reject(Object.assign(new Error('agent run aborted by watchdog'), { stderrTail }))
|
|
233
253
|
return
|
|
234
254
|
}
|
|
235
255
|
if (code !== 0) {
|
|
@@ -382,7 +402,42 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
382
402
|
if (progress) opts.onProgress(progress)
|
|
383
403
|
}
|
|
384
404
|
|
|
385
|
-
|
|
405
|
+
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
406
|
+
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
407
|
+
// turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
|
|
408
|
+
// `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
|
|
409
|
+
// `guardAbort` (folded into streamCli's signal below) and the run then fails with its
|
|
410
|
+
// diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
|
|
411
|
+
const guard = opts.guardLimits
|
|
412
|
+
? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
|
|
413
|
+
: undefined
|
|
414
|
+
const toolNames = new Map<string, string>()
|
|
415
|
+
const guardAbort = new AbortController()
|
|
416
|
+
let guardReason: string | undefined
|
|
417
|
+
|
|
418
|
+
// Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
|
|
419
|
+
// with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
|
|
420
|
+
// it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
|
|
421
|
+
// below surfaces `guardReason` over the generic abort message). A standalone closure so the
|
|
422
|
+
// per-block loop doesn't nest onEvent past the readable-depth limit.
|
|
423
|
+
const feedGuard = (content: unknown[]): void => {
|
|
424
|
+
if (!guard || guardReason) return
|
|
425
|
+
for (const block of content) {
|
|
426
|
+
if (!isObject(block) || block.type !== 'tool_result') continue
|
|
427
|
+
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
|
|
428
|
+
const name = id ? toolNames.get(id) : undefined
|
|
429
|
+
if (id) toolNames.delete(id)
|
|
430
|
+
if (!name) continue
|
|
431
|
+
const reason = guard.observeSignal({ name, isError: block.is_error === true })
|
|
432
|
+
if (reason) {
|
|
433
|
+
guardReason = reason
|
|
434
|
+
guardAbort.abort()
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
|
|
386
441
|
const type = event.type
|
|
387
442
|
if (type === 'assistant' && isObject(event.message)) {
|
|
388
443
|
const message = event.message as Record<string, unknown>
|
|
@@ -391,7 +446,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
391
446
|
stats.assistantChars += text.length
|
|
392
447
|
stats.toolCalls += toolUses
|
|
393
448
|
for (const block of content) {
|
|
394
|
-
if (isObject(block)
|
|
449
|
+
if (!isObject(block) || block.type !== 'tool_use') continue
|
|
450
|
+
// Remember each call's name against its id so the guard can pair it with the
|
|
451
|
+
// `is_error` its `tool_result` carries on the next `user` turn.
|
|
452
|
+
if (typeof block.id === 'string' && typeof block.name === 'string') {
|
|
453
|
+
toolNames.set(block.id, block.name)
|
|
454
|
+
}
|
|
455
|
+
if (block.name === 'TodoWrite') {
|
|
395
456
|
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
396
457
|
if (progress) lastTodo = progress
|
|
397
458
|
}
|
|
@@ -422,6 +483,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
422
483
|
sliceTracker.onUser(content)
|
|
423
484
|
planTracker.onUser(content)
|
|
424
485
|
emitProgress()
|
|
486
|
+
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
487
|
+
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
488
|
+
if (!meta?.final) feedGuard(content)
|
|
425
489
|
messages.push({ role: 'tool', content })
|
|
426
490
|
}
|
|
427
491
|
} else if (type === 'result') {
|
|
@@ -488,6 +552,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
488
552
|
})
|
|
489
553
|
: undefined
|
|
490
554
|
|
|
555
|
+
// Fold the guard's abort into the run signal so a tripped guard kills the CLI the same way the
|
|
556
|
+
// external watchdog does; `guardReason` (set above) distinguishes the two at the catch below.
|
|
557
|
+
const runSignal = opts.signal
|
|
558
|
+
? AbortSignal.any([opts.signal, guardAbort.signal])
|
|
559
|
+
: guardAbort.signal
|
|
560
|
+
|
|
491
561
|
try {
|
|
492
562
|
const { stderrTail } = await streamCli(
|
|
493
563
|
{
|
|
@@ -509,7 +579,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
509
579
|
],
|
|
510
580
|
},
|
|
511
581
|
prompt,
|
|
512
|
-
opts,
|
|
582
|
+
{ ...opts, signal: runSignal },
|
|
513
583
|
env,
|
|
514
584
|
opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
|
|
515
585
|
onEvent,
|
|
@@ -524,6 +594,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
524
594
|
usage,
|
|
525
595
|
subagents,
|
|
526
596
|
})
|
|
597
|
+
} catch (err) {
|
|
598
|
+
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
599
|
+
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
600
|
+
// it attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
601
|
+
// killed. Byte-for-byte the shape `runPi` fails with.
|
|
602
|
+
if (guardReason) {
|
|
603
|
+
const tail = (err as { stderrTail?: string } | undefined)?.stderrTail
|
|
604
|
+
throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason)
|
|
605
|
+
}
|
|
606
|
+
throw err
|
|
527
607
|
} finally {
|
|
528
608
|
await subagents?.stop()
|
|
529
609
|
if (configHome) {
|
package/src/claude-stream.ts
CHANGED
|
@@ -10,6 +10,25 @@ export function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
10
10
|
return typeof value === 'object' && value !== null
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
|
|
15
|
+
* shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
|
|
16
|
+
* `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
|
|
17
|
+
* harness runs against whatever CLI the image happens to bundle, and matching only the old name
|
|
18
|
+
* is what left a CLI 2.1.x pr-review reporting no slices at all.
|
|
19
|
+
*
|
|
20
|
+
* Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
|
|
21
|
+
* FALSE signal rather than merely no signal — if a future build were to name a plain task-list
|
|
22
|
+
* tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
|
|
23
|
+
* build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
|
|
24
|
+
* `progress.ts`), and dropping legacy coverage is the more likely regression.
|
|
25
|
+
*
|
|
26
|
+
* Lives here rather than in `subagents.ts` because BOTH the slice tracker and the no-progress
|
|
27
|
+
* guard (`pi.ts`, which `subagents.ts` imports — so it cannot import back) must agree on what a
|
|
28
|
+
* subagent dispatch looks like.
|
|
29
|
+
*/
|
|
30
|
+
export const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task'])
|
|
31
|
+
|
|
13
32
|
export function numberOf(value: unknown): number {
|
|
14
33
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
15
34
|
}
|
package/src/coding-agent.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
runAgentInWorkspace,
|
|
47
47
|
withWorkspace,
|
|
48
48
|
} from './pi-workspace.js'
|
|
49
|
-
import type { ProgressGuardLimits } from './
|
|
49
|
+
import type { ProgressGuardLimits } from './progress-guard.js'
|
|
50
50
|
import type { RunOptions } from './runner.js'
|
|
51
51
|
import { log, type Logger } from './logger.js'
|
|
52
52
|
import {
|
package/src/embed.ts
CHANGED
|
@@ -7,21 +7,23 @@
|
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
9
|
PI_MAX_OUTPUT_TOKENS,
|
|
10
|
-
DEFAULT_PROGRESS_GUARD_LIMITS,
|
|
11
10
|
writePiModelsConfig,
|
|
12
11
|
writeAgentsContext,
|
|
13
12
|
runPi,
|
|
14
13
|
summarizePiRun,
|
|
15
14
|
parsePiOutput,
|
|
16
15
|
parseTodoProgress,
|
|
17
|
-
progressGuardLimitsFromEnv,
|
|
18
16
|
terminalRunError,
|
|
19
17
|
type PiRunOutcome,
|
|
20
18
|
type PiRunStats,
|
|
21
|
-
type ProgressGuardLimits,
|
|
22
19
|
type TodoItem,
|
|
23
20
|
type TodoProgress,
|
|
24
21
|
} from './pi.js'
|
|
22
|
+
export {
|
|
23
|
+
DEFAULT_PROGRESS_GUARD_LIMITS,
|
|
24
|
+
progressGuardLimitsFromEnv,
|
|
25
|
+
type ProgressGuardLimits,
|
|
26
|
+
} from './progress-guard.js'
|
|
25
27
|
export {
|
|
26
28
|
cloneRepo,
|
|
27
29
|
createBranch,
|
package/src/pi-workspace.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
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
4
|
import type { RepoSpec, SkillSpec } from './job.js'
|
|
@@ -8,13 +8,10 @@ import {
|
|
|
8
8
|
type ContextFileInfo,
|
|
9
9
|
type PiRunOutcome,
|
|
10
10
|
type PiRunStats,
|
|
11
|
-
type ProgressGuardLimits,
|
|
12
11
|
type RunDiagnostics,
|
|
13
12
|
CONTEXT_DIR,
|
|
14
13
|
materializeContextFiles,
|
|
15
14
|
materializeSkillResources,
|
|
16
|
-
mergeGuardLimits,
|
|
17
|
-
progressGuardLimitsFromEnv,
|
|
18
15
|
runPi,
|
|
19
16
|
webSearchConfigFromEnv,
|
|
20
17
|
webSearchProxyEnv,
|
|
@@ -22,6 +19,11 @@ import {
|
|
|
22
19
|
writePiModelsConfig,
|
|
23
20
|
writeWebToolsConfig,
|
|
24
21
|
} from './pi.js'
|
|
22
|
+
import {
|
|
23
|
+
type ProgressGuardLimits,
|
|
24
|
+
mergeGuardLimits,
|
|
25
|
+
progressGuardLimitsFromEnv,
|
|
26
|
+
} from './progress-guard.js'
|
|
25
27
|
import type { RunOptions } from './runner.js'
|
|
26
28
|
import { type SubscriptionHarness, runSubscriptionHarness } from './agent-runner.js'
|
|
27
29
|
|
|
@@ -226,6 +228,31 @@ export interface AgentRunSpec {
|
|
|
226
228
|
multiRepo?: boolean
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Whether the run's checkout actually ships a `blueprints/` folder — what gates the blueprint
|
|
233
|
+
* orientation note in AGENTS.md (an external repo has none, so the note would be ~10 lines of
|
|
234
|
+
* dead guidance pointing at files that don't exist, re-sent on every turn).
|
|
235
|
+
*
|
|
236
|
+
* A MULTI-REPO run's `dir` is the workspace ROOT with each repo checked out as a sibling under
|
|
237
|
+
* it, so the root itself never holds `blueprints/`: the legs are checked too, and the note is
|
|
238
|
+
* included when ANY leg ships one (it orients the agent to the concept, and the agent finds the
|
|
239
|
+
* per-repo folder from there). Best-effort throughout — any stat/readdir failure simply omits
|
|
240
|
+
* the note rather than failing the dispatch.
|
|
241
|
+
*/
|
|
242
|
+
export async function checkoutHasBlueprints(dir: string, multiRepo: boolean): Promise<boolean> {
|
|
243
|
+
const isBlueprintDir = (path: string): Promise<boolean> =>
|
|
244
|
+
stat(join(path, 'blueprints'))
|
|
245
|
+
.then((s) => s.isDirectory())
|
|
246
|
+
.catch(() => false)
|
|
247
|
+
if (await isBlueprintDir(dir)) return true
|
|
248
|
+
if (!multiRepo) return false
|
|
249
|
+
const legs = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
250
|
+
const checks = await Promise.all(
|
|
251
|
+
legs.filter((e) => e.isDirectory()).map((e) => isBlueprintDir(join(dir, e.name))),
|
|
252
|
+
)
|
|
253
|
+
return checks.some(Boolean)
|
|
254
|
+
}
|
|
255
|
+
|
|
229
256
|
/**
|
|
230
257
|
* Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
|
|
231
258
|
* then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
|
|
@@ -272,6 +299,13 @@ export async function runAgentInWorkspace(
|
|
|
272
299
|
...(spec.skill ? { skill: spec.skill } : {}),
|
|
273
300
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
274
301
|
signal: opts.signal,
|
|
302
|
+
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
303
|
+
// defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
|
|
304
|
+
// no-edit allowance, so a claude-code run that stops making progress is killed early
|
|
305
|
+
// instead of burning the full wall-clock budget. The claude runner consumes it; codex
|
|
306
|
+
// ignores it for now (its stream isn't wired to the guard).
|
|
307
|
+
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
308
|
+
expectsEdits: spec.expectsEdits ?? true,
|
|
275
309
|
onActivity: opts.onActivity,
|
|
276
310
|
onProgress: opts.onProgress,
|
|
277
311
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
@@ -303,11 +337,13 @@ export async function runAgentInWorkspace(
|
|
|
303
337
|
}
|
|
304
338
|
const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv })
|
|
305
339
|
if (webSearch) await writeWebToolsConfig(webSearch)
|
|
340
|
+
const hasBlueprints = await checkoutHasBlueprints(spec.dir, spec.multiRepo === true)
|
|
306
341
|
await writeAgentsContext(spec.systemPrompt, {
|
|
307
342
|
webSearch: Boolean(webSearch),
|
|
308
343
|
guidance: spec.webToolsGuidance,
|
|
309
344
|
serviceDirectory: spec.serviceDirectory,
|
|
310
345
|
contextFiles,
|
|
346
|
+
hasBlueprints,
|
|
311
347
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
312
348
|
})
|
|
313
349
|
await writePiModelsConfig({ model: spec.model, proxyBaseUrl })
|