pi-optchat 0.8.7 → 0.8.9
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/docs/victor-recipe.md +1 -1
- package/package.json +1 -1
- package/src/agents.ts +25 -2
- package/src/cache.ts +9 -5
- package/src/compactor.ts +16 -12
- package/src/handoff.ts +4 -5
- package/src/memory.ts +7 -8
package/docs/victor-recipe.md
CHANGED
|
@@ -12,7 +12,7 @@ The source remains upstream rather than duplicating the full article here. Its p
|
|
|
12
12
|
|
|
13
13
|
- `src/memory.ts`: append-only log, binary summary tree, compression scheduling (§4 of the 2026-10-08 revision: a message's summary starts once fewer than 8 lines before it are unbuilt, a merge once both halves are built, kept in a queue so the tree is never scanned for work), the compactions' own view (§4: the view merged further to 16,000-32,000 bytes, ending at the node; unlike the recipe, a line not built yet shows as a placeholder instead of ending it, so a summary still sees the built lines after it), the view (§3.2: the most due pair, measured from its last message, merges in one batch from 128,000 down to 64,000 bytes, and the view is saved to `view.json` rather than refolded at start), zoom/date, and the opt-in text search over original messages (`src/tools.ts` has the tool; not in the recipe, off by default).
|
|
14
14
|
- `src/compactor.ts`: contextual compression and size retries, with the 2026-10-08 revision's task (a 512-dash ruler for the size) and its "Too long" retry, verbatim. The compactions' view shows each line under its `id+n|` head, as the task's line ids need, so a reply that copies a head has it removed.
|
|
15
|
-
- `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3, plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
|
|
15
|
+
- `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3 (a compaction leaves its end unmarked, as Unii does: its task is never sent again), plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
|
|
16
16
|
- `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text (left out if over 16,000 bytes by default), an intentional addition to the summary-only recipe for conversational continuity.
|
|
17
17
|
- `src/agents.ts`: asynchronous Pi SDK children and automatic completion reports.
|
|
18
18
|
- `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (no memory search), except the previous exchange (on), the summary size tolerance (640 bytes, against the recipe's strict 512) and the turn refresh threshold (30%, where the recipe has none). Grouped subagent reports are off by default, as in the revised recipe, which reports each subagent on its own.
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { THINKING } from './profiles.ts';
|
|
|
23
23
|
export interface LiveRun {
|
|
24
24
|
session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
|
|
25
25
|
tools: Map<string, { name: string; args: unknown; output?: unknown; started: number }>;
|
|
26
|
-
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
|
|
26
|
+
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>; primed?: Promise<unknown>;
|
|
27
27
|
/** Set by the user's interrupt: the queued guidance the run continues with once its current step is aborted; with none, it pauses. */
|
|
28
28
|
interruption?: RunInfo['guidance'];
|
|
29
29
|
/** Messages being steered back into Pi's queue after it was cleared; other queue changes wait for it. */
|
|
@@ -98,6 +98,20 @@ export function subagentCompaction(contextWindow: number, fraction: number) {
|
|
|
98
98
|
if (!(fraction > 0)) return { enabled: true };
|
|
99
99
|
return { enabled: true, reserveTokens: Math.max(16_384, Math.round(contextWindow * (1 - fraction) - VIEW / 4)) };
|
|
100
100
|
}
|
|
101
|
+
/** Waits for `promise`, or until `signal` aborts. */
|
|
102
|
+
async function abortable(promise: Promise<unknown>, signal?: AbortSignal) {
|
|
103
|
+
let stop = () => {};
|
|
104
|
+
const aborted = new Promise<void>(resolve => { stop = resolve; });
|
|
105
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
106
|
+
if (signal?.aborted) stop();
|
|
107
|
+
try { await Promise.race([promise, aborted]); } finally { signal?.removeEventListener('abort', stop); }
|
|
108
|
+
}
|
|
109
|
+
/** Resolves once the session's model starts answering, or its request ends without an answer. */
|
|
110
|
+
const answering = (session: AgentSession) => new Promise<void>(resolve => {
|
|
111
|
+
const off = session.subscribe(event => {
|
|
112
|
+
if (event.type === 'message_update' || event.type === 'agent_end' || event.type === 'message_end' && event.message.role === 'assistant') { off(); resolve(); }
|
|
113
|
+
});
|
|
114
|
+
});
|
|
101
115
|
export class Children {
|
|
102
116
|
private readonly running = new Map<string, LiveRun>();
|
|
103
117
|
readonly history: RunHistory;
|
|
@@ -224,10 +238,14 @@ export class Children {
|
|
|
224
238
|
const launched = await this.track(this.launchBatch({ tasks: runs, cwd, depth, parentId, connected, signal, cancelled }));
|
|
225
239
|
// Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
|
|
226
240
|
const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
|
|
241
|
+
// Siblings on one model and directory send the same system prompt, tools and view. Anthropic can't read a cache entry
|
|
242
|
+
// still being written, so their first requests wait until the first sibling starts answering and read its entry instead.
|
|
243
|
+
const [first] = launched, answered = launched.length > 1 && first.session.model?.api === 'anthropic-messages' ? answering(first.session) : undefined;
|
|
227
244
|
for (const live of launched) {
|
|
228
245
|
live.batch = batch;
|
|
229
246
|
const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
230
247
|
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
248
|
+
if (live === first && answered) for (const sibling of launched.slice(1)) if ((['model', 'thinking', 'cwd'] as const).every(k => sibling.info[k] === first.info[k])) sibling.primed = Promise.race([answered, work]);
|
|
231
249
|
this.completions.add(work);
|
|
232
250
|
live.completion = work;
|
|
233
251
|
}
|
|
@@ -295,7 +313,12 @@ export class Children {
|
|
|
295
313
|
event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
|
|
296
314
|
event.systemPromptOptions.sections.instructions = instructions;
|
|
297
315
|
});
|
|
298
|
-
pi.on('before_provider_request', (event, ctx) =>
|
|
316
|
+
pi.on('before_provider_request', async (event, ctx) => {
|
|
317
|
+
// Set by launch for a sibling's first request; a stop or an interrupt aborts the request, which ends the wait.
|
|
318
|
+
const live = this.running.get(id), primed = live?.primed;
|
|
319
|
+
if (live && primed) { live.primed = undefined; await abortable(primed, ctx.signal); }
|
|
320
|
+
return ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload;
|
|
321
|
+
});
|
|
299
322
|
}],
|
|
300
323
|
});
|
|
301
324
|
await loader.reload();
|
package/src/cache.ts
CHANGED
|
@@ -33,8 +33,9 @@ function lastBlock(message: Record<string, unknown>) {
|
|
|
33
33
|
/** Anthropic: marks on the view (see MARKS) plus a mark at the end of the request, so the next call finds the last one's entry
|
|
34
34
|
* and pays only for the lines after it. The end mark sits on the last block itself rather than as top-level automatic caching:
|
|
35
35
|
* a proxy or setting that gives block marks a 1h TTL makes Anthropic refuse a top-level mark, whose TTL defaults to 5m, on the
|
|
36
|
-
* same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'").
|
|
37
|
-
|
|
36
|
+
* same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'").
|
|
37
|
+
* `end: false` leaves the end unmarked: a compaction's task is never sent again, so marking it would only write it at 1.25× (#197). */
|
|
38
|
+
export function cachePayload(payload: unknown, { end: markEnd = true } = {}): unknown {
|
|
38
39
|
if (!record(payload) || !Array.isArray(payload.messages)) return payload;
|
|
39
40
|
const messages = payload.messages;
|
|
40
41
|
const view = new Set<unknown>();
|
|
@@ -42,9 +43,12 @@ export function cachePayload(payload: unknown): unknown {
|
|
|
42
43
|
if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) continue;
|
|
43
44
|
const at = message.content.findIndex((block: unknown) => record(block) && block.type === 'text' && typeof block.text === 'string' && isView(block.text));
|
|
44
45
|
if (at < 0) continue;
|
|
45
|
-
|
|
46
|
+
// A subagent's task follows the view in the same text. Lines never hold a newline, so the first one before </chat> ends the view;
|
|
47
|
+
// the task goes in its own block, so the view's marks fall on lines its siblings share and they can read one another's entry.
|
|
48
|
+
const text: string = message.content[at].text, close = text.indexOf('\n</chat>'), end = close < 0 ? text.length : close + '\n</chat>'.length;
|
|
49
|
+
const pieces = splitView(text.slice(0, end)), marked = new Set(MARKS.map(back => pieces.length - 2 - back));
|
|
46
50
|
const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(marked.has(j) ? { cache_control: { type: 'ephemeral' } } : {}) }));
|
|
47
|
-
message.content.splice(at, 1, ...blocks);
|
|
51
|
+
message.content.splice(at, 1, ...blocks, ...(end < text.length ? [{ type: 'text', text: text.slice(end) }] : []));
|
|
48
52
|
for (const block of blocks) view.add(block);
|
|
49
53
|
break;
|
|
50
54
|
}
|
|
@@ -62,7 +66,7 @@ export function cachePayload(payload: unknown): unknown {
|
|
|
62
66
|
// Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
|
|
63
67
|
const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
|
|
64
68
|
const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
|
|
65
|
-
const end = turn ?? endBlock(messages);
|
|
69
|
+
const end = markEnd ? turn ?? endBlock(messages) : undefined;
|
|
66
70
|
if (end) end.cache_control = { type: 'ephemeral' };
|
|
67
71
|
return payload;
|
|
68
72
|
}
|
package/src/compactor.ts
CHANGED
|
@@ -42,10 +42,21 @@ function primeFirst() {
|
|
|
42
42
|
};
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
/** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
|
|
46
|
+
export async function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void,
|
|
47
|
+
delays: readonly number[] = [1000, 2000], signal?: AbortSignal) {
|
|
48
|
+
for (let retry = 0; ; retry++) {
|
|
49
|
+
const reply = await ask();
|
|
50
|
+
onReply(reply);
|
|
51
|
+
if (retry >= delays.length || !isRetryableAssistantError(reply)) return reply;
|
|
52
|
+
await sleep(delays[retry], undefined, { signal });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
|
|
46
57
|
export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
|
|
47
58
|
onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes,
|
|
48
|
-
retryDelays
|
|
59
|
+
retryDelays?: readonly number[]): Compressor {
|
|
49
60
|
const gate = primeFirst();
|
|
50
61
|
return async (input, signal) => {
|
|
51
62
|
const selected = choice();
|
|
@@ -65,26 +76,19 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
|
|
|
65
76
|
// A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
|
|
66
77
|
sessionId: 'optchat-compactor', transport: 'sse',
|
|
67
78
|
reasoning: thinking, signal, cacheRetention: 'short',
|
|
68
|
-
onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
|
|
79
|
+
onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload, { end: false }) : payload,
|
|
69
80
|
});
|
|
70
81
|
// The cache entry is usable once the model starts answering.
|
|
71
82
|
for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
|
|
72
83
|
return await stream.result();
|
|
73
84
|
} finally { warmed(false); }
|
|
74
85
|
};
|
|
75
|
-
|
|
76
|
-
const answer = async () => {
|
|
77
|
-
for (let retry = 0; ; retry++) {
|
|
78
|
-
const reply = await ask();
|
|
79
|
-
onUsage(reply);
|
|
80
|
-
if (retry >= retryDelays.length || !isRetryableAssistantError(reply)) return reply;
|
|
81
|
-
await sleep(retryDelays[retry], undefined, { signal });
|
|
82
|
-
}
|
|
83
|
-
};
|
|
86
|
+
const answer = () => retryTransient(ask, onUsage, retryDelays, signal);
|
|
84
87
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
85
88
|
const reply = await answer();
|
|
86
89
|
// A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
|
|
87
|
-
|
|
90
|
+
// A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
|
|
91
|
+
if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !isRetryableAssistantError(reply))
|
|
88
92
|
throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
|
|
89
93
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
90
94
|
// A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
|
package/src/handoff.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
3
3
|
import type { AssistantMessage } from '@earendil-works/pi-ai';
|
|
4
|
-
import { reasoningFor, type ModelChoice } from './compactor.ts';
|
|
4
|
+
import { reasoningFor, retryTransient, type ModelChoice } from './compactor.ts';
|
|
5
5
|
import { HANDOFF } from './prompts.ts';
|
|
6
6
|
import type { RunInfo } from './runs.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
@@ -30,7 +30,7 @@ function formatEvidence({ run, messages, transcriptError }: HandoffEvidence) {
|
|
|
30
30
|
|
|
31
31
|
/** Summarize in one call when possible; otherwise fold chunks with the prior handoff included in the budget. */
|
|
32
32
|
export function createHandoffSummarizer(registry: ModelRegistry, choice: () => ModelChoice,
|
|
33
|
-
usage: (message: AssistantMessage) => void) {
|
|
33
|
+
usage: (message: AssistantMessage) => void, retryDelays?: readonly number[]) {
|
|
34
34
|
return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = []) => {
|
|
35
35
|
const selected = choice(), model = registry.find(selected.provider, selected.model);
|
|
36
36
|
if (!model) throw new Error('Profile compactor model unavailable');
|
|
@@ -50,11 +50,10 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
|
|
|
50
50
|
let end = Math.min(transcript.length, offset + available);
|
|
51
51
|
// Keep multibyte characters intact at chunk boundaries.
|
|
52
52
|
while (end < transcript.length && (transcript[end] & 0xc0) === 0x80) end--;
|
|
53
|
-
const reply = await registry.streamSimple(model, {
|
|
53
|
+
const reply = await retryTransient(() => registry.streamSimple(model, {
|
|
54
54
|
systemPrompt: HANDOFF,
|
|
55
55
|
messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
|
|
56
|
-
}, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result();
|
|
57
|
-
usage(reply);
|
|
56
|
+
}, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result(), usage, retryDelays);
|
|
58
57
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
|
|
59
58
|
if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
|
|
60
59
|
summary = textContent(reply.content).trim();
|
package/src/memory.ts
CHANGED
|
@@ -279,14 +279,13 @@ export class Memory {
|
|
|
279
279
|
}).finally(() => { this.busy.delete(id); this.schedule(); this.events.emit('change'); });
|
|
280
280
|
this.busy.set(id, promise);
|
|
281
281
|
};
|
|
282
|
-
// A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
for (const part of this.merges.values()) {
|
|
282
|
+
// A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built. They start
|
|
283
|
+
// in the order of the context they end at, a merge first at a tie, as Unii's do (#194): messages first left the merges waiting
|
|
284
|
+
// through a backlog, so the views ran over budget and changed at every node built, and compactions read about 60% from the cache.
|
|
285
|
+
const ready: Part[] = [];
|
|
286
|
+
for (const i of this.unbuilt) { if (ready.length === this.jobs) break; ready.push({ l: 0, i }); }
|
|
287
|
+
const at = (part: Part) => part.l ? end(part) : part.i;
|
|
288
|
+
for (const part of [...ready, ...this.merges.values()].sort((a, b) => at(a) - at(b) || b.l - a.l)) {
|
|
290
289
|
if (this.busy.size >= this.jobs) return;
|
|
291
290
|
run(part);
|
|
292
291
|
}
|