@mjasnikovs/pi-task 0.38.32 → 0.39.1
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 +2 -2
- package/dist/config/group-args.d.ts +24 -9
- package/dist/config/group-args.js +38 -28
- package/dist/config/option-picker.d.ts +39 -11
- package/dist/config/option-picker.js +52 -12
- package/dist/config/reasoning.d.ts +10 -7
- package/dist/config/reasoning.js +19 -32
- package/dist/config/register.d.ts +66 -41
- package/dist/config/register.js +201 -162
- package/dist/shared/child-process.d.ts +34 -32
- package/dist/shared/child-process.js +44 -58
- package/dist/shared/command-watchdog.d.ts +12 -4
- package/dist/shared/command-watchdog.js +6 -7
- package/dist/shared/connection-error.d.ts +7 -0
- package/dist/shared/connection-error.js +65 -0
- package/dist/shared/model-endpoint.d.ts +12 -24
- package/dist/shared/model-endpoint.js +32 -82
- package/dist/shared/model-resolve.d.ts +105 -0
- package/dist/shared/model-resolve.js +97 -0
- package/dist/shared/reasoning-capability.d.ts +20 -0
- package/dist/shared/reasoning-capability.js +32 -1
- package/dist/shared/stall-probe.d.ts +51 -0
- package/dist/shared/stall-probe.js +79 -0
- package/dist/task/child-runner.d.ts +76 -278
- package/dist/task/child-runner.js +186 -722
- package/dist/task/context-usage.js +2 -7
- package/dist/task/failure-classifier.js +53 -81
- package/dist/task/gate-child.js +1 -1
- package/dist/task/impl-widget.d.ts +2 -0
- package/dist/task/impl-widget.js +4 -0
- package/dist/task/implementation-hold.d.ts +11 -0
- package/dist/task/implementation-hold.js +20 -0
- package/dist/task/implementation-scope.d.ts +24 -0
- package/dist/task/implementation-scope.js +34 -0
- package/dist/task/loop-detector.d.ts +13 -5
- package/dist/task/loop-detector.js +11 -5
- package/dist/task/model-hold-stash.js +4 -14
- package/dist/task/orchestrator.d.ts +1 -8
- package/dist/task/orchestrator.js +11 -34
- package/dist/task/phases.js +2 -2
- package/dist/task/stall-detector.d.ts +1 -1
- package/dist/task/stall-detector.js +1 -1
- package/dist/workers/model-warning.d.ts +4 -16
- package/dist/workers/model-warning.js +14 -70
- package/dist/workers/pi-worker-core.d.ts +65 -20
- package/dist/workers/pi-worker-core.js +109 -50
- package/dist/workers/reasoning-warning.js +2 -24
- package/dist/workers/worker-failure.d.ts +2 -0
- package/dist/workers/worker-failure.js +2 -1
- package/dist/workers/worker-kill.d.ts +30 -11
- package/dist/workers/worker-kill.js +68 -20
- package/dist/workers/worker-profiles.d.ts +20 -0
- package/dist/workers/worker-profiles.js +22 -9
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
|
|
2
2
|
import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
|
|
3
|
+
import { realStallTimerDeps, StallProbe } from './stall-probe.js';
|
|
3
4
|
import { workerChannel } from '../workers/worker-channels.js';
|
|
4
5
|
/** Grace period between SIGTERM and SIGKILL (ms). */
|
|
5
6
|
export const KILL_GRACE_MS = 5000;
|
|
@@ -12,6 +13,11 @@ export const CHILD_BASE_ARGS = [
|
|
|
12
13
|
'--no-context-files',
|
|
13
14
|
'--no-session'
|
|
14
15
|
];
|
|
16
|
+
/** The cause a signal was aborted with, when its owner attached one. */
|
|
17
|
+
function abortCause(reason) {
|
|
18
|
+
const tagged = reason;
|
|
19
|
+
return tagged?.by === 'command-timeout' ? reason : { by: 'aborted' };
|
|
20
|
+
}
|
|
15
21
|
// ─── JSON event-stream sink ──────────────────────────────────────────────────
|
|
16
22
|
/**
|
|
17
23
|
* Parses a child's `--mode json` event stream into assistant text plus side
|
|
@@ -179,7 +185,7 @@ export class JsonEventSink {
|
|
|
179
185
|
if (opts.onToolCall) {
|
|
180
186
|
const hit = opts.onToolCall({ name: tn, args: evt.args, toolCallId: id });
|
|
181
187
|
if (hit)
|
|
182
|
-
this.onLoopKill();
|
|
188
|
+
this.onLoopKill(hit);
|
|
183
189
|
}
|
|
184
190
|
return;
|
|
185
191
|
}
|
|
@@ -208,7 +214,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
208
214
|
return new Promise(resolve => {
|
|
209
215
|
let stdout = '';
|
|
210
216
|
let stderr = '';
|
|
211
|
-
let
|
|
217
|
+
let kill;
|
|
212
218
|
const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
|
|
213
219
|
// Deliver the prompt on stdin, not argv. A large prompt — an inlined design
|
|
214
220
|
// doc, say — exceeds the OS argv ceiling and the spawn fails outright rather
|
|
@@ -253,12 +259,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
253
259
|
// group already gone
|
|
254
260
|
}
|
|
255
261
|
};
|
|
256
|
-
// One kill path
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
// One kill path for every source: SIGTERM, then SIGKILL after a grace
|
|
263
|
+
// period if the child ignored the term. For a group-owning (model) child,
|
|
264
|
+
// ALSO sweep the group so anything it backgrounded dies with it —
|
|
265
|
+
// proc.kill hits only the leader, reapGroup the grandchildren. The FIRST
|
|
266
|
+
// cause wins: a stall kill's SIGTERM can trip the abort path behind it.
|
|
267
|
+
const killProc = (cause) => {
|
|
268
|
+
kill ??= cause;
|
|
262
269
|
proc.kill('SIGTERM');
|
|
263
270
|
if (ownGroup)
|
|
264
271
|
reapGroup('SIGTERM');
|
|
@@ -274,15 +281,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
274
281
|
// below structurally cannot catch. Suspended for the duration of a tool
|
|
275
282
|
// call: a 12-minute build legitimately emits nothing, and that window is
|
|
276
283
|
// the COMMAND watchdog's to police, not this one's.
|
|
277
|
-
let streamStalledIdleMs;
|
|
278
284
|
const streamWatch = opts?.mode === 'json-events' && (opts.streamInactivityMs ?? 0) > 0 ?
|
|
279
285
|
new StreamWatchdog({
|
|
280
286
|
getTimeoutMs: () => opts.streamInactivityMs,
|
|
281
287
|
...realStreamTimerDeps,
|
|
282
|
-
onFire: idleMs => {
|
|
283
|
-
streamStalledIdleMs = idleMs;
|
|
284
|
-
killProc();
|
|
285
|
-
}
|
|
288
|
+
onFire: idleMs => killProc({ by: 'stream-stall', idleMs })
|
|
286
289
|
})
|
|
287
290
|
: null;
|
|
288
291
|
streamWatch?.start();
|
|
@@ -307,41 +310,21 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
307
310
|
}
|
|
308
311
|
}
|
|
309
312
|
: opts;
|
|
310
|
-
const sink = sinkOpts ? new JsonEventSink(sinkOpts, killProc) : null;
|
|
311
|
-
// Dead-backend stall guard (json-events children only; see the option
|
|
312
|
-
// docs). Any output resets the window; a reachable probe also resets it
|
|
313
|
-
// so the next probe is a full window away, not every tick.
|
|
313
|
+
const sink = sinkOpts ? new JsonEventSink(sinkOpts, hit => killProc({ by: 'loop', hit })) : null;
|
|
314
|
+
// Dead-backend stall guard (json-events children only; see the option docs).
|
|
314
315
|
const stall = opts?.mode === 'json-events' ? opts.stall : undefined;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
.probe()
|
|
325
|
-
.then(reachable => {
|
|
326
|
-
probing = false;
|
|
327
|
-
if (reachable || stalled) {
|
|
328
|
-
lastActivity = Date.now();
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
stalled = true;
|
|
332
|
-
killProc();
|
|
333
|
-
})
|
|
334
|
-
.catch(() => {
|
|
335
|
-
// A probe that itself crashed proves nothing —
|
|
336
|
-
// benefit of the doubt, keep waiting.
|
|
337
|
-
probing = false;
|
|
338
|
-
lastActivity = Date.now();
|
|
339
|
-
});
|
|
340
|
-
}, Math.max(50, Math.min(stall.afterMs / 2, 15_000)))
|
|
341
|
-
: undefined;
|
|
316
|
+
const stallProbe = stall ?
|
|
317
|
+
new StallProbe({
|
|
318
|
+
afterMs: stall.afterMs,
|
|
319
|
+
probe: stall.probe,
|
|
320
|
+
...realStallTimerDeps,
|
|
321
|
+
onDead: () => killProc({ by: 'stalled' })
|
|
322
|
+
})
|
|
323
|
+
: null;
|
|
324
|
+
stallProbe?.start();
|
|
342
325
|
let firstByteFired = false;
|
|
343
326
|
proc.stdout?.on('data', (d) => {
|
|
344
|
-
|
|
327
|
+
stallProbe?.note();
|
|
345
328
|
streamWatch?.note();
|
|
346
329
|
if (!firstByteFired) {
|
|
347
330
|
firstByteFired = true;
|
|
@@ -378,10 +361,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
378
361
|
// research-worker slices the TAIL of stderr in two places and the HEAD in a
|
|
379
362
|
// third, and child-runner feeds the whole string into its failure message.
|
|
380
363
|
proc.stderr?.on('data', (d) => {
|
|
381
|
-
|
|
364
|
+
stallProbe?.note();
|
|
382
365
|
streamWatch?.note();
|
|
383
366
|
stderr += d.toString();
|
|
384
367
|
});
|
|
368
|
+
const onAbort = () => killProc(abortCause(signal?.reason));
|
|
385
369
|
// One idempotent settle path for close/error/abort. Detaching the abort
|
|
386
370
|
// listener here is the point. `{once: true}` fires-and-removes on an ACTUAL
|
|
387
371
|
// abort and at no other time, so a child that finishes normally leaves its
|
|
@@ -394,10 +378,9 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
394
378
|
// the caller's callbacks close over.
|
|
395
379
|
let settled = false;
|
|
396
380
|
const cleanup = () => {
|
|
397
|
-
|
|
398
|
-
clearInterval(stallTimer);
|
|
381
|
+
stallProbe?.stop();
|
|
399
382
|
streamWatch?.stop();
|
|
400
|
-
signal?.removeEventListener('abort',
|
|
383
|
+
signal?.removeEventListener('abort', onAbort);
|
|
401
384
|
};
|
|
402
385
|
const settle = (result) => {
|
|
403
386
|
cleanup();
|
|
@@ -422,23 +405,26 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
422
405
|
stdout,
|
|
423
406
|
stderr,
|
|
424
407
|
exitCode: code ?? 0,
|
|
425
|
-
aborted,
|
|
408
|
+
aborted: kill !== undefined,
|
|
409
|
+
...(kill ? { kill } : {}),
|
|
426
410
|
text,
|
|
427
|
-
modelError: sink?.modelError
|
|
428
|
-
...(stalled ? { stalled: true } : {}),
|
|
429
|
-
...(streamStalledIdleMs !== undefined ?
|
|
430
|
-
{ streamStalled: { idleMs: streamStalledIdleMs } }
|
|
431
|
-
: {})
|
|
411
|
+
modelError: sink?.modelError
|
|
432
412
|
});
|
|
433
413
|
});
|
|
434
414
|
proc.once('error', () => {
|
|
435
|
-
settle({
|
|
415
|
+
settle({
|
|
416
|
+
stdout,
|
|
417
|
+
stderr,
|
|
418
|
+
exitCode: 1,
|
|
419
|
+
aborted: kill !== undefined,
|
|
420
|
+
...(kill ? { kill } : {})
|
|
421
|
+
});
|
|
436
422
|
});
|
|
437
423
|
if (signal) {
|
|
438
424
|
if (signal.aborted)
|
|
439
|
-
|
|
425
|
+
onAbort();
|
|
440
426
|
else
|
|
441
|
-
signal.addEventListener('abort',
|
|
427
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
442
428
|
}
|
|
443
429
|
});
|
|
444
430
|
}
|
|
@@ -130,6 +130,15 @@ export interface CommandKill {
|
|
|
130
130
|
* so the fresh child knows which call it must not repeat unbounded. */
|
|
131
131
|
detail?: string;
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The kill, as the REASON the watchdog aborts its signal with. runChild reads
|
|
135
|
+
* it off the combined signal, so a command kill reaches `ChildResult.kill` by
|
|
136
|
+
* the same road every other guard's kill does — not through a side query the
|
|
137
|
+
* caller has to remember to make.
|
|
138
|
+
*/
|
|
139
|
+
export interface CommandKillReason extends CommandKill {
|
|
140
|
+
by: 'command-timeout';
|
|
141
|
+
}
|
|
133
142
|
/**
|
|
134
143
|
* The tool-call fields the child-side watchdog reads. Structural rather than
|
|
135
144
|
* `ToolCall` from child-process.ts, so this module keeps its zero imports and a
|
|
@@ -142,9 +151,9 @@ export interface WatchedToolCall {
|
|
|
142
151
|
}
|
|
143
152
|
/**
|
|
144
153
|
* Build the child-side command watchdog for ONE attempt: a per-tool-call timer
|
|
145
|
-
* machine whose `onFire` aborts `signal
|
|
146
|
-
* process-GROUP kill — reaping the hung command itself,
|
|
147
|
-
* holding it.
|
|
154
|
+
* machine whose `onFire` aborts `signal` WITH the kill as its reason, which
|
|
155
|
+
* runChild turns into a process-GROUP kill — reaping the hung command itself,
|
|
156
|
+
* not just the pi child holding it — and reports as `kill.by === 'command-timeout'`.
|
|
148
157
|
*
|
|
149
158
|
* LIMIT: the group kill only reaches processes still IN the group. A hung command
|
|
150
159
|
* that detached a daemon (setsid, nohup, a background dev server) leaves it
|
|
@@ -158,7 +167,6 @@ export interface WatchedToolCall {
|
|
|
158
167
|
export declare function commandWatch(timeoutMs: number): {
|
|
159
168
|
onStart: (call: WatchedToolCall) => void;
|
|
160
169
|
onEnd: (toolCallId: string | undefined) => void;
|
|
161
|
-
killed: () => CommandKill | undefined;
|
|
162
170
|
signal: AbortSignal;
|
|
163
171
|
clear: () => void;
|
|
164
172
|
} | null;
|
|
@@ -174,9 +174,9 @@ export const realTimerDeps = {
|
|
|
174
174
|
};
|
|
175
175
|
/**
|
|
176
176
|
* Build the child-side command watchdog for ONE attempt: a per-tool-call timer
|
|
177
|
-
* machine whose `onFire` aborts `signal
|
|
178
|
-
* process-GROUP kill — reaping the hung command itself,
|
|
179
|
-
* holding it.
|
|
177
|
+
* machine whose `onFire` aborts `signal` WITH the kill as its reason, which
|
|
178
|
+
* runChild turns into a process-GROUP kill — reaping the hung command itself,
|
|
179
|
+
* not just the pi child holding it — and reports as `kill.by === 'command-timeout'`.
|
|
180
180
|
*
|
|
181
181
|
* LIMIT: the group kill only reaches processes still IN the group. A hung command
|
|
182
182
|
* that detached a daemon (setsid, nohup, a background dev server) leaves it
|
|
@@ -196,17 +196,17 @@ export function commandWatch(timeoutMs) {
|
|
|
196
196
|
// child are sequential, so a single slot is still correctly paired.
|
|
197
197
|
const key = (id) => id ?? 'anon';
|
|
198
198
|
const details = new Map();
|
|
199
|
-
let killed;
|
|
200
199
|
const watchdog = new CommandWatchdog({
|
|
201
200
|
getTimeoutMs: () => timeoutMs,
|
|
202
201
|
...realTimerDeps,
|
|
203
202
|
onFire: (toolCallId, toolName, ms) => {
|
|
204
|
-
|
|
203
|
+
const reason = {
|
|
204
|
+
by: 'command-timeout',
|
|
205
205
|
toolName,
|
|
206
206
|
timeoutMs: ms,
|
|
207
207
|
...(details.has(toolCallId) ? { detail: details.get(toolCallId) } : {})
|
|
208
208
|
};
|
|
209
|
-
ctrl.abort();
|
|
209
|
+
ctrl.abort(reason);
|
|
210
210
|
}
|
|
211
211
|
});
|
|
212
212
|
return {
|
|
@@ -226,7 +226,6 @@ export function commandWatch(timeoutMs) {
|
|
|
226
226
|
details.delete(key(id));
|
|
227
227
|
watchdog.onEnd(key(id));
|
|
228
228
|
},
|
|
229
|
-
killed: () => killed,
|
|
230
229
|
signal: ctrl.signal,
|
|
231
230
|
clear: () => watchdog.clearAll()
|
|
232
231
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function isConnectionError(cause: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Exponential backoff before a connection-error retry: 500ms, 1s, 2s — three
|
|
4
|
+
* requests over 3.5s, which is not a storm even against a throttle. pi's own
|
|
5
|
+
* ladder is three at 2s/4s/8s.
|
|
6
|
+
*/
|
|
7
|
+
export declare function connectionRetryBackoffMs(attempt: number): number;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which model errors are worth another attempt.
|
|
3
|
+
*
|
|
4
|
+
* A connection-class model error is transient: a single dropped fetch to a live
|
|
5
|
+
* endpoint, not a repeatable mistake. On a local single-slot server (e.g.
|
|
6
|
+
* llama-server with `--parallel 1`) pi-task's own concurrent fan-out can briefly
|
|
7
|
+
* saturate the slot, and one request fails to connect even though the model is
|
|
8
|
+
* up and the next request succeeds. pi already retries internally, but those
|
|
9
|
+
* retries don't always absorb it on a saturated local server — and a fail-fast
|
|
10
|
+
* then kills the whole task for a single blip.
|
|
11
|
+
*
|
|
12
|
+
* A NON-connection model error (bad request, context-length overflow, auth,
|
|
13
|
+
* provider 5xx that names a real fault) still fails fast: re-spawning against
|
|
14
|
+
* the same request won't fix it, so burning the budget only delays the report.
|
|
15
|
+
*
|
|
16
|
+
* SCOPE, and it is deliberate: connection classes only. pi's own
|
|
17
|
+
* `isRetryableAssistantError` (@earendil-works/pi-ai, `dist/utils/retry.js`) also
|
|
18
|
+
* retries the provider-LOAD family — `429`, `5xx`, `rate limit`, `overloaded` —
|
|
19
|
+
* which `does NOT match real, non-transient faults` in child-runner.test.ts
|
|
20
|
+
* explicitly rejects. That disagreement is real and OPEN; it is not settled here,
|
|
21
|
+
* because this backoff starts at 500ms and a 429 answered that fast is a retry
|
|
22
|
+
* storm, not a recovery.
|
|
23
|
+
*
|
|
24
|
+
* MEASURED against pi before widening: the transport entries here — a bare
|
|
25
|
+
* `timed out`, `getaddrinfo ENOTFOUND`, `upstream connect`, `reset before
|
|
26
|
+
* headers`, a truncated Anthropic stream and a closed websocket — were all
|
|
27
|
+
* MISSES. Every one is a REMOTE-provider failure, which is why a local llama.cpp
|
|
28
|
+
* setup never surfaced the gap. The errno spellings are pi-task's own: a child
|
|
29
|
+
* reports them through stderr, and pi never sees them.
|
|
30
|
+
*
|
|
31
|
+
* pi's bare `timeout` is deliberately NOT reproduced. It matched a provider 400
|
|
32
|
+
* that merely echoed a `timeout` field back, turning a fail-fast into a full
|
|
33
|
+
* retry budget, and it caught nothing the `timed out` spellings above miss.
|
|
34
|
+
*/
|
|
35
|
+
const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|socket connection was closed|fetch failed|network (?:error|timeout)|premature close|terminated|unreachable|getaddrinfo|enotfound|upstream.?connect|reset before headers|timed? out|ended without|stream ended before message_stop|websocket.?(?:closed|error))\b/i;
|
|
36
|
+
/**
|
|
37
|
+
* Provider LOAD, which is transient in a different way: the server is up and
|
|
38
|
+
* saying "not now". pi retries all of these; 53f0488 did not, but its own message
|
|
39
|
+
* names only "context overflow, bad request, auth" as the fail-fast set — a
|
|
40
|
+
* throttle was never argued for, it just rode along in a list written for a LOCAL
|
|
41
|
+
* server, where none of these can occur.
|
|
42
|
+
*
|
|
43
|
+
* Words carry no trailing \b (`overloaded_error` joins on `_`, which is a word
|
|
44
|
+
* character); the bare status codes carry one, or `500` matches inside `15000`.
|
|
45
|
+
*/
|
|
46
|
+
const PROVIDER_LOAD_RE = /(?:overloaded|rate.?limit|too many requests|service.?unavailable|server.?error|internal.?error|provider.?returned.?error)|\b(?:429|500|502|503|504|524)\b/i;
|
|
47
|
+
/**
|
|
48
|
+
* Account facts, not liveness: a budget does not refill on a retry. Checked FIRST,
|
|
49
|
+
* because these arrive worded as a throttle — `429 GoUsageLimitError` is a
|
|
50
|
+
* subscription limit, not a queue.
|
|
51
|
+
*/
|
|
52
|
+
const NON_RETRYABLE_RE = /\b(?:insufficient_quota|quota exceeded|out of budget|billing|usage limit reached|available balance|GoUsageLimitError|FreeUsageLimitError)\b/i;
|
|
53
|
+
export function isConnectionError(cause) {
|
|
54
|
+
if (NON_RETRYABLE_RE.test(cause))
|
|
55
|
+
return false;
|
|
56
|
+
return CONNECTION_ERROR_RE.test(cause) || PROVIDER_LOAD_RE.test(cause);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Exponential backoff before a connection-error retry: 500ms, 1s, 2s — three
|
|
60
|
+
* requests over 3.5s, which is not a storm even against a throttle. pi's own
|
|
61
|
+
* ladder is three at 2s/4s/8s.
|
|
62
|
+
*/
|
|
63
|
+
export function connectionRetryBackoffMs(attempt) {
|
|
64
|
+
return 500 * 2 ** attempt;
|
|
65
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Base URLs of every custom provider pi is configured with (possibly none). */
|
|
2
|
-
export declare function discoverModelEndpoints(agentDir?: string): string[];
|
|
3
1
|
/**
|
|
4
2
|
* The provider/id a child pi process will actually resolve.
|
|
5
3
|
*
|
|
@@ -11,24 +9,12 @@ export interface ModelRef {
|
|
|
11
9
|
provider: string;
|
|
12
10
|
id: string;
|
|
13
11
|
}
|
|
14
|
-
export declare function defaultModelRef(agentDir?: string): ModelRef | undefined;
|
|
15
12
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* Two files, two shapes, and the difference is not cosmetic. `models.json` is
|
|
20
|
-
* hand-written and hangs `baseUrl` off the PROVIDER; `models-store.json` is the
|
|
21
|
-
* cached remote catalogue and hangs it off each MODEL — its provider objects are
|
|
22
|
-
* `{models, checkedAt, lastModified, etag}` with no `baseUrl` key at all. A
|
|
23
|
-
* reader that knows only the first shape sees one endpoint on a machine that has
|
|
24
|
-
* fifteen.
|
|
25
|
-
*
|
|
26
|
-
* `undefined` is the honest answer for a model pi serves from `@earendil-works/
|
|
27
|
-
* pi-ai`'s built-in catalogue, whose URLs live in that package rather than on
|
|
28
|
-
* disk. We do not import it — see the header of this file — so we say we do not
|
|
29
|
-
* know, and the caller declines to probe rather than probing something else.
|
|
13
|
+
* Still read from settings.json rather than the session snapshot: pi persists a
|
|
14
|
+
* `/model` switch there, and a child with no `--model` resolves it fresh, so the
|
|
15
|
+
* file is the one source that is right after a mid-session switch.
|
|
30
16
|
*/
|
|
31
|
-
export declare function
|
|
17
|
+
export declare function defaultModelRef(agentDir?: string): ModelRef | undefined;
|
|
32
18
|
/**
|
|
33
19
|
* What to probe on behalf of one child — the endpoint that child's own model
|
|
34
20
|
* uses, not every endpoint on the machine.
|
|
@@ -45,12 +31,14 @@ export declare function modelBaseUrl(ref: ModelRef, agentDir?: string): string |
|
|
|
45
31
|
* child whose own backend is healthy, and it can leave the guard disarmed for
|
|
46
32
|
* one whose backend is dead.
|
|
47
33
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
34
|
+
* Every escape is toward never killing: an unknown model, a model with no URL,
|
|
35
|
+
* an unreadable default and a session that has not started all answer `[]`,
|
|
36
|
+
* which `probeModelEndpoints` reads as reachable. Probing some OTHER model's
|
|
37
|
+
* url instead would import a false positive, which is the one thing a blind
|
|
38
|
+
* guard never does.
|
|
39
|
+
*
|
|
40
|
+
* `agentDir` locates settings.json for the unpinned case only. The URL always
|
|
41
|
+
* comes from this session's registry snapshot, never from a file under it.
|
|
54
42
|
*/
|
|
55
43
|
export declare function childModelEndpoints(spec?: string, agentDir?: string): string[];
|
|
56
44
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* model-endpoint —
|
|
3
|
-
*
|
|
2
|
+
* model-endpoint — which backend a child pi process talks to, and whether it
|
|
3
|
+
* still answers.
|
|
4
4
|
*
|
|
5
5
|
* The failure this serves: the model server dies mid-child and the child hangs
|
|
6
6
|
* mute. pi's own connection-error handling cannot help, because it runs from a
|
|
@@ -10,43 +10,26 @@
|
|
|
10
10
|
* while it runs, so only "no output AND the endpoint does not answer" counts as
|
|
11
11
|
* a dead backend.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
13
|
+
* WHERE THE URL COMES FROM. The registry, snapshotted at session_start by
|
|
14
|
+
* workers/model-warning.ts into config/group-args.ts — the guards run where no
|
|
15
|
+
* `ctx` exists. It used to be re-parsed out of pi's `models.json` and
|
|
16
|
+
* `models-store.json`, which answer `undefined` for every one of pi-ai's 39
|
|
17
|
+
* built-in providers, so the guard was blind exactly where the registry knew
|
|
18
|
+
* the URL. It is now ARMED for those. The other direction is unchanged: no
|
|
19
|
+
* snapshot yet, or a model with no URL, means nothing to probe, and the guard
|
|
20
|
+
* then NEVER kills — a child on a backend we cannot see gets the benefit of the
|
|
21
|
+
* doubt.
|
|
22
22
|
*/
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
24
|
import * as os from 'node:os';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
urls.push(p.baseUrl);
|
|
34
|
-
}
|
|
35
|
-
return [...new Set(urls)];
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return [];
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/** A `provider/id` spec as a ref, splitting on the FIRST slash. */
|
|
42
|
-
function specRef(spec) {
|
|
43
|
-
if (spec === undefined)
|
|
44
|
-
return undefined;
|
|
45
|
-
const i = spec.indexOf('/');
|
|
46
|
-
if (i <= 0 || i === spec.length - 1)
|
|
47
|
-
return undefined;
|
|
48
|
-
return { provider: spec.slice(0, i), id: spec.slice(i + 1) };
|
|
49
|
-
}
|
|
26
|
+
import { modelEndpoint } from '../config/group-args.js';
|
|
27
|
+
import { specOf } from './model-resolve.js';
|
|
28
|
+
/**
|
|
29
|
+
* Still read from settings.json rather than the session snapshot: pi persists a
|
|
30
|
+
* `/model` switch there, and a child with no `--model` resolves it fresh, so the
|
|
31
|
+
* file is the one source that is right after a mid-session switch.
|
|
32
|
+
*/
|
|
50
33
|
export function defaultModelRef(agentDir = path.join(os.homedir(), '.pi', 'agent')) {
|
|
51
34
|
try {
|
|
52
35
|
const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'settings.json'), 'utf8'));
|
|
@@ -62,43 +45,6 @@ export function defaultModelRef(agentDir = path.join(os.homedir(), '.pi', 'agent
|
|
|
62
45
|
return undefined;
|
|
63
46
|
}
|
|
64
47
|
}
|
|
65
|
-
/**
|
|
66
|
-
* The base URL one model is served from, or `undefined` for "not configured on
|
|
67
|
-
* this machine".
|
|
68
|
-
*
|
|
69
|
-
* Two files, two shapes, and the difference is not cosmetic. `models.json` is
|
|
70
|
-
* hand-written and hangs `baseUrl` off the PROVIDER; `models-store.json` is the
|
|
71
|
-
* cached remote catalogue and hangs it off each MODEL — its provider objects are
|
|
72
|
-
* `{models, checkedAt, lastModified, etag}` with no `baseUrl` key at all. A
|
|
73
|
-
* reader that knows only the first shape sees one endpoint on a machine that has
|
|
74
|
-
* fifteen.
|
|
75
|
-
*
|
|
76
|
-
* `undefined` is the honest answer for a model pi serves from `@earendil-works/
|
|
77
|
-
* pi-ai`'s built-in catalogue, whose URLs live in that package rather than on
|
|
78
|
-
* disk. We do not import it — see the header of this file — so we say we do not
|
|
79
|
-
* know, and the caller declines to probe rather than probing something else.
|
|
80
|
-
*/
|
|
81
|
-
export function modelBaseUrl(ref, agentDir = path.join(os.homedir(), '.pi', 'agent')) {
|
|
82
|
-
const url = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
83
|
-
try {
|
|
84
|
-
const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'models.json'), 'utf8'));
|
|
85
|
-
const p = j.providers?.[ref.provider];
|
|
86
|
-
if (p) {
|
|
87
|
-
const own = p.models?.find(m => m.id === ref.id);
|
|
88
|
-
return url(own?.baseUrl) ?? url(p.baseUrl);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
/* fall through to the store */
|
|
93
|
-
}
|
|
94
|
-
try {
|
|
95
|
-
const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'models-store.json'), 'utf8'));
|
|
96
|
-
return url(j[ref.provider]?.models?.find(m => m.id === ref.id)?.baseUrl);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
48
|
/**
|
|
103
49
|
* What to probe on behalf of one child — the endpoint that child's own model
|
|
104
50
|
* uses, not every endpoint on the machine.
|
|
@@ -115,18 +61,22 @@ export function modelBaseUrl(ref, agentDir = path.join(os.homedir(), '.pi', 'age
|
|
|
115
61
|
* child whose own backend is healthy, and it can leave the guard disarmed for
|
|
116
62
|
* one whose backend is dead.
|
|
117
63
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
64
|
+
* Every escape is toward never killing: an unknown model, a model with no URL,
|
|
65
|
+
* an unreadable default and a session that has not started all answer `[]`,
|
|
66
|
+
* which `probeModelEndpoints` reads as reachable. Probing some OTHER model's
|
|
67
|
+
* url instead would import a false positive, which is the one thing a blind
|
|
68
|
+
* guard never does.
|
|
69
|
+
*
|
|
70
|
+
* `agentDir` locates settings.json for the unpinned case only. The URL always
|
|
71
|
+
* comes from this session's registry snapshot, never from a file under it.
|
|
124
72
|
*/
|
|
125
73
|
export function childModelEndpoints(spec, agentDir = path.join(os.homedir(), '.pi', 'agent')) {
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
74
|
+
let ref = spec;
|
|
75
|
+
if (ref === undefined) {
|
|
76
|
+
const saved = defaultModelRef(agentDir);
|
|
77
|
+
ref = saved && specOf(saved);
|
|
78
|
+
}
|
|
79
|
+
const url = ref === undefined ? undefined : modelEndpoint(ref);
|
|
130
80
|
return url === undefined ? [] : [url];
|
|
131
81
|
}
|
|
132
82
|
/**
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one seam between a stored `provider/id` spec and pi's live model registry.
|
|
3
|
+
*
|
|
4
|
+
* Every question of the form "which pi Model does X run on" comes through here:
|
|
5
|
+
* the session hints, the context-window table, the settings panel's catalogue
|
|
6
|
+
* and the implementation hold. They used to each carry their own split-then-
|
|
7
|
+
* `find`, and each re-decided what `inherit` means. One of them getting it
|
|
8
|
+
* wrong is a warning naming the wrong model or a hold restoring the wrong one.
|
|
9
|
+
*
|
|
10
|
+
* `ctx` is the STRUCTURAL minimum, not one of pi's context interfaces: a tool
|
|
11
|
+
* context, a command context and a test literal all satisfy it. `ctx.model` and
|
|
12
|
+
* `ctx.modelRegistry` are GETTERS on the real thing that call `assertActive()`
|
|
13
|
+
* and throw on a stale context, so every reader here answers `undefined`
|
|
14
|
+
* instead of throwing — a session hint must not take the session down.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
17
|
+
import { type ChildGroup } from '../config/groups.js';
|
|
18
|
+
/**
|
|
19
|
+
* pi's own `Model`, named without importing `@earendil-works/pi-ai` — which is
|
|
20
|
+
* neither a dependency, a devDependency nor a peerDependency of this package.
|
|
21
|
+
* The context already carries the type, so deriving it adds no edge to the graph.
|
|
22
|
+
*/
|
|
23
|
+
export type PiModel = NonNullable<ExtensionContext['model']>;
|
|
24
|
+
/** The two registry questions this asks. `getAvailable` only feeds the endpoint map. */
|
|
25
|
+
export interface ModelRegistryView {
|
|
26
|
+
find(provider: string, id: string): PiModel | undefined;
|
|
27
|
+
getRegisteredProviderIds?(): readonly string[];
|
|
28
|
+
getAvailable?(): readonly PiModel[];
|
|
29
|
+
}
|
|
30
|
+
export interface ModelContext {
|
|
31
|
+
model?: PiModel;
|
|
32
|
+
modelRegistry?: ModelRegistryView;
|
|
33
|
+
}
|
|
34
|
+
/** A spec, resolved. Structurally a `GroupModelFacts`, so it feeds the reasoning check as-is. */
|
|
35
|
+
export interface ResolvedModel {
|
|
36
|
+
/** Canonical `provider/id` — for `inherit`, the session model's own. */
|
|
37
|
+
spec: string;
|
|
38
|
+
name: string;
|
|
39
|
+
reasoning: boolean;
|
|
40
|
+
thinkingLevelMap?: PiModel['thinkingLevelMap'];
|
|
41
|
+
/** Absent when the model declares none; a probe cannot be aimed at it. */
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
/** 0 when the model declares none, so `||` falls through to the parent's. */
|
|
44
|
+
contextWindow: number;
|
|
45
|
+
/**
|
|
46
|
+
* Its provider was registered by a host extension. Children run
|
|
47
|
+
* `--no-extensions`, so the model works in a child exactly when that
|
|
48
|
+
* extension is in the child whitelist — a warning, never a drop.
|
|
49
|
+
*/
|
|
50
|
+
fromExtension: boolean;
|
|
51
|
+
handle: PiModel;
|
|
52
|
+
}
|
|
53
|
+
/** The `provider/id` inverse of {@link splitSpec}. */
|
|
54
|
+
export declare function specOf(model: {
|
|
55
|
+
provider: string;
|
|
56
|
+
id: string;
|
|
57
|
+
}): string;
|
|
58
|
+
/**
|
|
59
|
+
* `inherit` is the session's model. That is decision 3 of the model table —
|
|
60
|
+
* children are NOT switched to follow the host — and the honest value is
|
|
61
|
+
* settings.json's default, which need not be the session's. Naming the
|
|
62
|
+
* session's model is still the better of the two: it is the one the user can
|
|
63
|
+
* see, and on every machine with one provider the two agree.
|
|
64
|
+
*
|
|
65
|
+
* `find` is EXACT, deliberately stricter than pi's own CLI, which also
|
|
66
|
+
* substring-matches. We store a canonical `provider/id`, so exact is the only
|
|
67
|
+
* match that should ever count.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveModel(ctx: ModelContext, spec: string): ResolvedModel | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* One group's cell, as the code with no `ctx` needs it (see config/group-args.ts).
|
|
72
|
+
*
|
|
73
|
+
* `unresolved` — no such model here, so the `--model` flag is dropped.
|
|
74
|
+
* `extension` — see {@link ResolvedModel.fromExtension}; a warning, not a drop.
|
|
75
|
+
*/
|
|
76
|
+
export interface GroupModelSnapshot {
|
|
77
|
+
spec: string;
|
|
78
|
+
/** false ⇒ emit no `--model` for this group. Only `unresolved` clears it. */
|
|
79
|
+
usable: boolean;
|
|
80
|
+
/** Absent for `inherit` and for an unresolved spec: the caller keeps its live parent value. */
|
|
81
|
+
contextWindow?: number;
|
|
82
|
+
problem?: 'unresolved' | 'extension';
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Every group's cell in ONE registry walk, so the argv drop, the churn window
|
|
86
|
+
* and the hint can never disagree about which model a group runs on.
|
|
87
|
+
*
|
|
88
|
+
* An `inherit` cell gets NO window. Storing the parent's would freeze a
|
|
89
|
+
* session_start snapshot in front of the live per-run value: a user who
|
|
90
|
+
* switches the session model with Ctrl+P to a bigger one would have every
|
|
91
|
+
* child judged against the old window, and the churn rule then fires early and
|
|
92
|
+
* kills a healthy child. An unresolved cell gets none for the same reason —
|
|
93
|
+
* such a child runs on the live default, whichever that is by then.
|
|
94
|
+
*
|
|
95
|
+
* A registry that cannot answer condemns NOTHING: every cell stays usable and
|
|
96
|
+
* carries no window. Claiming every spec unresolved would drop every `--model`
|
|
97
|
+
* on a session whose runtime merely was not ready.
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolveGroupModels(ctx: ModelContext, specs: Readonly<Record<ChildGroup, string>>): Record<ChildGroup, GroupModelSnapshot>;
|
|
100
|
+
/**
|
|
101
|
+
* `spec → baseUrl` for every model this session can use, for the dead-backend
|
|
102
|
+
* probe (shared/model-endpoint.ts). Empty when the registry cannot answer,
|
|
103
|
+
* which the probe reads as "cannot see, so never kill".
|
|
104
|
+
*/
|
|
105
|
+
export declare function resolveModelEndpoints(ctx: ModelContext): Map<string, string>;
|