@mjasnikovs/pi-task 0.13.9 → 0.13.11
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.
|
@@ -21,6 +21,14 @@ export interface ChildResult {
|
|
|
21
21
|
aborted: boolean;
|
|
22
22
|
/** Extracted assistant text (only populated in json-events mode). */
|
|
23
23
|
text?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The model-failure cause, when the child's final turn carried
|
|
26
|
+
* stopReason "error" (provider/connection failure after pi exhausted its
|
|
27
|
+
* own retries). pi emits this as an agent_end whose assistant message has
|
|
28
|
+
* empty text, so without it the phase would mis-report "produced no output".
|
|
29
|
+
* Only populated in json-events mode.
|
|
30
|
+
*/
|
|
31
|
+
modelError?: string;
|
|
24
32
|
}
|
|
25
33
|
export interface ToolCall {
|
|
26
34
|
name: string;
|
|
@@ -74,6 +82,13 @@ export declare class JsonEventSink {
|
|
|
74
82
|
private readonly onLoopKill;
|
|
75
83
|
/** Final assistant text from the agent_end event, if one arrived. */
|
|
76
84
|
finalText: string;
|
|
85
|
+
/**
|
|
86
|
+
* Set when the final assistant turn carried stopReason "error" — i.e. the
|
|
87
|
+
* model/provider failed (disconnect, fetch failed, socket hang up, 5xx)
|
|
88
|
+
* after pi exhausted its internal retries. Holds the provider's errorMessage
|
|
89
|
+
* so callers can report the real cause instead of an empty completion.
|
|
90
|
+
*/
|
|
91
|
+
modelError: string | undefined;
|
|
77
92
|
private textDeltaAccum;
|
|
78
93
|
private buf;
|
|
79
94
|
constructor(opts: RunChildJsonEventsOptions,
|
|
@@ -24,6 +24,13 @@ export class JsonEventSink {
|
|
|
24
24
|
onLoopKill;
|
|
25
25
|
/** Final assistant text from the agent_end event, if one arrived. */
|
|
26
26
|
finalText = '';
|
|
27
|
+
/**
|
|
28
|
+
* Set when the final assistant turn carried stopReason "error" — i.e. the
|
|
29
|
+
* model/provider failed (disconnect, fetch failed, socket hang up, 5xx)
|
|
30
|
+
* after pi exhausted its internal retries. Holds the provider's errorMessage
|
|
31
|
+
* so callers can report the real cause instead of an empty completion.
|
|
32
|
+
*/
|
|
33
|
+
modelError = undefined;
|
|
27
34
|
textDeltaAccum = '';
|
|
28
35
|
// json-events lines can split across data chunks; this holds the trailing
|
|
29
36
|
// partial line between feeds so events spanning a boundary still parse. We
|
|
@@ -97,7 +104,20 @@ export class JsonEventSink {
|
|
|
97
104
|
if (t === 'agent_end' && Array.isArray(evt.messages)) {
|
|
98
105
|
for (let i = evt.messages.length - 1; i >= 0; i--) {
|
|
99
106
|
const m = evt.messages[i];
|
|
100
|
-
if (m
|
|
107
|
+
if (!m || m.role !== 'assistant')
|
|
108
|
+
continue;
|
|
109
|
+
// A model failure (disconnect, fetch failed, socket hang up, 5xx
|
|
110
|
+
// after pi's own retries) arrives as an assistant message with
|
|
111
|
+
// stopReason "error" and the real cause in errorMessage — but
|
|
112
|
+
// EMPTY text content. Capture it so the phase reports the actual
|
|
113
|
+
// failure instead of the useless "produced no output".
|
|
114
|
+
if (m.stopReason === 'error'
|
|
115
|
+
&& typeof m.errorMessage === 'string'
|
|
116
|
+
&& m.errorMessage.length > 0
|
|
117
|
+
&& this.modelError === undefined) {
|
|
118
|
+
this.modelError = m.errorMessage;
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(m.content)) {
|
|
101
121
|
const texts = [];
|
|
102
122
|
for (const c of m.content) {
|
|
103
123
|
if (c?.type === 'text' && typeof c.text === 'string') {
|
|
@@ -186,7 +206,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
186
206
|
if (sink)
|
|
187
207
|
sink.flush();
|
|
188
208
|
const text = sink ? sink.text : undefined;
|
|
189
|
-
resolve({
|
|
209
|
+
resolve({
|
|
210
|
+
stdout,
|
|
211
|
+
stderr,
|
|
212
|
+
exitCode: code ?? 0,
|
|
213
|
+
aborted,
|
|
214
|
+
text,
|
|
215
|
+
modelError: sink?.modelError
|
|
216
|
+
});
|
|
190
217
|
});
|
|
191
218
|
proc.on('error', () => {
|
|
192
219
|
resolve({ stdout, stderr, exitCode: 1, aborted });
|
|
@@ -16,6 +16,8 @@ export interface PhaseRunResult {
|
|
|
16
16
|
loopHit?: LoopHit;
|
|
17
17
|
/** Set when the assistant text contains an unexecuted, leaked tool call. */
|
|
18
18
|
leakedToolCall?: string;
|
|
19
|
+
/** Set when the child's final turn failed with stopReason "error" (model/provider failure). */
|
|
20
|
+
modelError?: string;
|
|
19
21
|
}
|
|
20
22
|
export declare function childArgs(tools: string, prompt: string): string[];
|
|
21
23
|
export declare const USER_CANCELLED = "__user_cancelled__";
|
|
@@ -74,6 +76,22 @@ export declare class LoopExhaustedError extends Error {
|
|
|
74
76
|
readonly history: LoopHit[];
|
|
75
77
|
constructor(phase: string, history: LoopHit[]);
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Thrown when a phase child's final turn failed with stopReason "error" — the
|
|
81
|
+
* model/provider died (local model disconnect, fetch failed, socket hang up,
|
|
82
|
+
* provider 5xx) after pi exhausted its own internal retries. pi reports this as
|
|
83
|
+
* an agent_end with empty assistant text, which would otherwise surface as the
|
|
84
|
+
* misleading "produced no output"; this names the real cause instead.
|
|
85
|
+
*
|
|
86
|
+
* Fail-fast: not retried at the pi-task layer. pi already retried the retryable
|
|
87
|
+
* cases; re-spawning a fresh child against the same dead endpoint only burns
|
|
88
|
+
* time and buries the real error. Restart the model/provider, then resume.
|
|
89
|
+
*/
|
|
90
|
+
export declare class ModelError extends Error {
|
|
91
|
+
readonly phase: string;
|
|
92
|
+
readonly cause: string;
|
|
93
|
+
constructor(phase: string, cause: string);
|
|
94
|
+
}
|
|
77
95
|
/**
|
|
78
96
|
* Thrown when a phase child repeatedly wrote a tool call as plain text (a markup
|
|
79
97
|
* dialect pi's harness didn't parse) instead of invoking it. The call never ran,
|
|
@@ -69,6 +69,7 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
|
|
|
69
69
|
exitCode: result.exitCode,
|
|
70
70
|
stderr: result.stderr.trim(),
|
|
71
71
|
loopHit,
|
|
72
|
+
modelError: result.modelError,
|
|
72
73
|
// A tool call the model wrote as text (wrong dialect) never executed and
|
|
73
74
|
// sailed past the structured-event guards above; flag it so the wrappers
|
|
74
75
|
// can re-prompt instead of accepting the unexecuted call. Only meaningful
|
|
@@ -90,8 +91,21 @@ export async function runPhaseChild(deps, name, tools, prompt) {
|
|
|
90
91
|
if (r.exitCode !== 0) {
|
|
91
92
|
throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
|
|
92
93
|
}
|
|
94
|
+
if (r.modelError) {
|
|
95
|
+
// The model/provider failed (pi exited 0 with an stopReason "error"
|
|
96
|
+
// turn). Surface the real cause and fail fast — pi already retried.
|
|
97
|
+
throw new ModelError(name, r.modelError);
|
|
98
|
+
}
|
|
93
99
|
if (r.text.trim().length === 0) {
|
|
94
|
-
|
|
100
|
+
// An empty completion (exit 0, no assistant text, no stderr) is almost
|
|
101
|
+
// always transient — a model/API error swallowed inside --mode json,
|
|
102
|
+
// not a repeatable mistake — so re-spawn rather than fail the phase.
|
|
103
|
+
// There's nothing to correct, so we carry no hint. Reuses the leak
|
|
104
|
+
// retry budget: MAX_LEAK_RETRIES+1 attempts, then surface the error.
|
|
105
|
+
if (attempt === MAX_LEAK_RETRIES) {
|
|
106
|
+
throw new Error(`${name} child produced no output${r.stderr ? ' — stderr: ' + r.stderr : ''}`);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
95
109
|
}
|
|
96
110
|
if (r.leakedToolCall) {
|
|
97
111
|
if (attempt === MAX_LEAK_RETRIES) {
|
|
@@ -153,8 +167,21 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
|
|
|
153
167
|
if (r.exitCode !== 0) {
|
|
154
168
|
throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
|
|
155
169
|
}
|
|
170
|
+
if (r.modelError) {
|
|
171
|
+
// The model/provider failed (pi exited 0 with a stopReason "error"
|
|
172
|
+
// turn). Surface the real cause and fail fast — pi already retried.
|
|
173
|
+
throw new ModelError(name, r.modelError);
|
|
174
|
+
}
|
|
156
175
|
if (r.text.trim().length === 0) {
|
|
157
|
-
|
|
176
|
+
// An empty completion (exit 0, no assistant text, no stderr) is almost
|
|
177
|
+
// always transient — a model/API error swallowed inside --mode json,
|
|
178
|
+
// not a repeatable mistake — so re-spawn rather than fail the phase.
|
|
179
|
+
// Nothing to correct, so leave nextHint as-is. Reuses the strike
|
|
180
|
+
// budget shared with loop/leak restarts: MAX_LOOP_RESTARTS+1 attempts.
|
|
181
|
+
if (strike === MAX_LOOP_RESTARTS) {
|
|
182
|
+
throw new Error(`${name} child produced no output${r.stderr ? ' — stderr: ' + r.stderr : ''}`);
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
158
185
|
}
|
|
159
186
|
if (r.leakedToolCall) {
|
|
160
187
|
if (strike === MAX_LOOP_RESTARTS) {
|
|
@@ -194,6 +221,28 @@ export class LoopExhaustedError extends Error {
|
|
|
194
221
|
this.name = 'LoopExhaustedError';
|
|
195
222
|
}
|
|
196
223
|
}
|
|
224
|
+
// ─── ModelError ──────────────────────────────────────────────────────────────
|
|
225
|
+
/**
|
|
226
|
+
* Thrown when a phase child's final turn failed with stopReason "error" — the
|
|
227
|
+
* model/provider died (local model disconnect, fetch failed, socket hang up,
|
|
228
|
+
* provider 5xx) after pi exhausted its own internal retries. pi reports this as
|
|
229
|
+
* an agent_end with empty assistant text, which would otherwise surface as the
|
|
230
|
+
* misleading "produced no output"; this names the real cause instead.
|
|
231
|
+
*
|
|
232
|
+
* Fail-fast: not retried at the pi-task layer. pi already retried the retryable
|
|
233
|
+
* cases; re-spawning a fresh child against the same dead endpoint only burns
|
|
234
|
+
* time and buries the real error. Restart the model/provider, then resume.
|
|
235
|
+
*/
|
|
236
|
+
export class ModelError extends Error {
|
|
237
|
+
phase;
|
|
238
|
+
cause;
|
|
239
|
+
constructor(phase, cause) {
|
|
240
|
+
super(`${phase} child: model error — ${cause}`);
|
|
241
|
+
this.phase = phase;
|
|
242
|
+
this.cause = cause;
|
|
243
|
+
this.name = 'ModelError';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
197
246
|
// ─── LeakedToolCallError ─────────────────────────────────────────────────────
|
|
198
247
|
/**
|
|
199
248
|
* Thrown when a phase child repeatedly wrote a tool call as plain text (a markup
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { updateTaskFrontMatter } from './task-io.js';
|
|
6
6
|
import { flashTerminalWidget } from './widget.js';
|
|
7
|
-
import { LoopExhaustedError, LeakedToolCallError, USER_CANCELLED } from './child-runner.js';
|
|
7
|
+
import { LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
|
|
8
8
|
// ─── Classifier ──────────────────────────────────────────────────────────────
|
|
9
9
|
export function classifyFailure(err, aborted) {
|
|
10
10
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -29,6 +29,15 @@ export function classifyFailure(err, aborted) {
|
|
|
29
29
|
level: 'error'
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
if (err instanceof ModelError) {
|
|
33
|
+
return {
|
|
34
|
+
state: 'failed',
|
|
35
|
+
reason: `model_error in ${err.phase}: ${err.cause.slice(0, 160)}`,
|
|
36
|
+
flash: 'model_error',
|
|
37
|
+
notify: `failed: ${err.phase} — model error: ${err.cause.slice(0, 120)}. Restart the model, then resume.`,
|
|
38
|
+
level: 'error'
|
|
39
|
+
};
|
|
40
|
+
}
|
|
32
41
|
if (msg === 'no_verify_block') {
|
|
33
42
|
return {
|
|
34
43
|
state: 'failed',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.11",
|
|
4
4
|
"description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|