@mjasnikovs/pi-task 0.39.0 → 0.39.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/README.md +2 -2
- package/dist/config/group-args.d.ts +24 -9
- package/dist/config/group-args.js +38 -28
- package/dist/config/register.d.ts +0 -9
- package/dist/config/register.js +17 -62
- 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
|
@@ -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>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
|
|
2
|
+
import { CHILD_GROUPS } from '../config/groups.js';
|
|
3
|
+
/** The `provider/id` inverse of {@link splitSpec}. */
|
|
4
|
+
export function specOf(model) {
|
|
5
|
+
return `${model.provider}/${model.id}`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `inherit` is the session's model. That is decision 3 of the model table —
|
|
9
|
+
* children are NOT switched to follow the host — and the honest value is
|
|
10
|
+
* settings.json's default, which need not be the session's. Naming the
|
|
11
|
+
* session's model is still the better of the two: it is the one the user can
|
|
12
|
+
* see, and on every machine with one provider the two agree.
|
|
13
|
+
*
|
|
14
|
+
* `find` is EXACT, deliberately stricter than pi's own CLI, which also
|
|
15
|
+
* substring-matches. We store a canonical `provider/id`, so exact is the only
|
|
16
|
+
* match that should ever count.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveModel(ctx, spec) {
|
|
19
|
+
try {
|
|
20
|
+
const parts = spec === MODEL_INHERIT ? undefined : splitSpec(spec);
|
|
21
|
+
const handle = spec === MODEL_INHERIT ?
|
|
22
|
+
ctx.model
|
|
23
|
+
: parts && ctx.modelRegistry?.find(parts.provider, parts.id);
|
|
24
|
+
if (!handle)
|
|
25
|
+
return undefined;
|
|
26
|
+
const provider = parts?.provider ?? handle.provider;
|
|
27
|
+
const extensionProviders = ctx.modelRegistry?.getRegisteredProviderIds?.() ?? [];
|
|
28
|
+
return {
|
|
29
|
+
spec: parts ? spec : specOf(handle),
|
|
30
|
+
name: handle.name || handle.id,
|
|
31
|
+
reasoning: handle.reasoning,
|
|
32
|
+
...(handle.thinkingLevelMap === undefined ?
|
|
33
|
+
{}
|
|
34
|
+
: { thinkingLevelMap: handle.thinkingLevelMap }),
|
|
35
|
+
...(handle.baseUrl ? { baseUrl: handle.baseUrl } : {}),
|
|
36
|
+
contextWindow: handle.contextWindow ?? 0,
|
|
37
|
+
fromExtension: extensionProviders.includes(provider),
|
|
38
|
+
handle
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Every group's cell in ONE registry walk, so the argv drop, the churn window
|
|
47
|
+
* and the hint can never disagree about which model a group runs on.
|
|
48
|
+
*
|
|
49
|
+
* An `inherit` cell gets NO window. Storing the parent's would freeze a
|
|
50
|
+
* session_start snapshot in front of the live per-run value: a user who
|
|
51
|
+
* switches the session model with Ctrl+P to a bigger one would have every
|
|
52
|
+
* child judged against the old window, and the churn rule then fires early and
|
|
53
|
+
* kills a healthy child. An unresolved cell gets none for the same reason —
|
|
54
|
+
* such a child runs on the live default, whichever that is by then.
|
|
55
|
+
*
|
|
56
|
+
* A registry that cannot answer condemns NOTHING: every cell stays usable and
|
|
57
|
+
* carries no window. Claiming every spec unresolved would drop every `--model`
|
|
58
|
+
* on a session whose runtime merely was not ready.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveGroupModels(ctx, specs) {
|
|
61
|
+
const registry = readRegistry(ctx);
|
|
62
|
+
const cell = (spec) => {
|
|
63
|
+
if (spec === MODEL_INHERIT || registry === undefined)
|
|
64
|
+
return { spec, usable: true };
|
|
65
|
+
const found = resolveModel({ modelRegistry: registry }, spec);
|
|
66
|
+
if (!found)
|
|
67
|
+
return { spec, usable: false, problem: 'unresolved' };
|
|
68
|
+
return {
|
|
69
|
+
spec,
|
|
70
|
+
usable: true,
|
|
71
|
+
...(found.contextWindow > 0 ? { contextWindow: found.contextWindow } : {}),
|
|
72
|
+
...(found.fromExtension ? { problem: 'extension' } : {})
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
return Object.fromEntries(CHILD_GROUPS.map(g => [g, cell(specs[g])]));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `spec → baseUrl` for every model this session can use, for the dead-backend
|
|
79
|
+
* probe (shared/model-endpoint.ts). Empty when the registry cannot answer,
|
|
80
|
+
* which the probe reads as "cannot see, so never kill".
|
|
81
|
+
*/
|
|
82
|
+
export function resolveModelEndpoints(ctx) {
|
|
83
|
+
const out = new Map();
|
|
84
|
+
for (const m of readRegistry(ctx)?.getAvailable?.() ?? []) {
|
|
85
|
+
if (m.baseUrl)
|
|
86
|
+
out.set(specOf(m), m.baseUrl);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function readRegistry(ctx) {
|
|
91
|
+
try {
|
|
92
|
+
return ctx.modelRegistry;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -67,6 +67,26 @@ export declare function supportedThinkingLevels(model: ReasoningModelFacts): Lad
|
|
|
67
67
|
* the model supports it — which is what makes the inequality a mismatch test.
|
|
68
68
|
*/
|
|
69
69
|
export declare function clampToModel(model: ReasoningModelFacts, level: LadderLevel): LadderLevel;
|
|
70
|
+
/**
|
|
71
|
+
* The settings a /task-config row may offer, given the model its group runs on.
|
|
72
|
+
*
|
|
73
|
+
* The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
|
|
74
|
+
* directly: that returns the whole ladder including `xhigh` and `max`, which
|
|
75
|
+
* the menu excludes on purpose (see config/reasoning.ts) because pi's own UI
|
|
76
|
+
* may not offer them. A model declaring `xhigh` must not smuggle it in.
|
|
77
|
+
*/
|
|
78
|
+
export declare function offeredLevels(facts: ReasoningModelFacts | undefined): GroupSetting[];
|
|
79
|
+
/**
|
|
80
|
+
* The setting a row will really run at, inside the menu's own vocabulary.
|
|
81
|
+
*
|
|
82
|
+
* ONE function for the picker's preselect and the writer, because they used to
|
|
83
|
+
* be two copies of the same clamp and only one re-projected into
|
|
84
|
+
* {@link offeredLevels}. `clampToModel` walks UP first and knows the whole
|
|
85
|
+
* ladder, so a model declaring `xhigh` can land on a level the menu excludes;
|
|
86
|
+
* a writer that stored it would put a value in the table that no row can show.
|
|
87
|
+
* The highest OFFERED level is the honest neighbour of an excluded one.
|
|
88
|
+
*/
|
|
89
|
+
export declare function effectiveSetting(facts: ReasoningModelFacts | undefined, wanted: GroupSetting): GroupSetting;
|
|
70
90
|
/** One group whose configured setting the model it runs on will not honour. */
|
|
71
91
|
export interface ReasoningMismatch {
|
|
72
92
|
group: ChildGroup;
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* line-for-line identical to it, ladder included. Nothing here imports pi-ai, so
|
|
31
31
|
* an upstream change will not fail a test — the two have to be re-compared.
|
|
32
32
|
*/
|
|
33
|
-
import { CHILD_GROUPS } from '../config/reasoning.js';
|
|
33
|
+
import { CHILD_GROUPS, REASONING_SETTINGS } from '../config/reasoning.js';
|
|
34
34
|
/**
|
|
35
35
|
* pi's own level ladder, in order — the same seven names, in the same sequence,
|
|
36
36
|
* as `EXTENDED_THINKING_LEVELS` in pi-ai's models.js. The order IS the algorithm:
|
|
@@ -86,6 +86,37 @@ export function clampToModel(model, level) {
|
|
|
86
86
|
}
|
|
87
87
|
return available[0] ?? 'off';
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The settings a /task-config row may offer, given the model its group runs on.
|
|
91
|
+
*
|
|
92
|
+
* The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
|
|
93
|
+
* directly: that returns the whole ladder including `xhigh` and `max`, which
|
|
94
|
+
* the menu excludes on purpose (see config/reasoning.ts) because pi's own UI
|
|
95
|
+
* may not offer them. A model declaring `xhigh` must not smuggle it in.
|
|
96
|
+
*/
|
|
97
|
+
export function offeredLevels(facts) {
|
|
98
|
+
if (facts === undefined)
|
|
99
|
+
return [...REASONING_SETTINGS];
|
|
100
|
+
const supported = supportedThinkingLevels(facts);
|
|
101
|
+
return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The setting a row will really run at, inside the menu's own vocabulary.
|
|
105
|
+
*
|
|
106
|
+
* ONE function for the picker's preselect and the writer, because they used to
|
|
107
|
+
* be two copies of the same clamp and only one re-projected into
|
|
108
|
+
* {@link offeredLevels}. `clampToModel` walks UP first and knows the whole
|
|
109
|
+
* ladder, so a model declaring `xhigh` can land on a level the menu excludes;
|
|
110
|
+
* a writer that stored it would put a value in the table that no row can show.
|
|
111
|
+
* The highest OFFERED level is the honest neighbour of an excluded one.
|
|
112
|
+
*/
|
|
113
|
+
export function effectiveSetting(facts, wanted) {
|
|
114
|
+
if (facts === undefined || wanted === 'inherit')
|
|
115
|
+
return wanted;
|
|
116
|
+
const offered = offeredLevels(facts);
|
|
117
|
+
const clamped = clampToModel(facts, wanted);
|
|
118
|
+
return offered.includes(clamped) ? clamped : (offered.at(-1) ?? 'inherit');
|
|
119
|
+
}
|
|
89
120
|
/**
|
|
90
121
|
* Every group whose setting the model IT RUNS ON will silently change.
|
|
91
122
|
*
|