privateer-agent 0.12.34 → 0.12.36
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 +7 -0
- package/bin/apply-patches.mjs +29 -14
- package/bin/privateer-launch.mjs +244 -5
- package/bin/privateer-splash.mjs +2 -4
- package/bin/privateer-subagent.mjs +2 -0
- package/bin/startup-cache.d.mts +1 -0
- package/bin/startup-cache.mjs +19 -0
- package/extensions/privateer-models.ts +36 -9
- package/extensions/privateer-posture.ts +30 -1
- package/package.json +2 -2
- package/patches/@earendil-works+pi-ai+0.84.4.patch +46 -0
- package/patches/@earendil-works+pi-coding-agent+0.84.4.patch +88 -21
- package/patches/pi-background-tasks+2.4.2.patch +110 -0
- package/src/boot.ts +7 -0
- package/src/bridge/engineAdapter.ts +47 -49
- package/src/config/privacyDisabled.ts +45 -0
- package/src/config/privacyPolicy.ts +135 -40
- package/src/engine/contextBudget.ts +74 -0
- package/src/engine/errors.ts +89 -2
- package/src/engine/events.ts +3 -1
- package/src/providers/account.ts +205 -27
- package/src/providers/defaultModel.ts +4 -2
- package/src/remote/relayClient.ts +2 -0
|
@@ -36,6 +36,8 @@ import { cliPalette, detectScheme } from "../ui/palette.ts";
|
|
|
36
36
|
import { accountPosture, privateerChannel } from "../providers/account.ts";
|
|
37
37
|
import { hasCredentials } from "../auth/privateer.ts";
|
|
38
38
|
import { writePiDefaultModel } from "../providers/defaultModel.ts";
|
|
39
|
+
import { privacyDisabled, setPrivacyDisabled } from "./privacyDisabled.ts";
|
|
40
|
+
import { updatePostureBadge } from "../../extensions/privateer-posture.ts";
|
|
39
41
|
|
|
40
42
|
// Color-coat pi-privacy's auto-redact notice as the moat acting on your behalf: the red
|
|
41
43
|
// no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
|
|
@@ -67,6 +69,7 @@ export function sharedPrivacyOptions() {
|
|
|
67
69
|
const ambient = loadConfig();
|
|
68
70
|
return {
|
|
69
71
|
...ambient,
|
|
72
|
+
disabled: privacyDisabled,
|
|
70
73
|
// The account channel's real posture. pi-privacy ships a `privateer` provider, but it
|
|
71
74
|
// is the PUBLIC developer-key channel (sk-priv-…, server-proxied and unverifiable
|
|
72
75
|
// end-to-end), so from the package alone every privateer/* model floors to
|
|
@@ -131,69 +134,161 @@ export function sharedPrivacyOptions() {
|
|
|
131
134
|
// Registered from HERE rather than from the extension file, for the reason this module
|
|
132
135
|
// exists at all: the factory-built sessions and the discovered extension must not drift.
|
|
133
136
|
function registerPrivacyCommand(pi: any): void {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const value = rest.join(" ").trim();
|
|
140
|
-
const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
|
|
141
|
-
|
|
142
|
-
if (verb === "allow" || verb === "unallow") {
|
|
143
|
-
if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
|
|
144
|
-
const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
|
|
145
|
-
return notify(r.message, r.ok ? "info" : "warning");
|
|
146
|
-
}
|
|
147
|
-
if (verb) return notify(`unknown option "${verb}" — usage: /privacy [allow <value> | unallow <value>]`, "warning");
|
|
137
|
+
const handler = async (args: string, ctx: any) => {
|
|
138
|
+
const raw = String(args ?? "").trim();
|
|
139
|
+
const [verb, ...rest] = raw.split(/\s+/);
|
|
140
|
+
const value = rest.join(" ").trim();
|
|
141
|
+
const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
|
|
148
142
|
|
|
143
|
+
if (verb === "off" || verb === "disable") {
|
|
144
|
+
setPrivacyDisabled(true);
|
|
145
|
+
await updatePostureBadge(ctx);
|
|
146
|
+
return notify(
|
|
147
|
+
"⚑ pi-privacy is completely OFF for this session. Outbound requests will not be scanned or gated for PII, " +
|
|
148
|
+
"and tool exfiltration, result, and downgrade guards are disabled. Run /privacy on to restore.",
|
|
149
|
+
"warning",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (verb === "on" || verb === "enable" || verb === "restore") {
|
|
154
|
+
setPrivacyDisabled(false);
|
|
155
|
+
await updatePostureBadge(ctx);
|
|
156
|
+
return notify(
|
|
157
|
+
"pi-privacy is ON for this session. PII scanning, tool exfiltration guards, and privacy checks restored.",
|
|
158
|
+
"info",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (verb === "status") {
|
|
163
|
+
const state = privacyDisabled() ? "OFF (disabled for this session)" : "ON (active)";
|
|
149
164
|
const mine = piiAllowEntries();
|
|
150
|
-
notify(
|
|
165
|
+
return notify(
|
|
151
166
|
[
|
|
167
|
+
`pi-privacy status: ${state}`,
|
|
152
168
|
mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
|
|
153
|
-
"Reserved shapes (example.com, loopback, noreply@…) are allowed by default
|
|
154
|
-
"
|
|
155
|
-
"an IPv4 block (10.0.0.0/8), or any exact/globbed value.",
|
|
169
|
+
"Reserved shapes (example.com, loopback, noreply@…) are allowed by default.",
|
|
170
|
+
"Commands: /privacy off | /privacy on | /privacy status | /privacy allow <value> | /privacy unallow <value>",
|
|
156
171
|
].join("\n"),
|
|
157
172
|
"info",
|
|
158
173
|
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (verb === "allow" || verb === "unallow") {
|
|
177
|
+
if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
|
|
178
|
+
const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
|
|
179
|
+
return notify(r.message, r.ok ? "info" : "warning");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (verb && verb !== "help") {
|
|
183
|
+
return notify(`unknown option "${verb}" — usage: /privacy [off | on | status | allow <value> | unallow <value>]`, "warning");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const state = privacyDisabled() ? "OFF (disabled for this session)" : "ON (active)";
|
|
187
|
+
const mine = piiAllowEntries();
|
|
188
|
+
notify(
|
|
189
|
+
[
|
|
190
|
+
`pi-privacy status: ${state}`,
|
|
191
|
+
mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
|
|
192
|
+
"Reserved shapes (example.com, loopback, noreply@…) are allowed by default and not listed here.",
|
|
193
|
+
"Commands:",
|
|
194
|
+
" /privacy off — turn off pi-privacy completely for this session",
|
|
195
|
+
" /privacy on — restore pi-privacy protections",
|
|
196
|
+
" /privacy status — show current status and allowlist",
|
|
197
|
+
" /privacy allow <value> — add value to PII allowlist",
|
|
198
|
+
" /privacy unallow <value> — remove value from PII allowlist",
|
|
199
|
+
].join("\n"),
|
|
200
|
+
"info",
|
|
201
|
+
);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const spec = {
|
|
205
|
+
description: "Manage privacy protections: /privacy [off | on | status | allow <value> | unallow <value>]",
|
|
206
|
+
handler,
|
|
207
|
+
getArgumentCompletions: (prefix: string) => {
|
|
208
|
+
const p = prefix.trim().toLowerCase();
|
|
209
|
+
const verbs = ["off", "on", "status", "allow", "unallow"];
|
|
210
|
+
return verbs.filter((v) => v.startsWith(p)).map((v) => ({ value: v, label: v }));
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
pi.registerCommand?.("privacy", spec);
|
|
215
|
+
pi.registerCommand?.("pi-privacy", {
|
|
216
|
+
...spec,
|
|
217
|
+
description: "Alias for /privacy: /pi-privacy [off | on | status | allow <value> | unallow <value>]",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Intercept pi-privacy's event registrations and commands so that when pi-privacy is completely
|
|
223
|
+
* disabled via `/privacy off` (or `PRIVATEER_PRIVACY_OFF=1` / `PI_PRIVACY_OFF=1`),
|
|
224
|
+
* all gating, prompt, and redaction hooks are cleanly bypassed.
|
|
225
|
+
*/
|
|
226
|
+
function gatingPrivacyPi(pi: any): any {
|
|
227
|
+
const on = (event: string, handler: any) => {
|
|
228
|
+
if (typeof pi?.on !== "function") return;
|
|
229
|
+
if (typeof handler !== "function") return pi.on(event, handler);
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
event === "before_provider_request" ||
|
|
233
|
+
event === "tool_call" ||
|
|
234
|
+
event === "user_bash" ||
|
|
235
|
+
event === "tool_result" ||
|
|
236
|
+
event === "model_select"
|
|
237
|
+
) {
|
|
238
|
+
return pi.on(event, async (ev: any, ctx: any) => {
|
|
239
|
+
if (privacyDisabled()) return undefined;
|
|
240
|
+
return handler(ev, ctx);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return pi.on(event, handler);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const registerCommand = (name: string, spec: any) => {
|
|
248
|
+
if (typeof pi?.registerCommand !== "function") return;
|
|
249
|
+
if (name === "pii" && spec && typeof spec.handler === "function") {
|
|
250
|
+
const orig = spec.handler;
|
|
251
|
+
const wrappedHandler = async (args: string, ctx: any) => {
|
|
252
|
+
const raw = String(args ?? "").trim().toLowerCase();
|
|
253
|
+
const [verb] = raw.split(/\s+/);
|
|
254
|
+
if (verb === "off" || verb === "disable") {
|
|
255
|
+
setPrivacyDisabled(true);
|
|
256
|
+
await updatePostureBadge(ctx);
|
|
257
|
+
} else if (verb === "on" || verb === "enable" || verb === "restore") {
|
|
258
|
+
setPrivacyDisabled(false);
|
|
259
|
+
await updatePostureBadge(ctx);
|
|
260
|
+
}
|
|
261
|
+
return orig(args, ctx);
|
|
262
|
+
};
|
|
263
|
+
return pi.registerCommand(name, { ...spec, handler: wrappedHandler });
|
|
264
|
+
}
|
|
265
|
+
return pi.registerCommand(name, spec);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return new Proxy(pi, {
|
|
269
|
+
get(target, prop, receiver) {
|
|
270
|
+
if (prop === "on") return on;
|
|
271
|
+
if (prop === "registerCommand") return registerCommand;
|
|
272
|
+
const value = Reflect.get(target, prop, receiver);
|
|
273
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
159
274
|
},
|
|
160
275
|
});
|
|
161
276
|
}
|
|
162
277
|
|
|
163
278
|
/**
|
|
164
279
|
* The privacy half of the moat as ONE factory: pi-privacy configured the Privateer way,
|
|
165
|
-
* plus the `/privacy` command that maintains its allowlist. Both routes into pi-privacy
|
|
280
|
+
* plus the `/privacy` command that maintains its allowlist and toggles. Both routes into pi-privacy
|
|
166
281
|
* (src/config/moat.ts and extensions/privateer-privacy.ts) use this, so neither can end
|
|
167
282
|
* up with the gate but not its escape hatch.
|
|
168
283
|
*/
|
|
169
284
|
export function privacyExtension() {
|
|
170
285
|
const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
|
|
171
286
|
return function privateerPrivacyCore(pi: any): void {
|
|
172
|
-
privacy(persistingModelPicks(pi));
|
|
287
|
+
privacy(gatingPrivacyPi(persistingModelPicks(pi)));
|
|
173
288
|
registerPrivacyCommand(pi);
|
|
174
289
|
};
|
|
175
290
|
}
|
|
176
291
|
|
|
177
|
-
/**
|
|
178
|
-
* pi-privacy's `/models` picker switched the live session and stopped there, so the
|
|
179
|
-
* pick lasted exactly as long as the terminal and the next one launched on whatever
|
|
180
|
-
* settings.json still said. Pi persists a switch only for a caller that passes
|
|
181
|
-
* `{ persist: true }`, and the extension api it reaches setModel through forwards no
|
|
182
|
-
* options at all (see savedPiDefaultSpec in providers/defaultModel.ts).
|
|
183
|
-
*
|
|
184
|
-
* Pi's own selector splits the two — Enter switches for the session, ctrl+s makes it
|
|
185
|
-
* the default. OUR picker has no second key to press and never advertised a
|
|
186
|
-
* distinction, so a pick made in it means "this is my model": we persist it.
|
|
187
|
-
*
|
|
188
|
-
* Wrapped here rather than fixed inside pi-privacy so the rule sits with the rest of
|
|
189
|
-
* what Privateer configures on that extension, and so it stays scoped to the picker.
|
|
190
|
-
* The brand extension's sign-in switch shares the same api and deliberately does NOT
|
|
191
|
-
* get this — writePiDefaultModel says why an automatic switch must not persist.
|
|
192
|
-
*
|
|
193
|
-
* A Proxy, not a spread: the api object is a plain literal today, but a spread of a
|
|
194
|
-
* class instance would silently drop every method and take pi-privacy down with it.
|
|
195
|
-
* Forwarding leaves that failure mode impossible.
|
|
196
|
-
*/
|
|
197
292
|
function persistingModelPicks(pi: any): any {
|
|
198
293
|
if (typeof pi?.setModel !== "function") return pi;
|
|
199
294
|
const setModel = async (model: any) => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// How many output tokens a turn may ask for, once the prompt has taken its share.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the patched clampMaxTokensToContext in pi-ai (api/simple-options.js) —
|
|
4
|
+
// that copy is what actually runs; this one is where the behaviour is specified and
|
|
5
|
+
// tested. See patches/@earendil-works+pi-ai+0.84.4.patch.
|
|
6
|
+
//
|
|
7
|
+
// ── The bug this exists to fix ───────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// Stock pi computes the answer budget as
|
|
10
|
+
//
|
|
11
|
+
// available = contextWindow − estimateContextTokens(context) − 4096
|
|
12
|
+
// maxTokens = min(asked, max(1, available))
|
|
13
|
+
//
|
|
14
|
+
// and that floor is literally `1`. So the moment the estimate reaches the declared
|
|
15
|
+
// window, every turn asks the provider for ONE token. The request still goes out,
|
|
16
|
+
// still sends the whole prompt, and is still billed in full — and comes back with a
|
|
17
|
+
// single token and `finish_reason: "length"`, which the TUI renders as "Response was
|
|
18
|
+
// truncated before completion." Compaction runs once, and if the estimate is still
|
|
19
|
+
// over (it usually is — see below) the session is stuck there: every turn burns a
|
|
20
|
+
// full-price prompt to produce nothing. That is the "it won't resume" failure, and
|
|
21
|
+
// it is reachable on any long session.
|
|
22
|
+
//
|
|
23
|
+
// Two things conspire to reach it early. `estimateContextTokens` is a chars/4
|
|
24
|
+
// approximation over the whole context — system prompt and tool schemas included —
|
|
25
|
+
// so it runs ahead of what the provider actually counts; and Privateer registers
|
|
26
|
+
// every account model with a flat `contextWindow: 128000` (providers/account.ts
|
|
27
|
+
// seedModel) because /api/models does not publish per-model windows. A model with a
|
|
28
|
+
// larger real window therefore hits this ceiling while it still has room.
|
|
29
|
+
//
|
|
30
|
+
// ── The fix ──────────────────────────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// Floor the budget at a usable answer instead of at 1. The floor only ever raises
|
|
33
|
+
// `available`; it never raises what the caller asked for, so a deliberately small
|
|
34
|
+
// request (a summariser, a title) keeps its own number — the `min` still wins.
|
|
35
|
+
//
|
|
36
|
+
// It is safe against the window because the floor is smaller than the 4096-token
|
|
37
|
+
// safety margin already subtracted above: when `available` lands between 0 and the
|
|
38
|
+
// floor, the tokens we hand back were inside that margin all along.
|
|
39
|
+
//
|
|
40
|
+
// When `available` is genuinely negative the context really does not fit, and the
|
|
41
|
+
// honest outcome is the provider saying so — a context-length error, which pi
|
|
42
|
+
// classifies via isContextOverflow and answers with compaction, the path designed
|
|
43
|
+
// for exactly this. That is strictly better than the silent one-token stub it
|
|
44
|
+
// replaces: same cost, but the agent recovers instead of looking hung.
|
|
45
|
+
export const CONTEXT_SAFETY_TOKENS = 4096;
|
|
46
|
+
|
|
47
|
+
// pi-ai's own MIN_ANSWER_TOKENS — the floor it already reserves when a thinking
|
|
48
|
+
// budget shares the response ceiling (clampThinkingBudgetToAnswerRoom). Below this a
|
|
49
|
+
// turn cannot produce a usable answer or even a complete tool call, so asking for
|
|
50
|
+
// less is never worth a request.
|
|
51
|
+
//
|
|
52
|
+
// The patch REFERENCES that constant rather than declaring its own: simple-options.js
|
|
53
|
+
// exports it from the same module scope, so a second top-level `const` of that name
|
|
54
|
+
// is a SyntaxError — and since every provider imports this module, that one would not
|
|
55
|
+
// fail quietly, it would stop the CLI from starting. This copy exists so the value is
|
|
56
|
+
// stated and tested here; tests/contextBudget.test.ts pins both halves together.
|
|
57
|
+
export const MIN_ANSWER_TOKENS = 1024;
|
|
58
|
+
|
|
59
|
+
export interface ContextBudgetModel {
|
|
60
|
+
contextWindow: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clampMaxTokensToContext(
|
|
64
|
+
model: ContextBudgetModel,
|
|
65
|
+
estimatedContextTokens: number,
|
|
66
|
+
maxTokens: number,
|
|
67
|
+
): number {
|
|
68
|
+
// An unknown window (0 or absent) means "we cannot reason about room" — pass the
|
|
69
|
+
// ask through rather than inventing a ceiling. Stock behaviour, kept verbatim:
|
|
70
|
+
// the 1 here guards a zero/negative ask, it is not the floor this patch changes.
|
|
71
|
+
if (!(model.contextWindow > 0)) return Math.max(1, maxTokens);
|
|
72
|
+
const available = model.contextWindow - estimatedContextTokens - CONTEXT_SAFETY_TOKENS;
|
|
73
|
+
return Math.min(maxTokens, Math.max(MIN_ANSWER_TOKENS, available));
|
|
74
|
+
}
|
package/src/engine/errors.ts
CHANGED
|
@@ -37,7 +37,7 @@ const HOST_LABELS: Record<string, string> = {
|
|
|
37
37
|
// provider's machine code (which comes out of the response body) — these mean the
|
|
38
38
|
// request never got a response at all.
|
|
39
39
|
const NETWORK_ERRNO =
|
|
40
|
-
/^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/;
|
|
40
|
+
/^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|UND_ERR_BODY_TIMEOUT|UND_ERR_HEADERS_TIMEOUT)$/;
|
|
41
41
|
|
|
42
42
|
// Pull structured fields off an unknown error without trusting any one shape.
|
|
43
43
|
//
|
|
@@ -117,6 +117,30 @@ export function isAccountCapCode(code: string | null | undefined): boolean {
|
|
|
117
117
|
return typeof code === "string" && CAP_CODE.test(code);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Balance guidance for the signed-in Privateer account channel ONLY. A bare 429
|
|
122
|
+
* is not evidence of an empty balance; neither is a daily cap or provider quota.
|
|
123
|
+
* The SDK may retain the JSON body (flat or OpenAI-shaped), or just its message.
|
|
124
|
+
*/
|
|
125
|
+
export function describeAccountBalanceError(text: string): DescribedError | null {
|
|
126
|
+
if (!/^\s*(?:402|429)\b/.test(text) || /<!doctype html|<html[\s>]/i.test(text)) return null;
|
|
127
|
+
const bodyStart = text.indexOf("{");
|
|
128
|
+
const facts = bodyStart < 0 ? {} : extract({ responseBody: text.slice(bodyStart) });
|
|
129
|
+
const code = facts.code;
|
|
130
|
+
const message = facts.providerMessage ?? text;
|
|
131
|
+
const balanceCode = /^(?:INSUFFICIENT_(?:BALANCE|FUNDS|CREDITS?)|(?:BALANCE|CREDITS?)_EXHAUSTED)$/i;
|
|
132
|
+
const balanceText = /\b(?:insufficient (?:credit(?:s| balance)?|balance|funds)|out of credits?|(?:credit balance|balance|credits?) (?:is |are )?exhausted)\b/i;
|
|
133
|
+
// If a machine code exists, it decides — don't turn a rate limit whose help
|
|
134
|
+
// text mentions credits into a billing failure.
|
|
135
|
+
if (code ? !balanceCode.test(code) : !balanceText.test(message)) return null;
|
|
136
|
+
return {
|
|
137
|
+
message: "Your Privateer account has insufficient balance.",
|
|
138
|
+
// Use our known destination, never a URL copied from an untrusted error body.
|
|
139
|
+
hint: "Top up at https://privateer.pro/top-up, then try again.",
|
|
140
|
+
retryable: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
120
144
|
// ── Oversized / non-API error bodies ─────────────────────────────────────────
|
|
121
145
|
//
|
|
122
146
|
// An inference endpoint does not always answer as an API. Put a WAF, a proxy or a
|
|
@@ -217,6 +241,35 @@ export function isHardHttpFailure(text: string | null | undefined): boolean {
|
|
|
217
241
|
return status >= 400 && status < 500 && !TRANSIENT_CLIENT_STATUS.has(status);
|
|
218
242
|
}
|
|
219
243
|
|
|
244
|
+
// ── Idle timeouts: a stalled stream, said plainly ────────────────────────────
|
|
245
|
+
//
|
|
246
|
+
// undici guards a connection that has gone quiet with `bodyTimeout` (the gap
|
|
247
|
+
// between response chunks) and `headersTimeout` (the wait for the first byte). Pi
|
|
248
|
+
// wires both from `httpIdleTimeoutMs` (default 5 min, see http-dispatcher.js). When
|
|
249
|
+
// either fires, undici throws BodyTimeoutError / HeadersTimeoutError with an exact,
|
|
250
|
+
// stable message and code — and NO HTTP status.
|
|
251
|
+
//
|
|
252
|
+
// That absence is the whole problem. `describeErrorText` only describes text that
|
|
253
|
+
// opens with a 3-digit status, so the bare string "Body Timeout Error" printed
|
|
254
|
+
// through untouched; and pi's retry regex matches the word "timeout", so the agent
|
|
255
|
+
// silently re-sent the entire turn across its whole retry budget — a single stalled
|
|
256
|
+
// stream became three 5-minute waits before the user saw any message at all. The
|
|
257
|
+
// transport timing out is a fact, not a guess from a body substring, so recognise
|
|
258
|
+
// it exactly (message OR code) and treat it as terminal: retrying re-bills the same
|
|
259
|
+
// request and, on a genuinely stalled provider, will stall again.
|
|
260
|
+
const IDLE_TIMEOUT_CODE = /^UND_ERR_(?:BODY|HEADERS)_TIMEOUT$/;
|
|
261
|
+
const IDLE_TIMEOUT_TEXT = /(?:^|\b)(?:Body|Headers) Timeout Error\b/i;
|
|
262
|
+
|
|
263
|
+
export function isIdleTimeoutError(text: string | null | undefined): boolean {
|
|
264
|
+
const s = typeof text === "string" ? text : "";
|
|
265
|
+
return IDLE_TIMEOUT_TEXT.test(s) || IDLE_TIMEOUT_CODE.test(s.trim());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const IDLE_TIMEOUT_DESCRIPTION: DescribedError = {
|
|
269
|
+
message: "The provider stopped responding — the connection went idle.",
|
|
270
|
+
hint: "No data arrived for the whole idle-timeout window, so the turn was cut off. Send it again, or run /model to switch providers — a slow model can be given longer under /settings → HTTP idle timeout.",
|
|
271
|
+
};
|
|
272
|
+
|
|
220
273
|
function rawMessage(err: unknown): string {
|
|
221
274
|
if (err instanceof Error) return err.message;
|
|
222
275
|
if (typeof err === "string") return err;
|
|
@@ -294,6 +347,17 @@ export function describeError(err: unknown): DescribedError {
|
|
|
294
347
|
hint: "Check the model id — run /model to switch.",
|
|
295
348
|
});
|
|
296
349
|
}
|
|
350
|
+
if (
|
|
351
|
+
status === 413 ||
|
|
352
|
+
facts.code === "PAYLOAD_TOO_LARGE" ||
|
|
353
|
+
facts.code === "REQUEST_TOO_LARGE" ||
|
|
354
|
+
/payload too large|request_too_large|request entity too large/i.test(text)
|
|
355
|
+
) {
|
|
356
|
+
return out({
|
|
357
|
+
message: `Request payload too large${forProvider} (413).`,
|
|
358
|
+
hint: "The conversation history or attached files exceed the server limit. Start a new session (/new) or remove large attachments.",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
297
361
|
if (status === 429) {
|
|
298
362
|
return out({
|
|
299
363
|
message: `Rate limited${forProvider} (429).`,
|
|
@@ -308,6 +372,12 @@ export function describeError(err: unknown): DescribedError {
|
|
|
308
372
|
retryable: true,
|
|
309
373
|
});
|
|
310
374
|
}
|
|
375
|
+
// A stream that stopped producing data. This carries no status, so without this
|
|
376
|
+
// branch it fell through to the generic "Network error" below, and pi re-sent the
|
|
377
|
+
// whole turn on the substring "timeout". Say what actually happened, and stop.
|
|
378
|
+
if (isIdleTimeoutError(text) || isIdleTimeoutError(facts.errno)) {
|
|
379
|
+
return out(IDLE_TIMEOUT_DESCRIPTION);
|
|
380
|
+
}
|
|
311
381
|
if (
|
|
312
382
|
facts.errno != null ||
|
|
313
383
|
/fetch failed|cannot connect|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)
|
|
@@ -445,7 +515,18 @@ export function retryDelayMs(
|
|
|
445
515
|
export function describeErrorText(text: string | null | undefined): DescribedError | null {
|
|
446
516
|
const s = typeof text === "string" ? text : "";
|
|
447
517
|
const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
|
|
448
|
-
if (!Number.isFinite(status))
|
|
518
|
+
if (!Number.isFinite(status)) {
|
|
519
|
+
// No leading status: the one message we can still describe with certainty is a
|
|
520
|
+
// transport idle timeout, which undici names exactly. Everything else is printed
|
|
521
|
+
// unchanged, as before.
|
|
522
|
+
if (isIdleTimeoutError(s)) {
|
|
523
|
+
return {
|
|
524
|
+
message: redactText(IDLE_TIMEOUT_DESCRIPTION.message),
|
|
525
|
+
hint: IDLE_TIMEOUT_DESCRIPTION.hint,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
449
530
|
|
|
450
531
|
if (status === 429) {
|
|
451
532
|
const stated = retryAfterMs(s);
|
|
@@ -469,6 +550,12 @@ export function describeErrorText(text: string | null | undefined): DescribedErr
|
|
|
469
550
|
hint: "Check the model id — run /model to switch.",
|
|
470
551
|
};
|
|
471
552
|
}
|
|
553
|
+
if (status === 413 || /413\b|payload too large|request_too_large|request entity too large/i.test(s)) {
|
|
554
|
+
return {
|
|
555
|
+
message: redactText(`Request payload too large (413).`),
|
|
556
|
+
hint: "The conversation history or attached files exceed the server limit. Start a new session (/new) or remove large attachments.",
|
|
557
|
+
};
|
|
558
|
+
}
|
|
472
559
|
if (status >= 500) {
|
|
473
560
|
return {
|
|
474
561
|
message: redactText(compactProviderError(s)),
|
package/src/engine/events.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type EngineEvent =
|
|
|
18
18
|
| { type: "text"; text: string }
|
|
19
19
|
| { type: "reasoning"; text: string }
|
|
20
20
|
| { type: "tool-call"; id: string; name: string; input: unknown }
|
|
21
|
+
// Latest output snapshot; transports bound it, without settling the running card.
|
|
22
|
+
| { type: "tool-progress"; id: string; name: string; output: string }
|
|
21
23
|
| { type: "tool-result"; id: string; name: string; output: unknown }
|
|
22
24
|
| { type: "tool-error"; id: string; name: string; error: string }
|
|
23
25
|
| { type: "step-finish" }
|
|
@@ -42,7 +44,7 @@ export type EngineEvent =
|
|
|
42
44
|
// engine — the engine has no clock and no view of the turn as a whole.
|
|
43
45
|
//
|
|
44
46
|
// It exists because silence is ambiguous and the app cannot resolve it: a tool
|
|
45
|
-
// call
|
|
47
|
+
// call may emit at its start and its end with nothing in between, so a fifteen-minute
|
|
46
48
|
// build, a stalled model socket and a dead agent all look identical from the far
|
|
47
49
|
// side of the relay. This is the frame that says which. `tool`/`toolMs` name what
|
|
48
50
|
// is being waited on when something is.
|