pi-optchat 0.8.7 → 0.8.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
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: readonly number[] = [1000, 2000]): Compressor {
59
+ retryDelays?: readonly number[]): Compressor {
49
60
  const gate = primeFirst();
50
61
  return async (input, signal) => {
51
62
  const selected = choice();
@@ -72,19 +83,12 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
72
83
  return await stream.result();
73
84
  } finally { warmed(false); }
74
85
  };
75
- // Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces.
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
- if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && !isRetryableAssistantError(reply))
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();