pi-observational-memory 3.0.3 → 3.1.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 +31 -11
- package/package.json +5 -5
- package/src/agents/dropper/agent.ts +20 -5
- package/src/agents/dropper/pool.ts +6 -2
- package/src/agents/observer/agent.ts +53 -8
- package/src/agents/reflector/agent.ts +20 -5
- package/src/agents/stream-errors.ts +22 -0
- package/src/agents/worker-stream.ts +65 -0
- package/src/commands/status.ts +1 -1
- package/src/config.ts +63 -1
- package/src/hooks/compaction-hook.ts +10 -2
- package/src/hooks/compaction-trigger.ts +15 -34
- package/src/hooks/consolidation-trigger.ts +165 -34
- package/src/runtime.ts +227 -5
- package/src/serialize.ts +59 -3
- package/src/session-ledger/progress.ts +100 -0
- package/src/tokens.ts +18 -0
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
|
```
|
|
@@ -218,6 +220,7 @@ A typical config:
|
|
|
218
220
|
"id": "google/gemma-4-31b-it",
|
|
219
221
|
"thinking": "low"
|
|
220
222
|
},
|
|
223
|
+
"showWorkerNotifications": true,
|
|
221
224
|
"passive": false,
|
|
222
225
|
"debugLog": false
|
|
223
226
|
}
|
|
@@ -226,12 +229,14 @@ A typical config:
|
|
|
226
229
|
|
|
227
230
|
Most users can start with the defaults and tune only if they have a specific reason.
|
|
228
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
|
+
|
|
229
234
|
### Scaling compaction to the model's context window
|
|
230
235
|
|
|
231
236
|
By default `compactAfterTokensMode` is `"calibrated"`, so the proactive
|
|
232
|
-
compaction trigger
|
|
233
|
-
default).
|
|
234
|
-
context models.
|
|
237
|
+
compaction trigger uses the fixed `compactAfterTokens` estimated source-entry
|
|
238
|
+
threshold (81,000 by default). This preserves the pre-PR #40 compaction metric
|
|
239
|
+
for typical ~128K–200K context models.
|
|
235
240
|
|
|
236
241
|
On a large-context model (e.g. 1M tokens) the calibrated default preempts
|
|
237
242
|
compaction at ~81K, wasting most of the window. Switch to `"ratio"` mode to let
|
|
@@ -249,8 +254,11 @@ the trigger scale with the active model's `contextWindow`:
|
|
|
249
254
|
|
|
250
255
|
In ratio mode the effective threshold is
|
|
251
256
|
`floor(model.contextWindow * compactAfterTokensRatio)` (clamped to a minimum of
|
|
252
|
-
1). With the example above, a 1,000,000-token window compacts
|
|
253
|
-
|
|
257
|
+
1). With the example above, a 1,000,000-token window compacts after about
|
|
258
|
+
500,000 estimated source-entry tokens after the latest compaction boundary; a
|
|
259
|
+
200,000-token window uses about 100,000. The threshold counts source entries,
|
|
260
|
+
not Pi's system prompt, tool schemas, or provider accounting. Pi's native
|
|
261
|
+
window-pressure compaction remains independent.
|
|
254
262
|
|
|
255
263
|
`compactAfterTokensRatio` is user-tunable precisely because **context window ≠
|
|
256
264
|
attention**. Some models advertise a large window but degrade at long range; set
|
|
@@ -268,14 +276,17 @@ on the `Next compaction` line regardless of mode.
|
|
|
268
276
|
| Setting | Default | Meaning |
|
|
269
277
|
| --------------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
|
|
270
278
|
| `observeAfterTokens` | `10000` | Raw/source token threshold for observation runs. |
|
|
279
|
+
| `observerChunkMaxTokens` | derived | Max estimated tokens serialized into one observer chunk (minimum `256`). Unset: `floor(contextWindow * 0.2)` of the resolved memory model, or `60000` when the window is unknown. Larger backlogs drain oldest-first; a single over-budget source is sent as a marked head/tail excerpt while the original source remains in the session ledger. |
|
|
271
280
|
| `reflectAfterTokens` | `20000` | Raw/source token threshold for reflection runs; successful reflection creates dropper opportunities. |
|
|
272
|
-
| `compactAfterTokens` | `81000` |
|
|
273
|
-
| `compactAfterTokensMode` | `"calibrated"`| `"calibrated"` uses `compactAfterTokens` directly
|
|
281
|
+
| `compactAfterTokens` | `81000` | Estimated source-entry threshold for proactive auto-compaction, counted after the latest compaction boundary. |
|
|
282
|
+
| `compactAfterTokensMode` | `"calibrated"`| `"calibrated"` uses `compactAfterTokens` directly. `"ratio"` scales the source-entry threshold by the active model's `contextWindow`. |
|
|
274
283
|
| `compactAfterTokensRatio` | `0.68` | In `"ratio"` mode, the threshold is `floor(contextWindow * ratio)`. Tunable because large windows do not always mean strong long-range attention. Must be in `(0, 1)`. |
|
|
275
284
|
| `observationsPoolMaxTokens` | `20000` | Observation-token budget used for compaction full-fold pressure. |
|
|
276
285
|
| `observationsPoolTargetTokens` | half of max | Active observation target used by post-reflection dropper maintenance. |
|
|
277
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`. |
|
|
278
288
|
| `model` | session model | Optional memory-worker model override: `{ provider, id, thinking }`. |
|
|
289
|
+
| `showWorkerNotifications` | `true` | Shows routine observer, reflector, and dropper progress notifications. Warnings and errors are unaffected. |
|
|
279
290
|
| `passive` | `false` | Disables proactive background observation, reflection, maintenance, and auto-compaction triggers. |
|
|
280
291
|
| `debugLog` | `false` | Writes opt-in per-session extension debug events to Pi's agent directory. |
|
|
281
292
|
|
|
@@ -287,8 +298,11 @@ Valid `model.thinking` values are:
|
|
|
287
298
|
* `medium`
|
|
288
299
|
* `high`
|
|
289
300
|
* `xhigh`
|
|
301
|
+
* `max`
|
|
290
302
|
|
|
291
|
-
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.
|
|
304
|
+
|
|
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.
|
|
292
306
|
|
|
293
307
|
`observationsPoolMaxTokens` and `observationsPoolTargetTokens` intentionally describe different pools. Max tokens control when compaction performs a full fold over visible memory. Target tokens control the folded active observation pool that the dropper maintains after successful reflection. If the target is omitted, it defaults to half of max.
|
|
294
308
|
|
|
@@ -320,14 +334,14 @@ flowchart TD
|
|
|
320
334
|
Turn[turn_end]
|
|
321
335
|
Observe[Capture observations]
|
|
322
336
|
Reflect[Distill reflections]
|
|
323
|
-
|
|
337
|
+
AgentSettled[agent_settled]
|
|
324
338
|
Trigger[auto-compaction trigger]
|
|
325
339
|
Compact[session_before_compact]
|
|
326
340
|
Summary[visible memory for Pi]
|
|
327
341
|
|
|
328
342
|
Turn -->|observation due| Observe
|
|
329
343
|
Turn -->|reflection due| Reflect
|
|
330
|
-
|
|
344
|
+
AgentSettled -->|compactAfterTokens and idle| Trigger --> Compact --> Summary
|
|
331
345
|
```
|
|
332
346
|
|
|
333
347
|
The high-level lifecycle:
|
|
@@ -340,6 +354,12 @@ The high-level lifecycle:
|
|
|
340
354
|
|
|
341
355
|
The important part: compaction does not need to rethink the whole session from scratch.
|
|
342
356
|
|
|
357
|
+
The proactive compaction threshold counts estimated source-entry tokens after
|
|
358
|
+
the latest compaction boundary. It includes source entries retained by
|
|
359
|
+
`firstKeptEntryId` and newer source entries, while memory ledger entries and
|
|
360
|
+
compaction metadata contribute zero. `/om:status` uses the same metric. Pi's
|
|
361
|
+
own window-pressure compaction remains independent.
|
|
362
|
+
|
|
343
363
|
---
|
|
344
364
|
|
|
345
365
|
## Current V3 behavior
|
|
@@ -348,7 +368,7 @@ Current behavior:
|
|
|
348
368
|
|
|
349
369
|
* **Observation-centered memory.** The extension records useful session observations while you work.
|
|
350
370
|
* **Durable reflections.** The extension distills stable facts that help the agent stay oriented over time.
|
|
351
|
-
* **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.
|
|
352
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`.
|
|
353
373
|
* **Source-backed recall.** Observations and reflections can be traced back through the `recall` tool.
|
|
354
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-observational-memory",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Observational memory extension for pi — cache-friendly tiered compaction with observations and reflections.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"@earendil-works/pi-tui": "*"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
44
|
-
"@earendil-works/pi-ai": "^0.
|
|
45
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
46
|
-
"@earendil-works/pi-tui": "^0.
|
|
43
|
+
"@earendil-works/pi-agent-core": "^0.81.0",
|
|
44
|
+
"@earendil-works/pi-ai": "^0.81.0",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^0.81.0",
|
|
46
|
+
"@earendil-works/pi-tui": "^0.81.0",
|
|
47
47
|
"@types/node": "^22.0.0",
|
|
48
48
|
"typebox": "^1.1.38",
|
|
49
49
|
"typescript": "^5.6.0",
|
|
@@ -4,6 +4,8 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import type { Static } from "typebox";
|
|
5
5
|
import { debugLog } from "../../debug-log.js";
|
|
6
6
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
7
|
+
import { logAgentStreamError } from "../stream-errors.js";
|
|
8
|
+
import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
|
|
7
9
|
import { reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
|
|
8
10
|
import { DROPPER_SYSTEM } from "./prompts.js";
|
|
9
11
|
import {
|
|
@@ -37,15 +39,20 @@ export type { CoverageSummaryByRelevance, CoverageTransitionSummaryByRelevance,
|
|
|
37
39
|
|
|
38
40
|
interface RunDropperArgs {
|
|
39
41
|
model: Model<any>;
|
|
40
|
-
apiKey
|
|
42
|
+
apiKey?: string;
|
|
41
43
|
headers?: Record<string, string>;
|
|
44
|
+
env?: Record<string, string>;
|
|
42
45
|
reflections: Reflection[];
|
|
43
46
|
observations: Observation[];
|
|
44
47
|
targetTokens: number;
|
|
45
48
|
signal?: AbortSignal;
|
|
46
49
|
agentLoop?: typeof agentLoop;
|
|
47
50
|
maxTurns?: number;
|
|
51
|
+
/** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
|
|
52
|
+
maxOutputTokens?: number;
|
|
48
53
|
thinkingLevel?: ModelThinkingLevel;
|
|
54
|
+
modelRegistry?: StreamableModelRegistry;
|
|
55
|
+
streamSimple?: WorkerStreamSimple;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
const RELEVANCE_DROP_RANK: Record<Observation["relevance"], number> = {
|
|
@@ -129,7 +136,7 @@ export function selectDropCandidates(
|
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
export async function runDropper(args: RunDropperArgs): Promise<string[] | undefined> {
|
|
132
|
-
const { model, apiKey, headers, reflections, observations, targetTokens, signal } = args;
|
|
139
|
+
const { model, apiKey, headers, env, reflections, observations, targetTokens, signal } = args;
|
|
133
140
|
if (observations.length === 0) return undefined;
|
|
134
141
|
|
|
135
142
|
const metrics = observationPoolMetrics(observations, targetTokens);
|
|
@@ -241,7 +248,8 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
241
248
|
model,
|
|
242
249
|
apiKey,
|
|
243
250
|
headers,
|
|
244
|
-
|
|
251
|
+
env,
|
|
252
|
+
maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
|
|
245
253
|
convertToLlm: (msgs) => msgs as Message[],
|
|
246
254
|
toolExecution: "sequential",
|
|
247
255
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
@@ -249,9 +257,16 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
249
257
|
};
|
|
250
258
|
|
|
251
259
|
const loop = args.agentLoop ?? agentLoop;
|
|
252
|
-
const stream = loop(
|
|
253
|
-
|
|
260
|
+
const stream = loop(
|
|
261
|
+
prompts,
|
|
262
|
+
context,
|
|
263
|
+
config,
|
|
264
|
+
signal,
|
|
265
|
+
resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
|
|
266
|
+
);
|
|
267
|
+
for await (const event of stream) {
|
|
254
268
|
// Tool execution collects candidate ids.
|
|
269
|
+
logAgentStreamError("dropper", event);
|
|
255
270
|
}
|
|
256
271
|
await stream.result();
|
|
257
272
|
const droppedIds = selectDropCandidates(proposedDropIds, observations, maxDropsAllowed, reflections);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observationLineTokenCount } from "../../tokens.js";
|
|
1
2
|
import type { Observation } from "../../session-ledger/index.js";
|
|
2
3
|
|
|
3
4
|
export type ObservationPoolMetrics = {
|
|
@@ -12,8 +13,11 @@ export type ObservationPoolMetrics = {
|
|
|
12
13
|
ready: boolean;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
export function observationTokenSum(observations: readonly
|
|
16
|
-
|
|
16
|
+
export function observationTokenSum(observations: readonly Observation[]): number {
|
|
17
|
+
// Count the full rendered line (id + timestamp + relevance + content), not
|
|
18
|
+
// bare content: the pool budget caps how much observation text is re-rendered
|
|
19
|
+
// into future contexts, and every line carries metadata overhead.
|
|
20
|
+
return observations.reduce((sum, observation) => sum + observationLineTokenCount(observation), 0);
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export function observationPoolFullness(observationTokens: number, targetTokens: number): number {
|
|
@@ -3,16 +3,19 @@ import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { Static } from "typebox";
|
|
5
5
|
import { hashId } from "../../ids.js";
|
|
6
|
+
import { logAgentStreamError } from "../stream-errors.js";
|
|
7
|
+
import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
|
|
6
8
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
7
9
|
import { OBSERVER_SYSTEM } from "./prompts.js";
|
|
8
10
|
import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
|
|
9
11
|
import type { Observation, Relevance } from "../../session-ledger/index.js";
|
|
10
|
-
import {
|
|
12
|
+
import { observationLineTokenCount } from "../../tokens.js";
|
|
11
13
|
|
|
12
14
|
interface RunObserverArgs {
|
|
13
15
|
model: Model<any>;
|
|
14
|
-
apiKey
|
|
16
|
+
apiKey?: string;
|
|
15
17
|
headers?: Record<string, string>;
|
|
18
|
+
env?: Record<string, string>;
|
|
16
19
|
priorReflections: string[];
|
|
17
20
|
priorObservations: string[];
|
|
18
21
|
chunk: string;
|
|
@@ -20,7 +23,11 @@ interface RunObserverArgs {
|
|
|
20
23
|
signal?: AbortSignal;
|
|
21
24
|
agentLoop?: typeof agentLoop;
|
|
22
25
|
maxTurns?: number;
|
|
26
|
+
/** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
|
|
27
|
+
maxOutputTokens?: number;
|
|
23
28
|
thinkingLevel?: ModelThinkingLevel;
|
|
29
|
+
modelRegistry?: StreamableModelRegistry;
|
|
30
|
+
streamSimple?: WorkerStreamSimple;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
const RelevanceSchema = Type.Union([
|
|
@@ -60,6 +67,21 @@ const RecordObservationsSchema = Type.Object({
|
|
|
60
67
|
|
|
61
68
|
type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
|
|
62
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Thrown when the agent loop ends with an API/stream failure (`stopReason`
|
|
72
|
+
* `"error"`/`"aborted"`) without recording anything. agent-core returns such
|
|
73
|
+
* runs normally, so without this the caller cannot tell a hard failure from a
|
|
74
|
+
* deliberate empty result (#32).
|
|
75
|
+
*/
|
|
76
|
+
export class ObserverStreamError extends Error {
|
|
77
|
+
readonly stopReason: string;
|
|
78
|
+
constructor(stopReason: string, errorMessage?: string) {
|
|
79
|
+
super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
|
|
80
|
+
this.name = "ObserverStreamError";
|
|
81
|
+
this.stopReason = stopReason;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
function joinOrEmpty(items: string[]): string {
|
|
64
86
|
return items.length ? items.join("\n") : "(none yet)";
|
|
65
87
|
}
|
|
@@ -82,7 +104,7 @@ export function normalizeSourceEntryIds(
|
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
|
|
85
|
-
const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
107
|
+
const { model, apiKey, headers, env, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
86
108
|
const conversation = chunk.trim();
|
|
87
109
|
if (!conversation) return undefined;
|
|
88
110
|
|
|
@@ -118,7 +140,12 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
|
|
|
118
140
|
timestamp: obs.timestamp,
|
|
119
141
|
relevance: obs.relevance as Relevance,
|
|
120
142
|
sourceEntryIds,
|
|
121
|
-
tokenCount:
|
|
143
|
+
tokenCount: observationLineTokenCount({
|
|
144
|
+
id,
|
|
145
|
+
timestamp: obs.timestamp,
|
|
146
|
+
relevance: obs.relevance,
|
|
147
|
+
content,
|
|
148
|
+
}),
|
|
122
149
|
});
|
|
123
150
|
added++;
|
|
124
151
|
}
|
|
@@ -171,7 +198,8 @@ ${conversation}`;
|
|
|
171
198
|
model,
|
|
172
199
|
apiKey,
|
|
173
200
|
headers,
|
|
174
|
-
|
|
201
|
+
env,
|
|
202
|
+
maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
|
|
175
203
|
convertToLlm: (msgs) => msgs as Message[],
|
|
176
204
|
toolExecution: "sequential",
|
|
177
205
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
@@ -186,12 +214,29 @@ ${conversation}`;
|
|
|
186
214
|
};
|
|
187
215
|
|
|
188
216
|
const loop = args.agentLoop ?? agentLoop;
|
|
189
|
-
const stream = loop(
|
|
190
|
-
|
|
217
|
+
const stream = loop(
|
|
218
|
+
prompts,
|
|
219
|
+
context,
|
|
220
|
+
config,
|
|
221
|
+
signal,
|
|
222
|
+
resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
|
|
223
|
+
);
|
|
224
|
+
let streamError: { stopReason: string; errorMessage?: string } | undefined;
|
|
225
|
+
for await (const event of stream) {
|
|
191
226
|
// Drain events; the tool's execute already collects records.
|
|
227
|
+
logAgentStreamError("observer", event);
|
|
228
|
+
// Watch for a terminal API/stream failure so it is not conflated with
|
|
229
|
+
// a deliberate empty result.
|
|
230
|
+
const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
|
|
231
|
+
if (message?.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
|
|
232
|
+
streamError = { stopReason: message.stopReason, errorMessage: message.errorMessage };
|
|
233
|
+
}
|
|
192
234
|
}
|
|
193
235
|
await stream.result();
|
|
194
236
|
|
|
195
|
-
if (accumulated.size === 0)
|
|
237
|
+
if (accumulated.size === 0) {
|
|
238
|
+
if (streamError) throw new ObserverStreamError(streamError.stopReason, streamError.errorMessage);
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
196
241
|
return Array.from(accumulated.values());
|
|
197
242
|
}
|
|
@@ -4,6 +4,8 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import type { Static } from "typebox";
|
|
5
5
|
import { debugLog } from "../../debug-log.js";
|
|
6
6
|
import { hashId } from "../../ids.js";
|
|
7
|
+
import { logAgentStreamError } from "../stream-errors.js";
|
|
8
|
+
import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
|
|
7
9
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
8
10
|
import { truncateRecordContent } from "../../serialize.js";
|
|
9
11
|
import { REFLECTOR_SYSTEM } from "./prompts.js";
|
|
@@ -19,14 +21,19 @@ import {
|
|
|
19
21
|
|
|
20
22
|
interface RunReflectorArgs {
|
|
21
23
|
model: Model<any>;
|
|
22
|
-
apiKey
|
|
24
|
+
apiKey?: string;
|
|
23
25
|
headers?: Record<string, string>;
|
|
26
|
+
env?: Record<string, string>;
|
|
24
27
|
reflections: Reflection[];
|
|
25
28
|
observations: Observation[];
|
|
26
29
|
signal?: AbortSignal;
|
|
27
30
|
agentLoop?: typeof agentLoop;
|
|
28
31
|
maxTurns?: number;
|
|
32
|
+
/** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
|
|
33
|
+
maxOutputTokens?: number;
|
|
29
34
|
thinkingLevel?: ModelThinkingLevel;
|
|
35
|
+
modelRegistry?: StreamableModelRegistry;
|
|
36
|
+
streamSimple?: WorkerStreamSimple;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
const RecordReflectionsSchema = Type.Object({
|
|
@@ -103,7 +110,7 @@ function normalizeReflectionContent(content: string): string | undefined {
|
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
export async function runReflector(args: RunReflectorArgs): Promise<Reflection[] | undefined> {
|
|
106
|
-
const { model, apiKey, headers, reflections, observations, signal } = args;
|
|
113
|
+
const { model, apiKey, headers, env, reflections, observations, signal } = args;
|
|
107
114
|
if (observations.length === 0) return undefined;
|
|
108
115
|
|
|
109
116
|
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
@@ -174,7 +181,8 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
174
181
|
model,
|
|
175
182
|
apiKey,
|
|
176
183
|
headers,
|
|
177
|
-
|
|
184
|
+
env,
|
|
185
|
+
maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
|
|
178
186
|
convertToLlm: (msgs) => msgs as Message[],
|
|
179
187
|
toolExecution: "sequential",
|
|
180
188
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
@@ -182,9 +190,16 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
182
190
|
};
|
|
183
191
|
|
|
184
192
|
const loop = args.agentLoop ?? agentLoop;
|
|
185
|
-
const stream = loop(
|
|
186
|
-
|
|
193
|
+
const stream = loop(
|
|
194
|
+
prompts,
|
|
195
|
+
context,
|
|
196
|
+
config,
|
|
197
|
+
signal,
|
|
198
|
+
resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
|
|
199
|
+
);
|
|
200
|
+
for await (const event of stream) {
|
|
187
201
|
// Tool execution collects records.
|
|
202
|
+
logAgentStreamError("reflector", event);
|
|
188
203
|
}
|
|
189
204
|
await stream.result();
|
|
190
205
|
const acceptedReflections = Array.from(accumulated.values());
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { debugLog } from "../debug-log.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Surface LLM failures from an agent-loop event stream.
|
|
6
|
+
*
|
|
7
|
+
* When the underlying LLM call fails, the loop ends the stream with a final
|
|
8
|
+
* assistant message whose stopReason is "error" (or "aborted") — no exception
|
|
9
|
+
* is thrown. Without this hook the drain loops treat such runs exactly like
|
|
10
|
+
* "the model chose not to call the tool", which hides the real cause
|
|
11
|
+
* (rate limits, oversized prompts, auth failures, ...) from the debug log.
|
|
12
|
+
*/
|
|
13
|
+
export function logAgentStreamError(stage: "observer" | "reflector" | "dropper", event: AgentEvent): void {
|
|
14
|
+
if (event.type !== "message_end") return;
|
|
15
|
+
const message = event.message;
|
|
16
|
+
if (message.role !== "assistant") return;
|
|
17
|
+
if (message.stopReason !== "error" && message.stopReason !== "aborted") return;
|
|
18
|
+
debugLog(`${stage}.stream_error`, {
|
|
19
|
+
stopReason: message.stopReason,
|
|
20
|
+
errorMessage: message.errorMessage,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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` still exposes each `registerProvider`
|
|
15
|
+
* `streamSimple` handler, keyed by the extension provider id — match on
|
|
16
|
+
* `config.api === model.api`.
|
|
17
|
+
*/
|
|
18
|
+
export type StreamableModelRegistry = {
|
|
19
|
+
streamSimple?: WorkerStreamSimple;
|
|
20
|
+
getRegisteredProviderIds?: () => readonly string[];
|
|
21
|
+
getRegisteredProviderConfig?: (providerId: string) => {
|
|
22
|
+
api?: string;
|
|
23
|
+
streamSimple?: WorkerStreamSimple;
|
|
24
|
+
} | undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the stream function background workers must pass to `agentLoop`.
|
|
29
|
+
*
|
|
30
|
+
* Direct `@earendil-works/pi-ai/compat` `streamSimple` only knows built-in API
|
|
31
|
+
* ids. Custom providers (`cursor-sdk`, `cliproxyapi-*`, commandcode, …) live on
|
|
32
|
+
* Pi's composed runtime. Using compat after a successful foreground turn is
|
|
33
|
+
* what crashes Pi with `No API provider registered for api: …` (#30).
|
|
34
|
+
*/
|
|
35
|
+
export function resolveWorkerStreamSimple(
|
|
36
|
+
model: Model<any>,
|
|
37
|
+
modelRegistry?: StreamableModelRegistry | null,
|
|
38
|
+
override?: WorkerStreamSimple,
|
|
39
|
+
): WorkerStreamSimple {
|
|
40
|
+
if (override) return override;
|
|
41
|
+
|
|
42
|
+
const registryStream = modelRegistry?.streamSimple;
|
|
43
|
+
if (typeof registryStream === "function") {
|
|
44
|
+
return (nextModel, context, options) => registryStream(nextModel, context, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
if (
|
|
49
|
+
typeof modelRegistry?.getRegisteredProviderIds === "function"
|
|
50
|
+
&& typeof modelRegistry?.getRegisteredProviderConfig === "function"
|
|
51
|
+
) {
|
|
52
|
+
for (const providerId of modelRegistry.getRegisteredProviderIds()) {
|
|
53
|
+
const config = modelRegistry.getRegisteredProviderConfig(providerId);
|
|
54
|
+
const composed = config?.streamSimple;
|
|
55
|
+
if (config?.api === model.api && typeof composed === "function") {
|
|
56
|
+
return composed;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Incomplete host/test doubles still use the built-in compat dispatcher.
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return compatStreamSimple;
|
|
65
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
82
82
|
"── Activity ──",
|
|
83
83
|
`Next observation: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
|
|
84
84
|
`Next reflection: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
|
|
85
|
-
`Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} tokens (${pct(compactionProgress, compactThreshold)}%)`,
|
|
85
|
+
`Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} estimated source tokens (${pct(compactionProgress, compactThreshold)}%)`,
|
|
86
86
|
`Visible observation pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
|
|
87
87
|
`Active observation pool: ~${activeObservationPool.observationTokens.toLocaleString()} / ${runtime.config.observationsPoolTargetTokens.toLocaleString()} target tokens (${pct(activeObservationPool.observationTokens, runtime.config.observationsPoolTargetTokens)}%)`,
|
|
88
88
|
`Reflection pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
|
package/src/config.ts
CHANGED
|
@@ -33,13 +33,28 @@ export type CompactAfterTokensMode = "calibrated" | "ratio";
|
|
|
33
33
|
export interface Config {
|
|
34
34
|
observeAfterTokens: number;
|
|
35
35
|
reflectAfterTokens: number;
|
|
36
|
+
/**
|
|
37
|
+
* Maximum estimated source tokens serialized into a single observer chunk.
|
|
38
|
+
* Unset (default) derives the cap from the resolved memory model's context
|
|
39
|
+
* window; see {@link resolveObserverChunkMaxTokens}.
|
|
40
|
+
*/
|
|
41
|
+
observerChunkMaxTokens?: number;
|
|
36
42
|
compactAfterTokens: number;
|
|
37
43
|
compactAfterTokensMode: CompactAfterTokensMode;
|
|
38
44
|
compactAfterTokensRatio: number;
|
|
39
45
|
observationsPoolMaxTokens: number;
|
|
40
46
|
observationsPoolTargetTokens: number;
|
|
41
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;
|
|
42
56
|
model?: ConfiguredModel;
|
|
57
|
+
showWorkerNotifications: boolean;
|
|
43
58
|
passive: boolean;
|
|
44
59
|
debugLog: boolean;
|
|
45
60
|
}
|
|
@@ -53,6 +68,8 @@ export const DEFAULTS: Config = {
|
|
|
53
68
|
observationsPoolMaxTokens: 20_000,
|
|
54
69
|
observationsPoolTargetTokens: 10_000,
|
|
55
70
|
agentMaxTurns: 16,
|
|
71
|
+
agentMaxTokens: 32_000,
|
|
72
|
+
showWorkerNotifications: true,
|
|
56
73
|
passive: false,
|
|
57
74
|
debugLog: false,
|
|
58
75
|
};
|
|
@@ -76,7 +93,49 @@ export function resolveCompactAfterTokens(config: Config, contextWindow: number
|
|
|
76
93
|
return config.compactAfterTokens;
|
|
77
94
|
}
|
|
78
95
|
|
|
79
|
-
export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
96
|
+
export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
97
|
+
|
|
98
|
+
/** Observer chunk cap used when no config is set and the model's context window is unknown. */
|
|
99
|
+
export const OBSERVER_CHUNK_FALLBACK_MAX_TOKENS = 60_000;
|
|
100
|
+
|
|
101
|
+
/** Smallest useful observer chunk: enough for labels, omission markers, and source context. */
|
|
102
|
+
export const OBSERVER_CHUNK_MIN_TOKENS = 256;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fraction of the memory model's context window used for the derived observer
|
|
106
|
+
* chunk cap. Chunk sizes are estimated at ~4 chars/token, which can undercount
|
|
107
|
+
* real tokens by up to ~4x on non-ASCII content, so 0.2 keeps even the worst
|
|
108
|
+
* case at ~80% of the window with room left for the system prompt, prior
|
|
109
|
+
* memory, and the response.
|
|
110
|
+
*/
|
|
111
|
+
export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the maximum estimated tokens the observer serializes into one chunk.
|
|
115
|
+
*
|
|
116
|
+
* An explicit `observerChunkMaxTokens` config value always wins. Otherwise the
|
|
117
|
+
* cap is `floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO)` for the resolved
|
|
118
|
+
* memory model, falling back to {@link OBSERVER_CHUNK_FALLBACK_MAX_TOKENS} when
|
|
119
|
+
* the context window is unavailable.
|
|
120
|
+
*
|
|
121
|
+
* Without a cap, a backlog that outgrows the model's context window (e.g.
|
|
122
|
+
* after repeated observer failures, or when the extension is enabled mid-way
|
|
123
|
+
* into a long session) makes every observer call fail, so coverage never
|
|
124
|
+
* advances and the session can never recover. With the cap, oversized backlogs
|
|
125
|
+
* are drained oldest-first across successive runs.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveObserverChunkMaxTokens(config: Config, contextWindow: number | undefined): number {
|
|
128
|
+
if (config.observerChunkMaxTokens !== undefined && config.observerChunkMaxTokens > 0) {
|
|
129
|
+
return Math.max(OBSERVER_CHUNK_MIN_TOKENS, config.observerChunkMaxTokens);
|
|
130
|
+
}
|
|
131
|
+
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
|
|
132
|
+
return Math.max(
|
|
133
|
+
OBSERVER_CHUNK_MIN_TOKENS,
|
|
134
|
+
Math.floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return OBSERVER_CHUNK_FALLBACK_MAX_TOKENS;
|
|
138
|
+
}
|
|
80
139
|
|
|
81
140
|
const SETTINGS_KEY = "observational-memory";
|
|
82
141
|
const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
|
|
@@ -134,10 +193,12 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
|
|
|
134
193
|
const numberKeys = [
|
|
135
194
|
"observeAfterTokens",
|
|
136
195
|
"reflectAfterTokens",
|
|
196
|
+
"observerChunkMaxTokens",
|
|
137
197
|
"compactAfterTokens",
|
|
138
198
|
"observationsPoolMaxTokens",
|
|
139
199
|
"observationsPoolTargetTokens",
|
|
140
200
|
"agentMaxTurns",
|
|
201
|
+
"agentMaxTokens",
|
|
141
202
|
] as const;
|
|
142
203
|
for (const key of numberKeys) {
|
|
143
204
|
const normalizedValue = positiveIntegerOrUndefined(value[key]);
|
|
@@ -148,6 +209,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
|
|
|
148
209
|
}
|
|
149
210
|
const ratio = validRatioOrUndefined(value.compactAfterTokensRatio);
|
|
150
211
|
if (ratio !== undefined) normalized.compactAfterTokensRatio = ratio;
|
|
212
|
+
if (typeof value.showWorkerNotifications === "boolean") normalized.showWorkerNotifications = value.showWorkerNotifications;
|
|
151
213
|
if (typeof value.passive === "boolean") normalized.passive = value.passive;
|
|
152
214
|
if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
|
|
153
215
|
const model = normalizeModel(value.model);
|
|
@@ -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: {
|