@bacnh85/pi-subagent 0.17.0 → 0.19.2
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/CHANGELOG.md +69 -0
- package/README.md +12 -4
- package/agent-format.md +2 -1
- package/agents/planner.md +1 -0
- package/agents/reviewer.md +1 -0
- package/extensions/agents.ts +27 -0
- package/extensions/background.ts +14 -7
- package/extensions/index.ts +42 -8
- package/extensions/runner.ts +88 -6
- package/extensions/security.ts +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.2 (2026-09-02)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Stalled streams now fall back to the next model** — when a provider
|
|
8
|
+
accepts a request but the stream emits zero events for the entire idle
|
|
9
|
+
window (known router/provider failure mode, e.g. slow-TTFT models behind
|
|
10
|
+
omniroute), the run previously died on model #1 with "Idle timeout" and
|
|
11
|
+
never tried the fallback chain. An IDLE timeout is now treated as a
|
|
12
|
+
capacity signal (`isRetryableModelResult`): the task retries on the next
|
|
13
|
+
candidate (each candidate is tried at most once, so worst case is N idle
|
|
14
|
+
windows). The HARD lifetime cap stays terminal — a task that ran 20 min is
|
|
15
|
+
genuinely huge, not a stall. Found by live background-task test: trivial
|
|
16
|
+
20+22 task timed out on glm-5-turbo with zero stream events.
|
|
17
|
+
|
|
18
|
+
## 0.19.1 (2026-09-02)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Model fallback now engages on credential cooldown** — router providers
|
|
23
|
+
report "All credentials for model X are cooling down" when every key for a
|
|
24
|
+
model is in its rate-limit window. That message didn't match
|
|
25
|
+
`RATE_LIMIT_PATTERNS`, so `runWithModelFallback` treated it as a fatal error
|
|
26
|
+
instead of advancing to the next candidate (e.g. `@fast`
|
|
27
|
+
glm-5-turbo → gpt-oss-20b → deepseek-v4-flash), aborting subagent and chain
|
|
28
|
+
runs with "All credentials … are cooling down". Added the cooldown pattern
|
|
29
|
+
(credential cooldown / cooldown window / cooling down) so capacity signals
|
|
30
|
+
from the credential layer trigger the same fallback as 429s.
|
|
31
|
+
|
|
32
|
+
## 0.19.0 (2026-09-02)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **Reviewer/planner agents no longer abort at the 3-min idle timeout while
|
|
37
|
+
thinking** — root cause: a `thinking: high` child on a slow model can spend
|
|
38
|
+
many minutes in one reasoning stretch, and the default 3-min inactivity
|
|
39
|
+
window kills the run even though it is healthy. Fix: new agent frontmatter
|
|
40
|
+
field **`timeout: <minutes>`** (1–60) bakes a per-agent idle window; the
|
|
41
|
+
hard lifetime cap is raised to match so long windows are actually
|
|
42
|
+
enforceable. Bundled `reviewer` and `planner` agents ship with
|
|
43
|
+
`timeout: 10`. Per-call `timeout` still overrides. (The idle timer already
|
|
44
|
+
treats every SDK event — including streaming deltas — as activity; a run
|
|
45
|
+
that emits no events at all for the window is genuinely hung.)
|
|
46
|
+
Reported by peer mbp-sao-9915 (review rounds hitting the 180s default).
|
|
47
|
+
|
|
48
|
+
## 0.18.0 (2026-09-02)
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **Worktree patches now reach the parent model** — `sandbox: worktree` results
|
|
53
|
+
previously exposed the diff only in TUI details; the tool-result text the
|
|
54
|
+
parent model reads contained just the child's final message, so the
|
|
55
|
+
documented "parent merges via apply_patch" flow was impossible in practice.
|
|
56
|
+
The diff is now appended as a `🌿 worktree patch` block (capped at the
|
|
57
|
+
per-task output limit) in single, parallel, chain, and background-completion
|
|
58
|
+
results, and `operation: "status"` reports `Patch: N diff lines`.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`merge: "3way"` per call/item** — with a worktree-sandboxed agent, the
|
|
63
|
+
captured diff is applied to the parent checkout via `git apply --3way`
|
|
64
|
+
(temp-file at repo root, applied before worktree removal so the staged blobs
|
|
65
|
+
are available for 3-way reconstruction). Results carry
|
|
66
|
+
`mergeStatus: "applied" | "conflict"`; conflicts keep git's markers, report
|
|
67
|
+
the apply error, and still deliver the patch for manual merging — never
|
|
68
|
+
silently resolved. Concurrent applies are serialized (OMP `withRepoLock`
|
|
69
|
+
equivalent) so parallel siblings cannot race the checkout.
|
|
70
|
+
- Applies are mutex-serialized across parallel/background tasks.
|
|
71
|
+
|
|
3
72
|
## 0.17.0 (2026-08-31)
|
|
4
73
|
|
|
5
74
|
### Added
|
package/README.md
CHANGED
|
@@ -166,10 +166,18 @@ two `worker` agents editing the same files can no longer clobber each other —
|
|
|
166
166
|
each writes into its own checkout.
|
|
167
167
|
|
|
168
168
|
- All file mutations land in the worktree; the main checkout stays untouched.
|
|
169
|
-
- On completion,
|
|
170
|
-
result
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
- On completion, the unified diff of the child's changes is included in the
|
|
170
|
+
tool result as a `🌿 worktree patch` block (capped at the per-task output
|
|
171
|
+
limit) and shown in the thread viewer as a `🌿 worktree` badge.
|
|
172
|
+
- **Merging**: by default the parent merges explicitly via `apply_patch` /
|
|
173
|
+
`git apply` / discard. Pass `merge: "3way"` (per call/item) to have the diff
|
|
174
|
+
applied automatically to the parent checkout via `git apply --3way` once the
|
|
175
|
+
child completes — conflicts leave git's conflict markers in place, are
|
|
176
|
+
reported in the result (`mergeStatus: "conflict"`), and the patch is still
|
|
177
|
+
delivered for manual merging. Nothing is ever silently resolved. Applies are
|
|
178
|
+
serialized so parallel siblings cannot race the checkout.
|
|
179
|
+
- Children start from `HEAD`: uncommitted changes in the parent checkout are
|
|
180
|
+
invisible to the child and will surface as apply conflicts when merging.
|
|
173
181
|
- The worktree is removed on completion (success, error, or abort).
|
|
174
182
|
- Requires git; when the cwd is not a git repo, the agent falls back to
|
|
175
183
|
in-process execution with a warning (`ponytail`: isolation optimization,
|
package/agent-format.md
CHANGED
|
@@ -26,6 +26,7 @@ models: # Optional ordered fallbacks; comma form also accepted
|
|
|
26
26
|
thinking: low # Optional: off|minimal|low|medium|high|xhigh|max.
|
|
27
27
|
sandbox: read-only # Optional: read-only | workspace-write | worktree. Auto-derives tool restrictions.
|
|
28
28
|
color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|cyan.
|
|
29
|
+
timeout: 10 # Optional: per-agent inactivity timeout in minutes (1–60). Default 3. For slow-thinking (thinking: high) agents.
|
|
29
30
|
---
|
|
30
31
|
```
|
|
31
32
|
|
|
@@ -42,7 +43,7 @@ agent's `thinking` for that match. See README → *Role-based model routing*.
|
|
|
42
43
|
|
|
43
44
|
- `read-only`: Restricts tools to the read-only allowlist (`read`, `grep`, `find`, `ls` plus read-only extension tools when inherited — `web_*`, `serena_*`, `munin_*`, `fff*`, …). Overrides any `tools` field.
|
|
44
45
|
- `workspace-write` (default): Uses the agent's `tools` list or defaults to all tools.
|
|
45
|
-
- `worktree`: Runs the agent in an isolated git worktree (`.pi-worktrees/<id>` under the repo root). All file mutations land in the worktree; the main checkout is untouched. On completion,
|
|
46
|
+
- `worktree`: Runs the agent in an isolated git worktree (`.pi-worktrees/<id>` under the repo root). All file mutations land in the worktree; the main checkout is untouched. On completion, the unified diff is delivered in the tool result as a `🌿 worktree patch` block; pass `merge: "3way"` to apply it to the parent checkout automatically (`git apply --3way`, conflicts reported as `mergeStatus: "conflict"` with markers left in place). Falls back to in-process execution when the cwd is not a git repo (with a warning). Requires git.
|
|
46
47
|
|
|
47
48
|
### `color`
|
|
48
49
|
|
package/agents/planner.md
CHANGED
package/agents/reviewer.md
CHANGED
package/extensions/agents.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface AgentConfig {
|
|
|
24
24
|
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
25
25
|
sandbox?: "read-only" | "workspace-write" | "worktree";
|
|
26
26
|
color?: AgentColor;
|
|
27
|
+
/** Per-agent default inactivity timeout in minutes (1–60). Per-call timeout overrides. */
|
|
28
|
+
timeout?: number;
|
|
27
29
|
systemPrompt: string;
|
|
28
30
|
source: "user" | "project" | "bundled";
|
|
29
31
|
filePath: string;
|
|
@@ -224,6 +226,30 @@ function loadAgentsFromDir(
|
|
|
224
226
|
});
|
|
225
227
|
}
|
|
226
228
|
|
|
229
|
+
// Per-agent inactivity timeout (minutes) — lets slow-thinking agents
|
|
230
|
+
// (thinking: high) raise the 3-min default without per-call params.
|
|
231
|
+
let timeout: number | undefined;
|
|
232
|
+
if (frontmatter.timeout !== undefined && typeof frontmatter.timeout !== "boolean" && !Array.isArray(frontmatter.timeout)) {
|
|
233
|
+
const t = Number(frontmatter.timeout);
|
|
234
|
+
if (Number.isInteger(t) && t >= 1 && t <= 60) {
|
|
235
|
+
timeout = t;
|
|
236
|
+
} else {
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
filePath,
|
|
239
|
+
issue: `Invalid timeout "${frontmatter.timeout}". Must be an integer 1–60 (minutes). Ignoring.`,
|
|
240
|
+
severity: "warn",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
} else if (frontmatter.timeout !== undefined) {
|
|
244
|
+
// Booleans/arrays/objects: Number() would coerce (true→1, [10]→10), so
|
|
245
|
+
// reject up front with the same diagnostic instead of silently accepting.
|
|
246
|
+
diagnostics.push({
|
|
247
|
+
filePath,
|
|
248
|
+
issue: `Invalid timeout "${String(frontmatter.timeout)}". Must be an integer 1–60 (minutes). Ignoring.`,
|
|
249
|
+
severity: "warn",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
227
253
|
agents.push({
|
|
228
254
|
name: frontmatter.name,
|
|
229
255
|
description: frontmatter.description,
|
|
@@ -239,6 +265,7 @@ function loadAgentsFromDir(
|
|
|
239
265
|
color: typeof frontmatter.color === "string" && VALID_COLORS.includes(frontmatter.color as any)
|
|
240
266
|
? frontmatter.color as AgentColor
|
|
241
267
|
: undefined,
|
|
268
|
+
timeout,
|
|
242
269
|
systemPrompt: body,
|
|
243
270
|
source,
|
|
244
271
|
filePath,
|
package/extensions/background.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
12
12
|
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import type { SubAgentResult, SubAgentProgress } from "./runner.ts";
|
|
15
|
-
import { isFailedResult, getResultOutput, getFinalOutput } from "./runner.ts";
|
|
15
|
+
import { isFailedResult, getResultOutput, getFinalOutput, formatPatchBlock } from "./runner.ts";
|
|
16
16
|
import type { threadStore as ThreadStoreType } from "./threads.ts";
|
|
17
17
|
import type { AgentScope } from "./agents.ts";
|
|
18
18
|
import { parseStructuredResult } from "./result.ts";
|
|
@@ -59,6 +59,7 @@ export interface BackgroundDeps {
|
|
|
59
59
|
onHeartbeatDetails: () => BackgroundDetails,
|
|
60
60
|
onHeartbeat: () => void,
|
|
61
61
|
isReadOnly?: boolean,
|
|
62
|
+
merge?: "3way",
|
|
62
63
|
) => Promise<SubAgentResult>;
|
|
63
64
|
threadStore: typeof ThreadStoreType;
|
|
64
65
|
}
|
|
@@ -97,7 +98,7 @@ export interface TaskSnapshot {
|
|
|
97
98
|
completedAt?: number;
|
|
98
99
|
elapsedMs: number;
|
|
99
100
|
threadId: string;
|
|
100
|
-
result?: { output: string; model?: string; usage?: unknown };
|
|
101
|
+
result?: { output: string; model?: string; usage?: unknown; patchLines?: number };
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export function snapshotTask(task: BackgroundTask, now = Date.now()): TaskSnapshot {
|
|
@@ -112,7 +113,7 @@ export function snapshotTask(task: BackgroundTask, now = Date.now()): TaskSnapsh
|
|
|
112
113
|
elapsedMs: now - task.startedAt,
|
|
113
114
|
threadId: task.threadId,
|
|
114
115
|
result: result
|
|
115
|
-
? { output: getResultOutput(result), model: result.model, usage: result.usage }
|
|
116
|
+
? { output: getResultOutput(result), model: result.model, usage: result.usage, patchLines: result.patch && result.patch !== "(no changes)" ? result.patch.split("\n").length : undefined }
|
|
116
117
|
: undefined,
|
|
117
118
|
};
|
|
118
119
|
}
|
|
@@ -126,6 +127,7 @@ export interface StartBackgroundInput {
|
|
|
126
127
|
task: string;
|
|
127
128
|
cwd?: string;
|
|
128
129
|
timeout?: number;
|
|
130
|
+
merge?: "3way";
|
|
129
131
|
agentColor?: string;
|
|
130
132
|
toolCallId?: string;
|
|
131
133
|
deps: BackgroundDeps;
|
|
@@ -141,7 +143,7 @@ export interface StartBackgroundResult {
|
|
|
141
143
|
* On completion, delivers a follow-up turn to the parent session.
|
|
142
144
|
*/
|
|
143
145
|
export function startBackgroundTask(input: StartBackgroundInput): StartBackgroundResult {
|
|
144
|
-
const { agent, task, cwd, timeout, agentColor, toolCallId, deps } = input;
|
|
146
|
+
const { agent, task, cwd, timeout, merge, agentColor, toolCallId, deps } = input;
|
|
145
147
|
const taskId = `bg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
146
148
|
const controller = new AbortController();
|
|
147
149
|
const startedAt = Date.now();
|
|
@@ -191,6 +193,8 @@ export function startBackgroundTask(input: StartBackgroundInput): StartBackgroun
|
|
|
191
193
|
(progress) => deps.threadStore.updateProgress(thread.id, progress),
|
|
192
194
|
() => ({ mode: "single" as const, agentScope: "user" as const, projectAgentsDir: null, results: [] }),
|
|
193
195
|
() => deps.threadStore.refreshHeartbeat(thread.id),
|
|
196
|
+
undefined,
|
|
197
|
+
merge,
|
|
194
198
|
)
|
|
195
199
|
.then((result) => {
|
|
196
200
|
bgTask.result = result;
|
|
@@ -261,13 +265,16 @@ function deliverCompletion(task: BackgroundTask, result: SubAgentResult, deps: B
|
|
|
261
265
|
const phase = task.status;
|
|
262
266
|
const summary = output.split("\n").slice(0, 1)[0]!.slice(0, 200);
|
|
263
267
|
const elapsedMs = (task.completedAt ?? Date.now()) - task.startedAt;
|
|
268
|
+
// Worktree patch must reach the parent model in the follow-up turn (P0).
|
|
269
|
+
const patchBlock = formatPatchBlock(result);
|
|
270
|
+
const body = failed
|
|
271
|
+
? `Background task ${task.id} (${task.agent}) ${phase}.\n\n${getResultOutput(result)}`
|
|
272
|
+
: `Background task ${task.id} (${task.agent}) completed.\n\n${output}`;
|
|
264
273
|
|
|
265
274
|
deps.pi.sendMessage(
|
|
266
275
|
{
|
|
267
276
|
customType: "pi-subagent-complete",
|
|
268
|
-
content:
|
|
269
|
-
? `Background task ${task.id} (${task.agent}) ${phase}.\n\n${getResultOutput(result)}`
|
|
270
|
-
: `Background task ${task.id} (${task.agent}) completed.\n\n${output}`,
|
|
277
|
+
content: patchBlock ? `${body}\n\n${patchBlock}` : body,
|
|
271
278
|
display: true,
|
|
272
279
|
details: {
|
|
273
280
|
task_id: task.id,
|
package/extensions/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, for
|
|
|
31
31
|
import {
|
|
32
32
|
type SubAgentProgress,
|
|
33
33
|
type SubAgentResult,
|
|
34
|
+
formatPatchBlock,
|
|
34
35
|
getFinalOutput,
|
|
35
36
|
getResultOutput,
|
|
36
37
|
isFailedResult,
|
|
@@ -40,7 +41,7 @@ import {
|
|
|
40
41
|
} from "./runner.ts";
|
|
41
42
|
import {
|
|
42
43
|
flushWarnings,
|
|
43
|
-
|
|
44
|
+
isRetryableModelResult,
|
|
44
45
|
normalizeTimeout,
|
|
45
46
|
resolveSafeCwd,
|
|
46
47
|
validateAgentTools,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
MAX_PARALLEL_TASKS,
|
|
53
54
|
MAX_CHAIN_LENGTH,
|
|
54
55
|
MAX_INSTRUCTIONS_LENGTH,
|
|
56
|
+
HARD_TIMEOUT_MS,
|
|
55
57
|
} from "./security.ts";
|
|
56
58
|
import {
|
|
57
59
|
aggregateUsage,
|
|
@@ -111,6 +113,7 @@ const TaskItem = Type.Object({
|
|
|
111
113
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
112
114
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
113
115
|
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
116
|
+
merge: Type.Optional(StringEnum(["3way"] as const, { description: "With a worktree-sandboxed agent, apply its diff to the parent checkout via git apply --3way after it completes. Conflicts are reported, not resolved. Default: patch returned only." })),
|
|
114
117
|
});
|
|
115
118
|
|
|
116
119
|
const ChainItem = Type.Object({
|
|
@@ -118,6 +121,7 @@ const ChainItem = Type.Object({
|
|
|
118
121
|
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
119
122
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
120
123
|
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
124
|
+
merge: Type.Optional(StringEnum(["3way"] as const, { description: "With a worktree-sandboxed agent, apply its diff to the parent checkout via git apply --3way after the step completes. Conflicts are reported, not resolved." })),
|
|
121
125
|
});
|
|
122
126
|
|
|
123
127
|
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
@@ -157,6 +161,7 @@ const SubagentParams = Type.Object({
|
|
|
157
161
|
// See Security model section in README.
|
|
158
162
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
|
|
159
163
|
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout for the whole run, in ms; resets on activity, aborts on silence. Default 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). Lifetime cap: 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
164
|
+
merge: Type.Optional(StringEnum(["3way"] as const, { description: "Single mode: with a worktree-sandboxed agent, apply its diff to the parent checkout via git apply --3way after it completes. Conflicts are reported, not resolved. Default: patch returned only." })),
|
|
160
165
|
instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
|
|
161
166
|
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
162
167
|
});
|
|
@@ -596,6 +601,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
601
|
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
597
602
|
"For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
|
|
598
603
|
"Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
|
|
604
|
+
"Worktree-sandboxed agents return their diff as a patch block in the result; pass merge: \"3way\" to auto-apply it to the parent checkout (git apply --3way; conflicts reported, never silently resolved).",
|
|
599
605
|
"Use /subagent list to list all available agents, /subagent <name> for agent details, /subagent @role for role detail.",
|
|
600
606
|
],
|
|
601
607
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -671,6 +677,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
671
677
|
];
|
|
672
678
|
if (snap.result) {
|
|
673
679
|
lines.push(`Output: ${String(snap.result.output).slice(0, 2000)}`);
|
|
680
|
+
if (snap.result.patchLines) lines.push(`Patch: ${snap.result.patchLines} diff lines (worktree)`);
|
|
674
681
|
} else {
|
|
675
682
|
lines.push("(still running — no final output yet)");
|
|
676
683
|
}
|
|
@@ -872,6 +879,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
872
879
|
heartbeatDetails?: () => SubagentDetails,
|
|
873
880
|
onHeartbeat?: () => void,
|
|
874
881
|
isReadOnly?: boolean,
|
|
882
|
+
merge?: "3way",
|
|
875
883
|
): Promise<SubAgentResult> {
|
|
876
884
|
const agent = agents.find((a) => a.name === agentName);
|
|
877
885
|
|
|
@@ -912,12 +920,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
912
920
|
let tools: string[];
|
|
913
921
|
let loadExtensions: boolean;
|
|
914
922
|
let effectiveTimeoutMs: number | undefined;
|
|
923
|
+
let effectiveHardMs: number | undefined;
|
|
915
924
|
let safeCwd: string;
|
|
916
925
|
try {
|
|
917
926
|
const resolved = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
|
|
918
927
|
tools = resolved.tools;
|
|
919
928
|
loadExtensions = resolved.loadExtensions;
|
|
920
|
-
|
|
929
|
+
// Precedence: per-call timeout > agent frontmatter default > global default.
|
|
930
|
+
// The hard lifetime cap must never be shorter than the idle window
|
|
931
|
+
// (same invariant as the env-var clamp in security.ts) — an agent
|
|
932
|
+
// with timeout: 45 under a 20-min default cap would otherwise be
|
|
933
|
+
// hard-killed mid-stream while visibly producing deltas.
|
|
934
|
+
effectiveTimeoutMs = resolveChildTimeout(timeoutMs ?? (agent.timeout ? agent.timeout * 60 * 1_000 : undefined), params.timeout);
|
|
935
|
+
effectiveHardMs = Math.max(HARD_TIMEOUT_MS, effectiveTimeoutMs ?? 0);
|
|
921
936
|
safeCwd = resolveChildCwd(cwd);
|
|
922
937
|
} catch (err: unknown) {
|
|
923
938
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -958,6 +973,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
958
973
|
runSubAgent({
|
|
959
974
|
cwd: safeCwd,
|
|
960
975
|
sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
976
|
+
merge: agent.sandbox === "worktree" ? merge : undefined,
|
|
961
977
|
systemPrompt: params.instructions
|
|
962
978
|
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
|
|
963
979
|
: agent.systemPrompt,
|
|
@@ -969,6 +985,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
969
985
|
modelRegistry,
|
|
970
986
|
signal: parentSignal,
|
|
971
987
|
timeoutMs: effectiveTimeoutMs,
|
|
988
|
+
hardTimeoutMs: effectiveHardMs,
|
|
972
989
|
agentName,
|
|
973
990
|
thinkingLevel,
|
|
974
991
|
onMessage: onProgress,
|
|
@@ -976,7 +993,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
976
993
|
loadExtensions,
|
|
977
994
|
projectTrusted,
|
|
978
995
|
}),
|
|
979
|
-
isRateLimited: (result) =>
|
|
996
|
+
isRateLimited: (result) => isRetryableModelResult(result),
|
|
980
997
|
onExhausted: (reason, triedModels, remaining) => {
|
|
981
998
|
const exhaustedStderr = reason === "no-model"
|
|
982
999
|
? [
|
|
@@ -1035,6 +1052,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1035
1052
|
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
1036
1053
|
() => makeDetails("chain")(results),
|
|
1037
1054
|
() => threadStore.refreshHeartbeat(thread.id),
|
|
1055
|
+
undefined,
|
|
1056
|
+
step.merge,
|
|
1038
1057
|
);
|
|
1039
1058
|
threadStore.updateThread(thread.id, {
|
|
1040
1059
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -1055,6 +1074,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1055
1074
|
// Include successful previous step outputs in the error content
|
|
1056
1075
|
const prevCount = i;
|
|
1057
1076
|
let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
|
|
1077
|
+
const failedPatch = formatPatchBlock(result);
|
|
1078
|
+
if (failedPatch) contentText += `\n\n${failedPatch}`;
|
|
1058
1079
|
if (prevCount > 0) {
|
|
1059
1080
|
const prevSummaries = results
|
|
1060
1081
|
.slice(0, prevCount)
|
|
@@ -1076,16 +1097,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1076
1097
|
|
|
1077
1098
|
if (onUpdate) {
|
|
1078
1099
|
onUpdate({
|
|
1079
|
-
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
|
1100
|
+
content: [{ type: "text", text: [getFinalOutput(result.messages) || "(no output)", formatPatchBlock(result)].filter(Boolean).join("\n\n") }],
|
|
1080
1101
|
details: makeDetails("chain")(results),
|
|
1081
1102
|
});
|
|
1082
1103
|
}
|
|
1083
1104
|
}
|
|
1084
1105
|
|
|
1085
1106
|
const last = results[results.length - 1];
|
|
1107
|
+
// Chain deliverable: final output plus every step's worktree patch,
|
|
1108
|
+
// labeled so the parent can merge them in order.
|
|
1109
|
+
const chainPatches = results
|
|
1110
|
+
.map((r, i) => (formatPatchBlock(r) ? `Step ${i + 1} (${r.agent}): ${formatPatchBlock(r)}` : ""))
|
|
1111
|
+
.filter(Boolean)
|
|
1112
|
+
.join("\n\n");
|
|
1086
1113
|
return {
|
|
1087
1114
|
content: [
|
|
1088
|
-
{ type: "text", text: getFinalOutput(last.messages) || "(no output)" },
|
|
1115
|
+
{ type: "text", text: [getFinalOutput(last.messages) || "(no output)", chainPatches].filter(Boolean).join("\n\n") },
|
|
1089
1116
|
],
|
|
1090
1117
|
details: makeDetails("chain")(results),
|
|
1091
1118
|
};
|
|
@@ -1195,6 +1222,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1195
1222
|
(progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
|
|
1196
1223
|
() => makeDetails("parallel")([...allResults]),
|
|
1197
1224
|
() => threadStore.refreshHeartbeat(parallelThreads[index].id),
|
|
1225
|
+
undefined,
|
|
1226
|
+
t.merge,
|
|
1198
1227
|
);
|
|
1199
1228
|
allResults[index] = result;
|
|
1200
1229
|
threadStore.updateThread(parallelThreads[index].id, {
|
|
@@ -1219,7 +1248,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1219
1248
|
const status = isFailedResult(r)
|
|
1220
1249
|
? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
|
|
1221
1250
|
: "completed";
|
|
1222
|
-
|
|
1251
|
+
const patch = formatPatchBlock(r);
|
|
1252
|
+
return `### [${r.agent}] ${status}\n\n${output}${patch ? `\n\n${patch}` : ""}`;
|
|
1223
1253
|
});
|
|
1224
1254
|
|
|
1225
1255
|
let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
|
|
@@ -1247,6 +1277,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1247
1277
|
task: params.task,
|
|
1248
1278
|
cwd: params.cwd,
|
|
1249
1279
|
timeout: params.timeout,
|
|
1280
|
+
merge: params.merge,
|
|
1250
1281
|
agentColor: agentToThemeColor(params.agent),
|
|
1251
1282
|
toolCallId: _toolCallId,
|
|
1252
1283
|
deps: { pi, ctx, runOne, threadStore },
|
|
@@ -1274,6 +1305,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1274
1305
|
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
1275
1306
|
() => makeDetails("single")([]),
|
|
1276
1307
|
() => threadStore.refreshHeartbeat(thread.id),
|
|
1308
|
+
undefined,
|
|
1309
|
+
params.merge,
|
|
1277
1310
|
);
|
|
1278
1311
|
threadStore.updateThread(thread.id, {
|
|
1279
1312
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -1293,11 +1326,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1293
1326
|
|
|
1294
1327
|
if (isError) {
|
|
1295
1328
|
const errorMsg = getResultOutput(result);
|
|
1329
|
+
const patch = formatPatchBlock(result);
|
|
1296
1330
|
return {
|
|
1297
1331
|
content: [
|
|
1298
1332
|
{
|
|
1299
1333
|
type: "text",
|
|
1300
|
-
text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
|
|
1334
|
+
text: `Agent ${result.stopReason || "failed"}: ${errorMsg}${patch ? `\n\n${patch}` : ""}`,
|
|
1301
1335
|
},
|
|
1302
1336
|
],
|
|
1303
1337
|
details: makeDetails("single")([result]),
|
|
@@ -1307,7 +1341,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1307
1341
|
|
|
1308
1342
|
return {
|
|
1309
1343
|
content: [
|
|
1310
|
-
{ type: "text", text: getFinalOutput(result.messages) || "(no output)" },
|
|
1344
|
+
{ type: "text", text: [getFinalOutput(result.messages) || "(no output)", formatPatchBlock(result)].filter(Boolean).join("\n\n") },
|
|
1311
1345
|
],
|
|
1312
1346
|
details: makeDetails("single")([result]),
|
|
1313
1347
|
};
|
package/extensions/runner.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import type { Message, Model } from "@earendil-works/pi-ai";
|
|
18
18
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
|
+
import * as fs from "node:fs/promises";
|
|
20
|
+
import * as os from "node:os";
|
|
19
21
|
import * as path from "node:path";
|
|
20
22
|
import {
|
|
21
23
|
createAgentSession,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
type SubagentStatus,
|
|
33
35
|
DEFAULT_TIMEOUT_MS,
|
|
34
36
|
HARD_TIMEOUT_MS,
|
|
37
|
+
truncateParallelOutput,
|
|
35
38
|
} from "./security.ts";
|
|
36
39
|
|
|
37
40
|
// ---------------------------------------------------------------------------
|
|
@@ -122,6 +125,10 @@ export interface SubAgentResult {
|
|
|
122
125
|
errorMessage?: string;
|
|
123
126
|
/** Unified diff of changes made in an isolated worktree (sandbox: "worktree"). */
|
|
124
127
|
patch?: string;
|
|
128
|
+
/** Set when merge:"3way" was requested: outcome of applying the patch to the parent checkout. */
|
|
129
|
+
mergeStatus?: "applied" | "conflict";
|
|
130
|
+
/** git apply error excerpt when mergeStatus === "conflict". */
|
|
131
|
+
mergeError?: string;
|
|
125
132
|
/** Canonical result status (added in 0.6.0). */
|
|
126
133
|
status?: SubagentStatus;
|
|
127
134
|
/** Wall-clock duration of the run, set by runSubAgent (Date.now() - startedAt). */
|
|
@@ -146,6 +153,8 @@ export async function runSubAgent(options: {
|
|
|
146
153
|
model: Model<any>;
|
|
147
154
|
/** "worktree" runs the child in an isolated git worktree; the resulting diff is returned as result.patch. */
|
|
148
155
|
sandbox?: "worktree";
|
|
156
|
+
/** With sandbox:"worktree", apply the captured diff to the parent checkout via `git apply --3way` after the run. */
|
|
157
|
+
merge?: "3way";
|
|
149
158
|
/** Pi 0.80.10's canonical credential/model runtime. */
|
|
150
159
|
modelRuntime?: unknown;
|
|
151
160
|
/** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
|
|
@@ -177,7 +186,7 @@ export async function runSubAgent(options: {
|
|
|
177
186
|
cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
|
|
178
187
|
agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
|
|
179
188
|
timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
|
|
180
|
-
loadExtensions = false, projectTrusted = true, sandbox, exec,
|
|
189
|
+
loadExtensions = false, projectTrusted = true, sandbox, merge, exec,
|
|
181
190
|
} = options;
|
|
182
191
|
const result: SubAgentResult = {
|
|
183
192
|
agent: agentName, task, exitCode: 0, messages: [], stderr: "",
|
|
@@ -229,11 +238,13 @@ export async function runSubAgent(options: {
|
|
|
229
238
|
// ponytail: falls back to the in-process cwd when git is unavailable — the
|
|
230
239
|
// worktree is an isolation optimization, not a hard requirement.
|
|
231
240
|
let worktreeDir: string | undefined;
|
|
241
|
+
let worktreeRepoRoot: string | undefined;
|
|
232
242
|
let childCwd = cwd;
|
|
233
243
|
if (sandbox === "worktree") {
|
|
234
244
|
const wt = await createWorktree(cwd, exec);
|
|
235
245
|
if (wt.ok) {
|
|
236
246
|
worktreeDir = wt.path;
|
|
247
|
+
worktreeRepoRoot = wt.repoRoot;
|
|
237
248
|
childCwd = wt.path!;
|
|
238
249
|
} else if (wt.error) {
|
|
239
250
|
result.stderr = `Worktree unavailable (${wt.error}); running in workspace.`;
|
|
@@ -261,7 +272,10 @@ export async function runSubAgent(options: {
|
|
|
261
272
|
const finish = (fn: () => void) => { if (!done) { done = true; unsubscribe?.(); fn(); } };
|
|
262
273
|
unsubscribe = session.subscribe((event) => {
|
|
263
274
|
try {
|
|
264
|
-
// Any SDK session
|
|
275
|
+
// Any SDK session event — including message_update streaming
|
|
276
|
+
// deltas (thinking_delta/text_delta) — is real child activity
|
|
277
|
+
// and resets the idle timer. Only a stream with NO events at all
|
|
278
|
+
// for timeoutMs indicates a hung child.
|
|
265
279
|
armIdle(); onProgress?.(snapshot(event.type));
|
|
266
280
|
if (event.type === "message_end") {
|
|
267
281
|
const msg = event.message as AgentMessage;
|
|
@@ -297,7 +311,25 @@ export async function runSubAgent(options: {
|
|
|
297
311
|
if (worktreeDir) {
|
|
298
312
|
// Capture the child's changes as a unified diff before tearing down.
|
|
299
313
|
const diff = await captureWorktreeDiff(cwd, worktreeDir, exec);
|
|
300
|
-
if (diff.ok)
|
|
314
|
+
if (diff.ok) {
|
|
315
|
+
result.patch = diff.diff;
|
|
316
|
+
// Opt-in 3-way merge: apply while the worktree still exists — the
|
|
317
|
+
// blobs `git add -A` staged live in the shared object DB and
|
|
318
|
+
// `git apply --3way` needs them. Applies are serialized (see
|
|
319
|
+
// applyWorktreePatch3way) so parallel siblings cannot race. Only
|
|
320
|
+
// successfully completed children auto-merge: a timed-out or
|
|
321
|
+
// aborted child's half-finished edits must not land on the parent
|
|
322
|
+
// checkout — their patch is still delivered for manual review.
|
|
323
|
+
if (merge === "3way" && result.status === "success" && worktreeRepoRoot && diff.diff && diff.diff !== "(no changes)") {
|
|
324
|
+
const applied = await applyWorktreePatch3way(worktreeRepoRoot, diff.diff, exec);
|
|
325
|
+
if (applied.ok) {
|
|
326
|
+
result.mergeStatus = "applied";
|
|
327
|
+
} else {
|
|
328
|
+
result.mergeStatus = "conflict";
|
|
329
|
+
result.mergeError = applied.stderr.slice(0, 1000);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
301
333
|
else if (diff.error) result.stderr = result.stderr ? `${result.stderr}; diff unavailable (${diff.error})` : `Diff unavailable (${diff.error})`;
|
|
302
334
|
}
|
|
303
335
|
return result;
|
|
@@ -323,7 +355,7 @@ export async function runSubAgent(options: {
|
|
|
323
355
|
// ---------------------------------------------------------------------------
|
|
324
356
|
|
|
325
357
|
/** Minimal exec fallback (child_process spawn) used when no exec is injected. */
|
|
326
|
-
async function defaultExec(
|
|
358
|
+
export async function defaultExec(
|
|
327
359
|
command: string,
|
|
328
360
|
args: string[],
|
|
329
361
|
options?: { cwd?: string; timeout?: number },
|
|
@@ -358,7 +390,7 @@ async function runGit(
|
|
|
358
390
|
export async function createWorktree(
|
|
359
391
|
cwd: string,
|
|
360
392
|
exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
|
|
361
|
-
): Promise<{ ok: boolean; path?: string; error?: string }> {
|
|
393
|
+
): Promise<{ ok: boolean; path?: string; repoRoot?: string; error?: string }> {
|
|
362
394
|
try {
|
|
363
395
|
const root = await runGit(cwd, ["rev-parse", "--show-toplevel"], exec);
|
|
364
396
|
if (!root.ok) return { ok: false, error: root.stderr.trim() || "not a git repo" };
|
|
@@ -368,7 +400,7 @@ export async function createWorktree(
|
|
|
368
400
|
const wtPath = path.join(repoRoot, ".pi-worktrees", id);
|
|
369
401
|
const add = await runGit(repoRoot, ["worktree", "add", "--detach", wtPath, "HEAD"], exec);
|
|
370
402
|
if (!add.ok) return { ok: false, error: add.stderr.trim() || "git worktree add failed" };
|
|
371
|
-
return { ok: true, path: wtPath };
|
|
403
|
+
return { ok: true, path: wtPath, repoRoot };
|
|
372
404
|
} catch (error) {
|
|
373
405
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
374
406
|
}
|
|
@@ -403,6 +435,56 @@ export async function removeWorktree(
|
|
|
403
435
|
} catch { /* best effort */ }
|
|
404
436
|
}
|
|
405
437
|
|
|
438
|
+
/** Serialized `git apply` on the parent checkout: parallel siblings finishing
|
|
439
|
+
* at the same time must not race the merge (OMP's withRepoLock equivalent). */
|
|
440
|
+
let applyChain: Promise<unknown> = Promise.resolve();
|
|
441
|
+
|
|
442
|
+
/** Apply a unified diff to the repo root via `git apply --3way`. Never throws;
|
|
443
|
+
* ok:false means conflict/failure — the caller reports it and still delivers
|
|
444
|
+
* the patch text for manual merging. */
|
|
445
|
+
export async function applyWorktreePatch3way(
|
|
446
|
+
repoRoot: string,
|
|
447
|
+
diff: string,
|
|
448
|
+
exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
|
|
449
|
+
): Promise<{ ok: boolean; stderr: string }> {
|
|
450
|
+
const run = applyChain.then(async (): Promise<{ ok: boolean; stderr: string }> => {
|
|
451
|
+
const tmp = path.join(os.tmpdir(), `pi-subagent-apply-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.patch`);
|
|
452
|
+
try {
|
|
453
|
+
// git apply interprets paths relative to cwd — write the diff to a file
|
|
454
|
+
// and apply at the repo root. captureWorktreeDiff trims trailing
|
|
455
|
+
// whitespace; git rejects a patch whose last line has no newline, so
|
|
456
|
+
// restore it (CRLF-aware: trimming "...\r\n" must not downgrade the
|
|
457
|
+
// final line ending to LF).
|
|
458
|
+
const patchText = diff.endsWith("\n") ? diff : `${diff}${diff.includes("\r\n") ? "\r\n" : "\n"}`;
|
|
459
|
+
await fs.writeFile(tmp, patchText, "utf8");
|
|
460
|
+
const res = await runGit(repoRoot, ["apply", "--3way", tmp], exec);
|
|
461
|
+
return { ok: res.ok, stderr: (res.stderr || res.stdout).trim() };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return { ok: false, stderr: error instanceof Error ? error.message : String(error) };
|
|
464
|
+
} finally {
|
|
465
|
+
await fs.rm(tmp, { force: true }).catch(() => { /* best effort */ });
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
applyChain = run.then(() => undefined, () => undefined);
|
|
469
|
+
return run;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Model-facing patch block for a worktree result; "" when there is no patch.
|
|
473
|
+
* Applied merges omit the diff (the parent checkout already has it);
|
|
474
|
+
* conflicts include it plus the git apply error. */
|
|
475
|
+
export function formatPatchBlock(result: SubAgentResult): string {
|
|
476
|
+
if (!result.patch || result.patch === "(no changes)") return "";
|
|
477
|
+
const lines = result.patch.split("\n").length;
|
|
478
|
+
if (result.mergeStatus === "applied") {
|
|
479
|
+
return `🌿 worktree patch (${lines} diff lines) — already applied to the parent checkout via 3-way merge.`;
|
|
480
|
+
}
|
|
481
|
+
const head = result.mergeStatus === "conflict"
|
|
482
|
+
? `🌿 worktree patch (${lines} diff lines) — 3-way merge CONFLICTED; resolve the markers in the files, or merge manually:`
|
|
483
|
+
: `🌿 worktree patch (${lines} diff lines) — merge explicitly via apply_patch or \`git apply\` (or pass merge:"3way" next time):`;
|
|
484
|
+
const err = result.mergeError ? `\n\ngit apply: ${result.mergeError}` : "";
|
|
485
|
+
return `${head}${err}\n\n${truncateParallelOutput(result.patch)}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
406
488
|
|
|
407
489
|
export function getFinalOutput(messages: Message[]): string {
|
|
408
490
|
for (let i = messages.length - 1; i >= 0; i--) {
|
package/extensions/security.ts
CHANGED
|
@@ -668,6 +668,10 @@ const RATE_LIMIT_PATTERNS = [
|
|
|
668
668
|
/capacity[\s_]exceeded/i,
|
|
669
669
|
/usage[\s_]limit/i,
|
|
670
670
|
/overloaded/i,
|
|
671
|
+
// Credential-cooldown errors (e.g. "All credentials for model X are
|
|
672
|
+
// cooling down") are capacity signals too: the model may be unavailable
|
|
673
|
+
// NOW but the next fallback candidate may not be in cooldown.
|
|
674
|
+
/credential[\s_]cooldown|cooldown[\s_]window|cooling[\s_]down/i,
|
|
671
675
|
];
|
|
672
676
|
|
|
673
677
|
/**
|
|
@@ -679,3 +683,23 @@ const RATE_LIMIT_PATTERNS = [
|
|
|
679
683
|
export function isRateLimitError(message: string): boolean {
|
|
680
684
|
return RATE_LIMIT_PATTERNS.some(p => p.test(message));
|
|
681
685
|
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Whether a sub-agent result should trigger model fallback: a rate-limit /
|
|
689
|
+
* credential-cooldown error, or an IDLE timeout (a stalled stream with zero
|
|
690
|
+
* events for the whole idle window — a known router/provider failure mode).
|
|
691
|
+
* The HARD lifetime cap is terminal: a task that ran 20 min is genuinely
|
|
692
|
+
* huge, not a stall, and retrying it on another model doubles the cost.
|
|
693
|
+
*/
|
|
694
|
+
export function isRetryableModelResult(result: {
|
|
695
|
+
errorMessage?: string;
|
|
696
|
+
stopReason?: string;
|
|
697
|
+
status?: string;
|
|
698
|
+
}): boolean {
|
|
699
|
+
if (result.errorMessage && isRateLimitError(result.errorMessage)) return true;
|
|
700
|
+
return (
|
|
701
|
+
result.stopReason === "timeout" &&
|
|
702
|
+
result.status === "timeout" &&
|
|
703
|
+
!String(result.errorMessage ?? "").includes("Hard timeout")
|
|
704
|
+
);
|
|
705
|
+
}
|
package/package.json
CHANGED