pi-observational-memory 3.0.4 → 3.1.3
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 +9 -4
- package/package.json +1 -1
- package/src/agents/dropper/agent.ts +16 -4
- package/src/agents/observer/agent.ts +16 -4
- package/src/agents/reflector/agent.ts +16 -4
- package/src/agents/worker-stream.ts +57 -0
- package/src/config.ts +10 -0
- package/src/hooks/compaction-hook.ts +10 -2
- package/src/hooks/compaction-trigger.ts +3 -24
- package/src/hooks/consolidation-trigger.ts +25 -0
- package/src/runtime.ts +205 -10
package/README.md
CHANGED
|
@@ -173,6 +173,8 @@ This extension is especially useful when the session contains decisions that sho
|
|
|
173
173
|
|
|
174
174
|
## Install
|
|
175
175
|
|
|
176
|
+
Requires Pi 0.81.0 or newer. Proactive compaction uses the `agent_settled` lifecycle event introduced in that release.
|
|
177
|
+
|
|
176
178
|
```bash
|
|
177
179
|
pi install npm:pi-observational-memory
|
|
178
180
|
```
|
|
@@ -227,6 +229,8 @@ A typical config:
|
|
|
227
229
|
|
|
228
230
|
Most users can start with the defaults and tune only if they have a specific reason.
|
|
229
231
|
|
|
232
|
+
If your memory model is a local llama.cpp server, size `agentMaxTokens` so that a worst-case request (observer chunk + prior memory + system prompt + the full response budget) fits inside the server's context: slot KV is shared between the main session's retained cache and concurrent sub-agent requests, so an over-budget sub-agent request fails with `500 "Context size has been exceeded."` and the affected memory run aborts. For example, on a 64K-slot server, pairing `"agentMaxTokens": 8192` with a low `observerChunkMaxTokens` keeps sub-agent requests well inside the window.
|
|
233
|
+
|
|
230
234
|
### Scaling compaction to the model's context window
|
|
231
235
|
|
|
232
236
|
By default `compactAfterTokensMode` is `"calibrated"`, so the proactive
|
|
@@ -280,6 +284,7 @@ on the `Next compaction` line regardless of mode.
|
|
|
280
284
|
| `observationsPoolMaxTokens` | `20000` | Observation-token budget used for compaction full-fold pressure. |
|
|
281
285
|
| `observationsPoolTargetTokens` | half of max | Active observation target used by post-reflection dropper maintenance. |
|
|
282
286
|
| `agentMaxTurns` | `16` | Shared turn cap for background memory-agent loops. |
|
|
287
|
+
| `agentMaxTokens` | `32000` | Maximum output tokens requested for memory-agent loops (observer/reflector/dropper), clamped to the model's own `maxTokens` when available. Lower it for local servers with a modest context window, e.g. `8192`. |
|
|
283
288
|
| `model` | session model | Optional memory-worker model override: `{ provider, id, thinking }`. |
|
|
284
289
|
| `showWorkerNotifications` | `true` | Shows routine observer, reflector, and dropper progress notifications. Warnings and errors are unaffected. |
|
|
285
290
|
| `passive` | `false` | Disables proactive background observation, reflection, maintenance, and auto-compaction triggers. |
|
|
@@ -295,7 +300,7 @@ Valid `model.thinking` values are:
|
|
|
295
300
|
* `xhigh`
|
|
296
301
|
* `max`
|
|
297
302
|
|
|
298
|
-
If no `model` is configured, memory workers use the session model.
|
|
303
|
+
If no `model` is configured, memory workers use the session model, including custom `pi.registerProvider` APIs such as `cursor-sdk`. You do not need a second built-in provider (OpenAI, OpenRouter, …) for observational memory to run. Set `model` only when you want cheaper or faster workers than the coding agent.
|
|
299
304
|
|
|
300
305
|
Set `showWorkerNotifications` to `false` to hide routine worker start and completion messages (including deliberate-empty observer info messages). Model fallback/unavailability, worker failures (including observer stream errors), compaction notifications, and explicit `/om:*` command output remain visible.
|
|
301
306
|
|
|
@@ -329,14 +334,14 @@ flowchart TD
|
|
|
329
334
|
Turn[turn_end]
|
|
330
335
|
Observe[Capture observations]
|
|
331
336
|
Reflect[Distill reflections]
|
|
332
|
-
|
|
337
|
+
AgentSettled[agent_settled]
|
|
333
338
|
Trigger[auto-compaction trigger]
|
|
334
339
|
Compact[session_before_compact]
|
|
335
340
|
Summary[visible memory for Pi]
|
|
336
341
|
|
|
337
342
|
Turn -->|observation due| Observe
|
|
338
343
|
Turn -->|reflection due| Reflect
|
|
339
|
-
|
|
344
|
+
AgentSettled -->|compactAfterTokens and idle| Trigger --> Compact --> Summary
|
|
340
345
|
```
|
|
341
346
|
|
|
342
347
|
The high-level lifecycle:
|
|
@@ -363,7 +368,7 @@ Current behavior:
|
|
|
363
368
|
|
|
364
369
|
* **Observation-centered memory.** The extension records useful session observations while you work.
|
|
365
370
|
* **Durable reflections.** The extension distills stable facts that help the agent stay oriented over time.
|
|
366
|
-
* **Fast compaction.** `session_before_compact`
|
|
371
|
+
* **Fast compaction.** When prepared V3 memory exists, `session_before_compact` renders it without calling a model or waiting for background workers. An empty V3 projection delegates to Pi's native summarizer instead of replacing prior context with an empty summary.
|
|
367
372
|
* **Background memory work.** Observation and reflection work run from `turn_end` when their token clocks are due; dropper work runs only after successful reflection and prunes the folded active observation ledger toward `observationsPoolTargetTokens`.
|
|
368
373
|
* **Source-backed recall.** Observations and reflections can be traced back through the `recall` tool.
|
|
369
374
|
* **Visible/full views.** `/om:view` shows visible memory and `/om:view full` shows the full current memory state. Use `/om:status` for visible-vs-full drift and for the separate visible observation pool vs active observation pool.
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
4
|
import type { Static } from "typebox";
|
|
6
5
|
import { debugLog } from "../../debug-log.js";
|
|
7
6
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
8
7
|
import { logAgentStreamError } from "../stream-errors.js";
|
|
8
|
+
import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
|
|
9
9
|
import { reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
|
|
10
10
|
import { DROPPER_SYSTEM } from "./prompts.js";
|
|
11
11
|
import {
|
|
@@ -41,13 +41,18 @@ interface RunDropperArgs {
|
|
|
41
41
|
model: Model<any>;
|
|
42
42
|
apiKey?: string;
|
|
43
43
|
headers?: Record<string, string>;
|
|
44
|
+
env?: Record<string, string>;
|
|
44
45
|
reflections: Reflection[];
|
|
45
46
|
observations: Observation[];
|
|
46
47
|
targetTokens: number;
|
|
47
48
|
signal?: AbortSignal;
|
|
48
49
|
agentLoop?: typeof agentLoop;
|
|
49
50
|
maxTurns?: number;
|
|
51
|
+
/** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
|
|
52
|
+
maxOutputTokens?: number;
|
|
50
53
|
thinkingLevel?: ModelThinkingLevel;
|
|
54
|
+
modelRegistry?: StreamableModelRegistry;
|
|
55
|
+
streamSimple?: WorkerStreamSimple;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
const RELEVANCE_DROP_RANK: Record<Observation["relevance"], number> = {
|
|
@@ -131,7 +136,7 @@ export function selectDropCandidates(
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
export async function runDropper(args: RunDropperArgs): Promise<string[] | undefined> {
|
|
134
|
-
const { model, apiKey, headers, reflections, observations, targetTokens, signal } = args;
|
|
139
|
+
const { model, apiKey, headers, env, reflections, observations, targetTokens, signal } = args;
|
|
135
140
|
if (observations.length === 0) return undefined;
|
|
136
141
|
|
|
137
142
|
const metrics = observationPoolMetrics(observations, targetTokens);
|
|
@@ -243,7 +248,8 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
243
248
|
model,
|
|
244
249
|
apiKey,
|
|
245
250
|
headers,
|
|
246
|
-
|
|
251
|
+
env,
|
|
252
|
+
maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
|
|
247
253
|
convertToLlm: (msgs) => msgs as Message[],
|
|
248
254
|
toolExecution: "sequential",
|
|
249
255
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
@@ -251,7 +257,13 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
251
257
|
};
|
|
252
258
|
|
|
253
259
|
const loop = args.agentLoop ?? agentLoop;
|
|
254
|
-
const stream = loop(
|
|
260
|
+
const stream = loop(
|
|
261
|
+
prompts,
|
|
262
|
+
context,
|
|
263
|
+
config,
|
|
264
|
+
signal,
|
|
265
|
+
resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
|
|
266
|
+
);
|
|
255
267
|
for await (const event of stream) {
|
|
256
268
|
// Tool execution collects candidate ids.
|
|
257
269
|
logAgentStreamError("dropper", event);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
4
|
import type { Static } from "typebox";
|
|
6
5
|
import { hashId } from "../../ids.js";
|
|
7
6
|
import { logAgentStreamError } from "../stream-errors.js";
|
|
7
|
+
import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
|
|
8
8
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
9
9
|
import { OBSERVER_SYSTEM } from "./prompts.js";
|
|
10
10
|
import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
|
|
@@ -15,6 +15,7 @@ interface RunObserverArgs {
|
|
|
15
15
|
model: Model<any>;
|
|
16
16
|
apiKey?: string;
|
|
17
17
|
headers?: Record<string, string>;
|
|
18
|
+
env?: Record<string, string>;
|
|
18
19
|
priorReflections: string[];
|
|
19
20
|
priorObservations: string[];
|
|
20
21
|
chunk: string;
|
|
@@ -22,7 +23,11 @@ interface RunObserverArgs {
|
|
|
22
23
|
signal?: AbortSignal;
|
|
23
24
|
agentLoop?: typeof agentLoop;
|
|
24
25
|
maxTurns?: number;
|
|
26
|
+
/** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
|
|
27
|
+
maxOutputTokens?: number;
|
|
25
28
|
thinkingLevel?: ModelThinkingLevel;
|
|
29
|
+
modelRegistry?: StreamableModelRegistry;
|
|
30
|
+
streamSimple?: WorkerStreamSimple;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const RelevanceSchema = Type.Union([
|
|
@@ -99,7 +104,7 @@ export function normalizeSourceEntryIds(
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
|
|
102
|
-
const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
107
|
+
const { model, apiKey, headers, env, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
103
108
|
const conversation = chunk.trim();
|
|
104
109
|
if (!conversation) return undefined;
|
|
105
110
|
|
|
@@ -193,7 +198,8 @@ ${conversation}`;
|
|
|
193
198
|
model,
|
|
194
199
|
apiKey,
|
|
195
200
|
headers,
|
|
196
|
-
|
|
201
|
+
env,
|
|
202
|
+
maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
|
|
197
203
|
convertToLlm: (msgs) => msgs as Message[],
|
|
198
204
|
toolExecution: "sequential",
|
|
199
205
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
@@ -208,7 +214,13 @@ ${conversation}`;
|
|
|
208
214
|
};
|
|
209
215
|
|
|
210
216
|
const loop = args.agentLoop ?? agentLoop;
|
|
211
|
-
const stream = loop(
|
|
217
|
+
const stream = loop(
|
|
218
|
+
prompts,
|
|
219
|
+
context,
|
|
220
|
+
config,
|
|
221
|
+
signal,
|
|
222
|
+
resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
|
|
223
|
+
);
|
|
212
224
|
let streamError: { stopReason: string; errorMessage?: string } | undefined;
|
|
213
225
|
for await (const event of stream) {
|
|
214
226
|
// Drain events; the tool's execute already collects records.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
4
|
import type { Static } from "typebox";
|
|
6
5
|
import { debugLog } from "../../debug-log.js";
|
|
7
6
|
import { hashId } from "../../ids.js";
|
|
8
7
|
import { logAgentStreamError } from "../stream-errors.js";
|
|
8
|
+
import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
|
|
9
9
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
10
10
|
import { truncateRecordContent } from "../../serialize.js";
|
|
11
11
|
import { REFLECTOR_SYSTEM } from "./prompts.js";
|
|
@@ -23,12 +23,17 @@ interface RunReflectorArgs {
|
|
|
23
23
|
model: Model<any>;
|
|
24
24
|
apiKey?: string;
|
|
25
25
|
headers?: Record<string, string>;
|
|
26
|
+
env?: Record<string, string>;
|
|
26
27
|
reflections: Reflection[];
|
|
27
28
|
observations: Observation[];
|
|
28
29
|
signal?: AbortSignal;
|
|
29
30
|
agentLoop?: typeof agentLoop;
|
|
30
31
|
maxTurns?: number;
|
|
32
|
+
/** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
|
|
33
|
+
maxOutputTokens?: number;
|
|
31
34
|
thinkingLevel?: ModelThinkingLevel;
|
|
35
|
+
modelRegistry?: StreamableModelRegistry;
|
|
36
|
+
streamSimple?: WorkerStreamSimple;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
const RecordReflectionsSchema = Type.Object({
|
|
@@ -105,7 +110,7 @@ function normalizeReflectionContent(content: string): string | undefined {
|
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
export async function runReflector(args: RunReflectorArgs): Promise<Reflection[] | undefined> {
|
|
108
|
-
const { model, apiKey, headers, reflections, observations, signal } = args;
|
|
113
|
+
const { model, apiKey, headers, env, reflections, observations, signal } = args;
|
|
109
114
|
if (observations.length === 0) return undefined;
|
|
110
115
|
|
|
111
116
|
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
@@ -176,7 +181,8 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
176
181
|
model,
|
|
177
182
|
apiKey,
|
|
178
183
|
headers,
|
|
179
|
-
|
|
184
|
+
env,
|
|
185
|
+
maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
|
|
180
186
|
convertToLlm: (msgs) => msgs as Message[],
|
|
181
187
|
toolExecution: "sequential",
|
|
182
188
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
@@ -184,7 +190,13 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
184
190
|
};
|
|
185
191
|
|
|
186
192
|
const loop = args.agentLoop ?? agentLoop;
|
|
187
|
-
const stream = loop(
|
|
193
|
+
const stream = loop(
|
|
194
|
+
prompts,
|
|
195
|
+
context,
|
|
196
|
+
config,
|
|
197
|
+
signal,
|
|
198
|
+
resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
|
|
199
|
+
);
|
|
188
200
|
for await (const event of stream) {
|
|
189
201
|
// Tool execution collects records.
|
|
190
202
|
logAgentStreamError("reflector", event);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
2
|
+
import { streamSimple as compatStreamSimple } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
|
|
4
|
+
export type WorkerStreamSimple = (
|
|
5
|
+
model: Model<any>,
|
|
6
|
+
context: Context,
|
|
7
|
+
options?: SimpleStreamOptions,
|
|
8
|
+
) => AssistantMessageEventStream;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Duck-typed subset of Pi's extension ModelRegistry.
|
|
12
|
+
*
|
|
13
|
+
* `streamSimple` is the host-composed path (Pi #8964). Until that lands on the
|
|
14
|
+
* facade, `getRegisteredProviderConfig` exposes each `registerProvider`
|
|
15
|
+
* `streamSimple` handler by provider id. Use only the model's exact provider
|
|
16
|
+
* and require matching API metadata as a consistency check.
|
|
17
|
+
*/
|
|
18
|
+
export type StreamableModelRegistry = {
|
|
19
|
+
streamSimple?: WorkerStreamSimple;
|
|
20
|
+
getRegisteredProviderConfig?: (providerId: string) => {
|
|
21
|
+
api?: string;
|
|
22
|
+
streamSimple?: WorkerStreamSimple;
|
|
23
|
+
} | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the stream function background workers must pass to `agentLoop`.
|
|
28
|
+
*
|
|
29
|
+
* Direct `@earendil-works/pi-ai/compat` `streamSimple` only knows built-in API
|
|
30
|
+
* ids. Custom providers (`cursor-sdk`, `cliproxyapi-*`, commandcode, …) live on
|
|
31
|
+
* Pi's composed runtime. Using compat after a successful foreground turn is
|
|
32
|
+
* what crashes Pi with `No API provider registered for api: …` (#30).
|
|
33
|
+
*/
|
|
34
|
+
export function resolveWorkerStreamSimple(
|
|
35
|
+
model: Model<any>,
|
|
36
|
+
modelRegistry?: StreamableModelRegistry | null,
|
|
37
|
+
override?: WorkerStreamSimple,
|
|
38
|
+
): WorkerStreamSimple {
|
|
39
|
+
if (override) return override;
|
|
40
|
+
|
|
41
|
+
const registryStream = modelRegistry?.streamSimple;
|
|
42
|
+
if (typeof registryStream === "function") {
|
|
43
|
+
return (nextModel, context, options) => registryStream(nextModel, context, options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const config = modelRegistry?.getRegisteredProviderConfig?.(model.provider);
|
|
48
|
+
const composed = config?.streamSimple;
|
|
49
|
+
if (config?.api === model.api && typeof composed === "function") {
|
|
50
|
+
return composed;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Incomplete host/test doubles still use the built-in compat dispatcher.
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return compatStreamSimple;
|
|
57
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -45,6 +45,14 @@ export interface Config {
|
|
|
45
45
|
observationsPoolMaxTokens: number;
|
|
46
46
|
observationsPoolTargetTokens: number;
|
|
47
47
|
agentMaxTurns: number;
|
|
48
|
+
/**
|
|
49
|
+
* Maximum output tokens requested for background memory-agent loops
|
|
50
|
+
* (observer/reflector/dropper). Always clamped to the model's own
|
|
51
|
+
* `maxTokens` when available. Lower it for local servers with a modest
|
|
52
|
+
* context window, where concurrent sub-agent requests share KV with the
|
|
53
|
+
* main session and the default 32K response budget can overflow the slot.
|
|
54
|
+
*/
|
|
55
|
+
agentMaxTokens: number;
|
|
48
56
|
model?: ConfiguredModel;
|
|
49
57
|
showWorkerNotifications: boolean;
|
|
50
58
|
passive: boolean;
|
|
@@ -60,6 +68,7 @@ export const DEFAULTS: Config = {
|
|
|
60
68
|
observationsPoolMaxTokens: 20_000,
|
|
61
69
|
observationsPoolTargetTokens: 10_000,
|
|
62
70
|
agentMaxTurns: 16,
|
|
71
|
+
agentMaxTokens: 32_000,
|
|
63
72
|
showWorkerNotifications: true,
|
|
64
73
|
passive: false,
|
|
65
74
|
debugLog: false,
|
|
@@ -189,6 +198,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
|
|
|
189
198
|
"observationsPoolMaxTokens",
|
|
190
199
|
"observationsPoolTargetTokens",
|
|
191
200
|
"agentMaxTurns",
|
|
201
|
+
"agentMaxTokens",
|
|
192
202
|
] as const;
|
|
193
203
|
for (const key of numberKeys) {
|
|
194
204
|
const normalizedValue = positiveIntegerOrUndefined(value[key]);
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
SessionBeforeCompactEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
6
|
|
|
3
7
|
import type { Runtime } from "../runtime.js";
|
|
4
8
|
import { buildCompactionProjection, renderSummary, type Entry } from "../session-ledger/index.js";
|
|
@@ -13,7 +17,7 @@ function observationsPoolMaxTokens(runtime: Runtime): number {
|
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
|
|
16
|
-
pi.on("session_before_compact", async (event:
|
|
20
|
+
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
17
21
|
if (runtime.compactHookInFlight) {
|
|
18
22
|
if (ctx.hasUI) {
|
|
19
23
|
ctx.ui.notify(
|
|
@@ -35,6 +39,10 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
35
39
|
{ observationsPoolMaxTokens: observationsPoolMaxTokens(runtime) },
|
|
36
40
|
);
|
|
37
41
|
const summary = renderSummary(projection.reflections, projection.observations);
|
|
42
|
+
if (summary.length === 0) {
|
|
43
|
+
// Decline ownership so Pi's native summarizer preserves the pre-cut context.
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
38
46
|
|
|
39
47
|
return {
|
|
40
48
|
compaction: {
|
|
@@ -3,35 +3,14 @@ import { resolveCompactAfterTokens } from "../config.js";
|
|
|
3
3
|
import { rawTokensSinceLastCompaction, type Entry } from "../session-ledger/index.js";
|
|
4
4
|
import type { Runtime } from "../runtime.js";
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Regex matching Pi's internal retryable error detection.
|
|
8
|
-
* When the last assistant message in agent_end has stopReason "error" matching this pattern,
|
|
9
|
-
* Pi will auto-retry — we must not trigger compaction between attempts.
|
|
10
|
-
*/
|
|
11
|
-
const RETRYABLE_ERROR_RE =
|
|
12
|
-
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
|
|
13
|
-
|
|
14
6
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
15
|
-
|
|
7
|
+
// Pi emits agent_settled only after retries, automatic compaction, and queued
|
|
8
|
+
// continuation have finished, so retry policy stays owned by Pi.
|
|
9
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
16
10
|
runtime.ensureConfig(ctx.cwd);
|
|
17
11
|
if (runtime.config.passive === true) return;
|
|
18
12
|
if (runtime.compactInFlight) return;
|
|
19
13
|
|
|
20
|
-
// Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
|
|
21
|
-
// Pi emits agent_end before its own retry check, so we must detect this ourselves.
|
|
22
|
-
// The next agent_end (after retry succeeds or exhausts attempts) will re-evaluate.
|
|
23
|
-
const lastAssistant = [...event.messages].reverse().find(
|
|
24
|
-
(m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
|
|
25
|
-
);
|
|
26
|
-
if (
|
|
27
|
-
lastAssistant
|
|
28
|
-
&& lastAssistant.stopReason === "error"
|
|
29
|
-
&& lastAssistant.errorMessage
|
|
30
|
-
&& RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)
|
|
31
|
-
) {
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
14
|
const entries = ctx.sessionManager?.getBranch?.() as Entry[] | undefined;
|
|
36
15
|
if (!entries) return;
|
|
37
16
|
const progress = rawTokensSinceLastCompaction(entries);
|
|
@@ -124,6 +124,22 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
|
|
|
124
124
|
});
|
|
125
125
|
if (cached.ok) {
|
|
126
126
|
runtime.resolveFailureNotified = false;
|
|
127
|
+
// Console Go (opencode.ai) rejects requests without x-opencode-session
|
|
128
|
+
// (400 MissingSessionID). Mirror pi's own session headers on worker calls.
|
|
129
|
+
const model = (cached.model ?? {}) as { provider?: string; baseUrl?: string };
|
|
130
|
+
if (model.provider === "opencode" || model.provider === "opencode-go" || (typeof model.baseUrl === "string" && model.baseUrl.includes("opencode.ai"))) {
|
|
131
|
+
const sessionId = ctx.sessionManager.getSessionId?.();
|
|
132
|
+
if (sessionId) {
|
|
133
|
+
return {
|
|
134
|
+
...cached,
|
|
135
|
+
headers: {
|
|
136
|
+
...(cached.headers ?? {}),
|
|
137
|
+
"x-opencode-session": sessionId,
|
|
138
|
+
"x-opencode-client": "pi",
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
127
143
|
return cached;
|
|
128
144
|
}
|
|
129
145
|
debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
|
|
@@ -311,12 +327,15 @@ async function runObserverStage(
|
|
|
311
327
|
model: resolved.model as any,
|
|
312
328
|
apiKey: resolved.apiKey,
|
|
313
329
|
headers: resolved.headers,
|
|
330
|
+
env: resolved.env,
|
|
314
331
|
priorReflections,
|
|
315
332
|
priorObservations,
|
|
316
333
|
chunk,
|
|
317
334
|
allowedSourceEntryIds: sourceEntryIds,
|
|
318
335
|
maxTurns: runtime.config.agentMaxTurns,
|
|
336
|
+
maxOutputTokens: runtime.config.agentMaxTokens,
|
|
319
337
|
thinkingLevel: runtime.config.model?.thinking ?? "low",
|
|
338
|
+
modelRegistry: ctx.modelRegistry,
|
|
320
339
|
});
|
|
321
340
|
} catch (error) {
|
|
322
341
|
if (error instanceof ObserverStreamError) {
|
|
@@ -383,10 +402,13 @@ async function runReflectorStage(
|
|
|
383
402
|
model: resolved.model as any,
|
|
384
403
|
apiKey: resolved.apiKey,
|
|
385
404
|
headers: resolved.headers,
|
|
405
|
+
env: resolved.env,
|
|
386
406
|
reflections: folded.reflections,
|
|
387
407
|
observations: folded.activeObservations,
|
|
388
408
|
maxTurns: runtime.config.agentMaxTurns,
|
|
409
|
+
maxOutputTokens: runtime.config.agentMaxTokens,
|
|
389
410
|
thinkingLevel: runtime.config.model?.thinking ?? "low",
|
|
411
|
+
modelRegistry: ctx.modelRegistry,
|
|
390
412
|
});
|
|
391
413
|
if (!reflections) return { outcome: "continue", sameRunReflections: [] };
|
|
392
414
|
|
|
@@ -455,11 +477,14 @@ async function runDropperStage(
|
|
|
455
477
|
model: resolved.model as any,
|
|
456
478
|
apiKey: resolved.apiKey,
|
|
457
479
|
headers: resolved.headers,
|
|
480
|
+
env: resolved.env,
|
|
458
481
|
reflections: reflectionsForDropper,
|
|
459
482
|
observations: folded.activeObservations,
|
|
460
483
|
targetTokens: runtime.config.observationsPoolTargetTokens,
|
|
461
484
|
maxTurns: runtime.config.agentMaxTurns,
|
|
485
|
+
maxOutputTokens: runtime.config.agentMaxTokens,
|
|
462
486
|
thinkingLevel: runtime.config.model?.thinking ?? "low",
|
|
487
|
+
modelRegistry: ctx.modelRegistry,
|
|
463
488
|
});
|
|
464
489
|
const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, sameRunReflectionCoverageId);
|
|
465
490
|
const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
|
package/src/runtime.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type Config, DEFAULTS, loadConfig } from "./config.js";
|
|
2
|
+
import { debugLog } from "./debug-log.js";
|
|
2
3
|
|
|
3
4
|
export type ResolveResult =
|
|
4
|
-
| { ok: true; model: unknown; apiKey?: string; headers?: Record<string, string
|
|
5
|
+
| { ok: true; model: unknown; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string>; baseUrl?: string }
|
|
5
6
|
| { ok: false; reason: string };
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -10,21 +11,79 @@ export type ResolveResult =
|
|
|
10
11
|
* OAuth providers (kimi-coding, xai, openai-codex, anthropic OAuth, …) authenticate via
|
|
11
12
|
* `toAuth()` returning `{ headers: { Authorization: "Bearer …" } }` with no apiKey, and
|
|
12
13
|
* pi-ai providers accept a caller-supplied Authorization header in place of an apiKey.
|
|
14
|
+
*
|
|
15
|
+
* NOTE: a `false` result does NOT mean "unauthenticated" — see `resolveModel`. Providers
|
|
16
|
+
* that authenticate at request time (Amazon Bedrock SigV4 from AWS_PROFILE/SSO, Google
|
|
17
|
+
* Vertex ADC) legitimately expose neither an apiKey nor a header, because pi signs their
|
|
18
|
+
* requests itself.
|
|
13
19
|
*/
|
|
14
20
|
function hasUsableAuth(auth: { apiKey?: unknown; headers?: unknown }): boolean {
|
|
15
21
|
if (typeof auth.apiKey === "string" && auth.apiKey.length > 0) return true;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
22
|
+
return countUsableHeaders(auth.headers) > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How many headers the auth payload carries at all (diagnostics only, never values). */
|
|
26
|
+
function countHeaders(headers: unknown): number {
|
|
27
|
+
return headers && typeof headers === "object" ? Object.keys(headers as Record<string, unknown>).length : 0;
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/** How many headers carry a non-empty string value — the ones pi could actually send. */
|
|
31
|
+
function countUsableHeaders(headers: unknown): number {
|
|
32
|
+
if (!headers || typeof headers !== "object") return 0;
|
|
33
|
+
return Object.values(headers as Record<string, unknown>).filter(
|
|
34
|
+
(value) => typeof value === "string" && value.length > 0,
|
|
35
|
+
).length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long to wait for the availability re-check in `recheckProviderCredential`, and how
|
|
40
|
+
* long before the same provider may be re-checked again.
|
|
41
|
+
*
|
|
42
|
+
* The re-check is network-free and measured at ~1ms on a warm Bedrock/SSO host, but
|
|
43
|
+
* `checkAuth` can block on a provider's own credential resolution, so it is bounded. The
|
|
44
|
+
* re-arm interval keeps an unauthenticated host from paying the cost on every
|
|
45
|
+
* consolidation while still recovering within a session when credentials are renewed out
|
|
46
|
+
* of band (`aws sso login` in another terminal, `gcloud auth application-default login`).
|
|
47
|
+
*/
|
|
48
|
+
const AVAILABILITY_RECHECK_TIMEOUT_MS = 5_000;
|
|
49
|
+
const AVAILABILITY_RECHECK_REARM_MS = 60_000;
|
|
50
|
+
|
|
24
51
|
type NotifyLevel = "warning" | "info" | "error";
|
|
25
52
|
type Notify = (message: string, type?: NotifyLevel) => void;
|
|
26
53
|
export type ConsolidationPhase = "observer" | "reflector" | "dropper";
|
|
27
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Whether pi positively reports a working credential source for this model's provider.
|
|
57
|
+
*
|
|
58
|
+
* `ModelRegistry.hasConfiguredAuth(model)` is true when pi's availability check
|
|
59
|
+
* (`ModelRuntime.checkAuth`) resolved *something* for the provider — an API key, a
|
|
60
|
+
* stored credential, or an ambient source such as `AWS_PROFILE` / `AWS_ACCESS_KEY_ID`
|
|
61
|
+
* / gcloud ADC. Combined with `auth.ok === true` and an auth payload that carries
|
|
62
|
+
* nothing, that is the signature of a provider pi signs at request time:
|
|
63
|
+
*
|
|
64
|
+
* pi has a credential source, and deliberately hands the caller nothing to attach.
|
|
65
|
+
*
|
|
66
|
+
* Measured on a Bedrock/SSO host (pi 0.84.2), with `AWS_PROFILE` exported:
|
|
67
|
+
* checkAuth("amazon-bedrock") -> { source: "AWS_PROFILE", type: "api_key" }
|
|
68
|
+
* hasConfiguredAuth(model) -> true
|
|
69
|
+
* getApiKeyAndHeaders(model) -> { ok: true, apiKey: undefined, headers: undefined }
|
|
70
|
+
* `googleVertexProvider`'s ADC branch returns the same empty-auth resolution.
|
|
71
|
+
*
|
|
72
|
+
* The inverse case — `hasConfiguredAuth === false` with an empty auth payload — is a
|
|
73
|
+
* provider pi could not authenticate at all (no key, no ambient source). That must
|
|
74
|
+
* keep failing: it is the ordinary "not logged in" state, not ambient auth.
|
|
75
|
+
*
|
|
76
|
+
* Defensive: older pi versions and partial test doubles may not expose this, and an
|
|
77
|
+
* unknown answer must not be read as "authenticated".
|
|
78
|
+
*/
|
|
79
|
+
function hasConfiguredProviderCredential(registry: unknown, model: unknown): boolean {
|
|
80
|
+
try {
|
|
81
|
+
return (registry as { hasConfiguredAuth?: (m: unknown) => unknown }).hasConfiguredAuth?.(model) === true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
28
87
|
export interface ResolveCtx {
|
|
29
88
|
model: unknown;
|
|
30
89
|
modelRegistry: any;
|
|
@@ -49,6 +108,8 @@ export class Runtime {
|
|
|
49
108
|
lastObserverError: string | undefined;
|
|
50
109
|
lastReflectorError: string | undefined;
|
|
51
110
|
lastDropperError: string | undefined;
|
|
111
|
+
/** provider -> epoch ms of the last availability re-check (see `recheckProviderCredential`). */
|
|
112
|
+
availabilityRecheckedAt = new Map<string, number>();
|
|
52
113
|
/** Deliberate-empty backoff (#23): skip observer re-fires over the same span until enough new tokens arrive. */
|
|
53
114
|
observerEmptyBackoff: {
|
|
54
115
|
sessionIdentity: string | undefined;
|
|
@@ -78,14 +139,148 @@ export class Runtime {
|
|
|
78
139
|
if (!model) return { ok: false, reason: "no model available (session has no model and no observational-memory model configured)" };
|
|
79
140
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
80
141
|
const provider = (model as { provider?: string }).provider ?? "unknown";
|
|
81
|
-
|
|
82
|
-
|
|
142
|
+
const isOAuth = ctx.modelRegistry.isUsingOAuth?.(model) === true;
|
|
143
|
+
// `auth.ok === false` is the only unambiguous failure: pi returns it when a
|
|
144
|
+
// provider requires a request auth header and no credential resolved.
|
|
145
|
+
//
|
|
146
|
+
// `auth.ok === true` with neither apiKey nor headers, for a provider pi DOES
|
|
147
|
+
// report a credential source for, is not a failure — it is how pi describes a
|
|
148
|
+
// provider that authenticates at request time: Amazon Bedrock signing SigV4 from
|
|
149
|
+
// ambient AWS credentials (`bedrockAuth.resolve` returns `{ auth: {}, source:
|
|
150
|
+
// "AWS_PROFILE" }`), Google Vertex using ADC (same empty resolution). pi's own
|
|
151
|
+
// native streaming path forwards no apiKey either, and its pre-prompt gate is
|
|
152
|
+
// merely `hasConfiguredAuth(provider) || checkAuth(provider) !== undefined` —
|
|
153
|
+
// om's pre-flight check must not be stricter than pi's own. Treating it as "no
|
|
154
|
+
// auth" aborted consolidation before the model was ever called, disabling
|
|
155
|
+
// observational memory silently — no error, no cost, no latency — on such hosts.
|
|
156
|
+
//
|
|
157
|
+
// Three cases deliberately keep failing: OAuth providers, where an empty
|
|
158
|
+
// resolution means the credentials no longer resolve and the user must log in
|
|
159
|
+
// again; a credential that resolved to an empty *string* key, which is a
|
|
160
|
+
// misconfiguration rather than ambient auth; and a provider pi reports no
|
|
161
|
+
// credential source for at all, which is simply unauthenticated.
|
|
162
|
+
const usable = hasUsableAuth(auth);
|
|
163
|
+
const resolvedEmptyApiKey = typeof auth.apiKey === "string" && auth.apiKey.length === 0;
|
|
164
|
+
let providerCredentialConfigured = hasConfiguredProviderCredential(ctx.modelRegistry, model);
|
|
165
|
+
// pi's gate has TWO halves and never trusts the snapshot alone (agent-session.js):
|
|
166
|
+
//
|
|
167
|
+
// hasConfiguredAuth(provider) || (await checkAuth(provider)) !== undefined
|
|
168
|
+
//
|
|
169
|
+
// `hasConfiguredAuth` reads `snapshot.configuredProviders`, which is populated by an
|
|
170
|
+
// availability pass — and left untouched when that pass is skipped
|
|
171
|
+
// (`refreshOnCreate: false`), aborted, or FAILS (its catch records `availabilityError`
|
|
172
|
+
// and returns). A provider whose credential could not be checked at startup — an
|
|
173
|
+
// expired SSO token, say — is therefore absent from the snapshot for the rest of the
|
|
174
|
+
// session, even after the user renews it out of band. pi recovers on the next turn
|
|
175
|
+
// because its second half re-checks live; reading only the snapshot half would leave
|
|
176
|
+
// consolidation dead for the whole session, which is the same silent-failure class as
|
|
177
|
+
// the bug this gate was fixed for.
|
|
178
|
+
//
|
|
179
|
+
// The facade exposes no `checkAuth`, but `refresh({ providers })` performs the same
|
|
180
|
+
// live check and then updates the snapshot, so re-reading afterwards is equivalent.
|
|
181
|
+
// Only attempted when everything else already looks like the ambient shape, so an
|
|
182
|
+
// ordinary unauthenticated provider still fails on the first call.
|
|
183
|
+
if (auth.ok === true && !usable && !isOAuth && !resolvedEmptyApiKey && !providerCredentialConfigured) {
|
|
184
|
+
providerCredentialConfigured = await this.recheckProviderCredential(ctx.modelRegistry, model, provider);
|
|
185
|
+
}
|
|
186
|
+
const signsAtRequestTime =
|
|
187
|
+
auth.ok === true && !isOAuth && !resolvedEmptyApiKey && providerCredentialConfigured;
|
|
188
|
+
if (!auth.ok || (!usable && !signsAtRequestTime)) {
|
|
83
189
|
const reason = isOAuth
|
|
84
190
|
? `authentication failed for provider "${provider}" — OAuth credentials may have expired; run '/login ${provider}' to re-authenticate`
|
|
85
191
|
: `no API key or auth headers for provider "${provider}"`;
|
|
192
|
+
// The reason string alone cannot tell `ok: false` from `ok: true` with nothing to
|
|
193
|
+
// carry, which is what made the ambient-credential outage un-diagnosable from the
|
|
194
|
+
// debug log. Record the decision inputs — booleans and counts only, never values.
|
|
195
|
+
debugLog("resolve.rejected", {
|
|
196
|
+
provider,
|
|
197
|
+
reason,
|
|
198
|
+
authOk: auth.ok === true,
|
|
199
|
+
hasApiKey: typeof auth.apiKey === "string" && auth.apiKey.length > 0,
|
|
200
|
+
resolvedEmptyApiKey,
|
|
201
|
+
headerCount: countHeaders(auth.headers),
|
|
202
|
+
usableHeaderCount: countUsableHeaders(auth.headers),
|
|
203
|
+
isOAuth,
|
|
204
|
+
providerCredentialConfigured,
|
|
205
|
+
signsAtRequestTime,
|
|
206
|
+
});
|
|
86
207
|
return { ok: false, reason };
|
|
87
208
|
}
|
|
88
|
-
|
|
209
|
+
if (!usable) {
|
|
210
|
+
debugLog("resolve.request_time_signing", { provider, providerCredentialConfigured });
|
|
211
|
+
}
|
|
212
|
+
// Match pi's request model: OAuth may route to an account-specific endpoint
|
|
213
|
+
// (e.g. Copilot Business). Do not mutate the shared session/registry model.
|
|
214
|
+
const requestModel = auth.baseUrl ? { ...(model as object), baseUrl: auth.baseUrl } : model;
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
model: requestModel,
|
|
218
|
+
apiKey: auth.apiKey as string | undefined,
|
|
219
|
+
headers: auth.headers as Record<string, string> | undefined,
|
|
220
|
+
env: auth.env as Record<string, string> | undefined,
|
|
221
|
+
baseUrl: auth.baseUrl as string | undefined,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Re-check one provider's credential live, then re-read pi's snapshot.
|
|
227
|
+
*
|
|
228
|
+
* Implements the second half of pi's own auth gate for the only case that needs it: an
|
|
229
|
+
* otherwise-ambient-looking resolution whose provider is missing from a stale or never
|
|
230
|
+
* populated availability snapshot. Bounded and rate-limited; never throws.
|
|
231
|
+
*/
|
|
232
|
+
private async recheckProviderCredential(registry: unknown, model: unknown, provider: string): Promise<boolean> {
|
|
233
|
+
const last = this.availabilityRecheckedAt.get(provider);
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
if (last !== undefined && now - last < AVAILABILITY_RECHECK_REARM_MS) return false;
|
|
236
|
+
this.availabilityRecheckedAt.set(provider, now);
|
|
237
|
+
|
|
238
|
+
const refresh = (registry as { refresh?: (options?: unknown) => Promise<unknown> }).refresh;
|
|
239
|
+
if (typeof refresh !== "function") {
|
|
240
|
+
debugLog("resolve.availability_recheck", { provider, refreshed: false, reason: "registry exposes no refresh()" });
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const timer = setTimeout(() => controller.abort(), AVAILABILITY_RECHECK_TIMEOUT_MS);
|
|
246
|
+
let refreshError: string | undefined;
|
|
247
|
+
let timedOut = false;
|
|
248
|
+
try {
|
|
249
|
+
// allowNetwork:false — a credential re-check must not wait on a model-catalog fetch.
|
|
250
|
+
// providers:[provider] — scope the work, and the snapshot writes, to the one provider.
|
|
251
|
+
//
|
|
252
|
+
// Both are honoured from pi 0.84; on pi 0.81 the facade is `refresh()` with no
|
|
253
|
+
// parameters, delegating to `runtime.reloadConfig()`, which reloads models.json and
|
|
254
|
+
// then runs a FULL, network-permitted availability pass. Passing the options is
|
|
255
|
+
// harmless there, but the work is wider and slower — hence the race below rather
|
|
256
|
+
// than relying on the abort signal, which that version never sees.
|
|
257
|
+
await Promise.race([
|
|
258
|
+
refresh.call(registry, { allowNetwork: false, providers: [provider], signal: controller.signal }),
|
|
259
|
+
new Promise<void>((resolve) => {
|
|
260
|
+
controller.signal.addEventListener("abort", () => {
|
|
261
|
+
timedOut = true;
|
|
262
|
+
resolve();
|
|
263
|
+
});
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
refreshError = error instanceof Error ? error.message : String(error);
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Re-read even when the refresh reported an error or timed out: a scoped pass can
|
|
273
|
+
// update the snapshot for this provider and still fail elsewhere.
|
|
274
|
+
const recovered = hasConfiguredProviderCredential(registry, model);
|
|
275
|
+
debugLog("resolve.availability_recheck", {
|
|
276
|
+
provider,
|
|
277
|
+
refreshed: refreshError === undefined && !timedOut,
|
|
278
|
+
recovered,
|
|
279
|
+
elapsedMs: Date.now() - now,
|
|
280
|
+
timedOut,
|
|
281
|
+
...(refreshError === undefined ? {} : { refreshError }),
|
|
282
|
+
});
|
|
283
|
+
return recovered;
|
|
89
284
|
}
|
|
90
285
|
|
|
91
286
|
launchConsolidationTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> {
|