feat(agent): merge main into agent-harness-tools

This commit is contained in:
Mario Zechner
2026-07-22 11:56:35 +02:00
93 changed files with 2398 additions and 241 deletions
+18
View File
@@ -2,6 +2,24 @@
## [Unreleased]
## [0.81.1] - 2026-07-21
### New Features
- **Verifiable release source archives** — GitHub releases now include deterministic, checksummed source archives with instructions for rebuilding standalone binaries. See [Building standalone binaries from release source](../../README.md#building-standalone-binaries-from-release-source).
- **Resilient compaction and branch summaries** — Transient provider failures now follow the configured retry policy, with retry lifecycle events available to interactive, JSON, RPC, and SDK consumers. See [Compaction & Branch Summarization](docs/compaction.md) and [RPC retry events](docs/rpc.md#summarization_retry_scheduled--summarization_retry_attempt_start--summarization_retry_finished).
### Added
- Added deterministic, checksummed source archives to GitHub releases with documented standalone binary rebuild instructions ([#6913](https://github.com/earendil-works/pi/pull/6913) by [@christianklotz](https://github.com/christianklotz)).
### Fixed
- Fixed compaction and branch summarization to retry transient provider failures using the configured retry policy, with retry lifecycle events exposed to interactive, JSON, RPC, and SDK consumers ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)).
- Fixed interactive startup waiting for background model catalog refresh while computing the footer provider count.
- Restored the default stream fallback for extensions using the pre-0.81 agent-core API ([#6915](https://github.com/earendil-works/pi/issues/6915)).
- Fixed inherited Kimi K3 models from Moonshot AI and Moonshot AI China to use the OpenAI thinking format and expose reasoning effort support.
## [0.81.0] - 2026-07-21
### New Features
@@ -259,7 +259,7 @@ models: [{
```
Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`.
Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content.
Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user, assistant, or tool-result text content.
For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay.
@@ -760,4 +760,4 @@ interface ProviderModelConfig {
```
`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`.
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content.
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content.
+5 -1
View File
@@ -17,7 +17,11 @@ type AgentSessionEvent =
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
| { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string }
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| { type: "summarization_retry_scheduled"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "summarization_retry_attempt_start"; source: "branchSummary" }
| { type: "summarization_retry_attempt_start"; source: "compaction"; reason: "manual" | "threshold" | "overflow" }
| { type: "summarization_retry_finished" };
```
`queue_update` emits the full pending steering and follow-up queues whenever they change. `compaction_start` and `compaction_end` cover both manual and automatic compaction.
+1 -1
View File
@@ -445,7 +445,7 @@ For providers with partial OpenAI compatibility, use the `compat` field.
| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled |
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. |
| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. |
| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. |
| `supportsStrictMode` | Include the `strict` field in tool definitions |
+33
View File
@@ -851,6 +851,9 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
| `compaction_end` | Compaction completes |
| `auto_retry_start` | Auto-retry begins (after transient error) |
| `auto_retry_end` | Auto-retry completes (success or final failure) |
| `summarization_retry_scheduled` | Retry scheduled for a transient compaction or branch-summary summarization error |
| `summarization_retry_attempt_start` | Retried summarization request starts |
| `summarization_retry_finished` | Summarization retry loop completes |
| `extension_error` | Extension threw an error |
### agent_start
@@ -1077,6 +1080,36 @@ On final failure (max retries exceeded):
}
```
### summarization_retry_scheduled / summarization_retry_attempt_start / summarization_retry_finished
Emitted when compaction or branch-summary summarization retries after a transient provider error. These events use the same retry settings as automatic assistant-turn retries.
```json
{
"type": "summarization_retry_scheduled",
"attempt": 1,
"maxAttempts": 3,
"delayMs": 2000,
"errorMessage": "terminated"
}
```
```json
{
"type": "summarization_retry_attempt_start",
"source": "compaction",
"reason": "threshold"
}
```
For branch summaries, `source` is `"branchSummary"` and no `reason` is present.
```json
{
"type": "summarization_retry_finished"
}
```
### extension_error
Emitted when an extension throws an error.
+3
View File
@@ -319,6 +319,9 @@ session.subscribe((event) => {
case "compaction_end":
case "auto_retry_start":
case "auto_retry_end":
case "summarization_retry_scheduled":
case "summarization_retry_attempt_start":
case "summarization_retry_finished":
break;
}
});
@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.81.0",
"version": "0.81.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.81.0",
"version": "0.81.1",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.81.0",
"version": "0.81.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.81.0",
"version": "0.81.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-gondolin",
"version": "0.81.0",
"version": "0.81.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.81.0",
"version": "0.81.1",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.81.0",
"version": "0.81.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.11.0",
"version": "1.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.11.0",
"version": "1.11.1",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.11.0",
"version": "1.11.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.81.0",
"version": "0.81.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.81.0",
"version": "0.81.1",
"dependencies": {
"ms": "^2.1.3"
},
@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.81.0",
"version": "0.81.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
+15 -15
View File
@@ -1,14 +1,14 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.0",
"version": "0.81.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.0",
"version": "0.81.1",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.81.0"
"@earendil-works/pi-coding-agent": "0.81.1"
},
"engines": {
"node": ">=22.19.0"
@@ -450,11 +450,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
@@ -465,8 +465,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -489,13 +489,13 @@
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz",
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -523,8 +523,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -1,10 +1,10 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.0",
"version": "0.81.1",
"private": true,
"description": "Lockfile root used by the Pi installer and updater.",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.81.0"
"@earendil-works/pi-coding-agent": "0.81.1"
},
"overrides": {
"rimraf": "6.1.2",
+12 -12
View File
@@ -1,17 +1,17 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.81.0",
"version": "0.81.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.81.0",
"version": "0.81.1",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -474,11 +474,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
@@ -489,8 +489,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -513,8 +513,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.81.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.81.0",
"version": "0.81.1",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -39,9 +39,9 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-tui": "^0.81.0",
"@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -41,6 +41,7 @@ import {
isContextOverflow,
isRetryableAssistantError,
modelsAreEqual,
type RetryCallbacks,
resetApiProviders,
streamSimple,
} from "@earendil-works/pi-ai/compat";
@@ -161,7 +162,21 @@ export type AgentSessionEvent =
errorMessage?: string;
}
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| {
type: "summarization_retry_scheduled";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
| { type: "summarization_retry_attempt_start"; source: "branchSummary" }
| {
type: "summarization_retry_attempt_start";
source: "compaction";
reason: "manual" | "threshold" | "overflow";
}
| { type: "summarization_retry_finished" };
/** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -1838,6 +1853,8 @@ export class AgentSession {
this.thinkingLevel,
this.agent.streamFunction,
env,
this.settingsManager.getRetrySettings(),
this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),
);
summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId;
@@ -2114,6 +2131,8 @@ export class AgentSession {
this.thinkingLevel,
this.agent.streamFunction,
env,
this.settingsManager.getRetrySettings(),
this._summarizationRetryCallbacks({ source: "compaction", reason }),
);
summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId;
@@ -2620,6 +2639,37 @@ export class AgentSession {
return isRetryableAssistantError(message);
}
/**
* Retry policy + callbacks shared by compaction and branch-summary summarization calls.
* Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient
* stream drop no longer fails the whole operation. `source` carries the context
* the TUI needs to render the retry and recreate the underlying indicator.
*/
private _summarizationRetryCallbacks(
source: { source: "branchSummary" } | { source: "compaction"; reason: "manual" | "threshold" | "overflow" },
): RetryCallbacks {
return {
onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
this._emit({
type: "summarization_retry_scheduled",
attempt,
maxAttempts,
delayMs,
errorMessage,
});
},
onRetryAttemptStart: () => {
this._emit({
type: "summarization_retry_attempt_start",
...source,
});
},
onRetryFinished: () => {
this._emit({ type: "summarization_retry_finished" });
},
};
}
/**
* Prepare a retryable error for continuation with exponential backoff.
* @returns true if the caller should continue the agent, false otherwise
@@ -2934,6 +2984,8 @@ export class AgentSession {
replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
streamFn: this.agent.streamFunction,
retry: this.settingsManager.getRetrySettings(),
callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
});
if (result.aborted) {
return { cancelled: true, aborted: true };
@@ -6,9 +6,9 @@
*/
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
import type { RetryCallbacks, RetryPolicy } from "@earendil-works/pi-ai";
import { contentText } from "@earendil-works/pi-ai";
import type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import {
convertToLlm,
createBranchSummaryMessage,
@@ -16,7 +16,7 @@ import {
createCustomMessage,
} from "../messages.ts";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
import { estimateTokens } from "./compaction.ts";
import { completeSummarization, estimateTokens } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
@@ -83,6 +83,10 @@ export interface GenerateBranchSummaryOptions {
reserveTokens?: number;
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
streamFn?: StreamFn;
/** Retry policy for transient summarization errors. Reuses coding-agent's `settings.retry`. */
retry?: RetryPolicy;
/** Optional callbacks for retry reporting (e.g. TUI retry indicators). */
callbacks?: RetryCallbacks;
}
// ============================================================================
@@ -300,6 +304,8 @@ export async function generateBranchSummary(
replaceInstructions,
reserveTokens = 16384,
streamFn,
retry,
callbacks,
} = options;
// Token budget = context window minus reserved space for prompt + response
@@ -338,12 +344,11 @@ export async function generateBranchSummary(
// Call LLM for summarization. Prefer the session stream function so SDK
// request behavior (timeouts, retries, attribution headers) stays consistent
// without running through agent state/events.
// without running through agent state/events. Retried via completeSummarization
// so transient stream drops reuse the configured retry policy.
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
const response = streamFn
? await (await streamFn(model, context, requestOptions)).result()
: await completeSimple(model, context, requestOptions);
const response = await completeSummarization(model, context, requestOptions, streamFn, retry, callbacks);
// Check if aborted or errored
if (response.stopReason === "aborted") {
@@ -6,7 +6,7 @@
*/
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { contentText } from "@earendil-works/pi-ai";
import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall } from "@earendil-works/pi-ai";
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import { convertToLlm } from "../messages.ts";
@@ -552,17 +552,24 @@ function createSummarizationOptions(
return options;
}
async function completeSummarization(
/**
* Shared choke point for every compaction/branch-summary summarization call. Wraps the
* single LLM call in {@link retryAssistantCall} so transient stream drops (e.g.
* `terminated`, socket close) honor the configured retry policy instead of failing
* the whole compaction on the first attempt. Deterministic errors and aborts return
* immediately (see {@link retryAssistantCall}).
*/
export async function completeSummarization(
model: Model<any>,
context: Context,
options: SimpleStreamOptions,
streamFn?: StreamFn,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
if (!streamFn) {
return completeSimple(model, context, options);
}
const stream = await streamFn(model, context, options);
return stream.result();
const produce = async (): Promise<AssistantMessage> =>
streamFn ? (await streamFn(model, context, options)).result() : completeSimple(model, context, options);
return retryAssistantCall(produce, retry, options.signal, callbacks);
}
/**
@@ -581,6 +588,8 @@ export async function generateSummary(
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<string> {
return (
await generateSummaryWithUsage(
@@ -595,6 +604,8 @@ export async function generateSummary(
thinkingLevel,
streamFn,
env,
retry,
callbacks,
)
).text;
}
@@ -612,6 +623,8 @@ export async function generateSummaryWithUsage(
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<{ text: string; usage: Usage }> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
@@ -651,6 +664,8 @@ export async function generateSummaryWithUsage(
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
streamFn,
retry,
callbacks,
);
if (response.stopReason === "error") {
@@ -801,6 +816,8 @@ export async function compact(
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
@@ -833,6 +850,8 @@ export async function compact(
thinkingLevel,
streamFn,
env,
retry,
callbacks,
);
historyText = historyResult.text;
historyUsage = historyResult.usage;
@@ -847,6 +866,8 @@ export async function compact(
signal,
thinkingLevel,
streamFn,
retry,
callbacks,
);
// Merge into single summary
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;
@@ -865,6 +886,8 @@ export async function compact(
thinkingLevel,
streamFn,
env,
retry,
callbacks,
);
summary = result.text;
summaryUsage = result.usage;
@@ -900,6 +923,8 @@ async function generateTurnPrefixSummary(
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<{ text: string; usage: Usage }> {
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
@@ -921,6 +946,8 @@ async function generateTurnPrefixSummary(
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
streamFn,
retry,
callbacks,
);
if (response.stopReason === "error") {
+8 -3
View File
@@ -1,6 +1,6 @@
import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model } from "@earendil-works/pi-ai/compat";
import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
@@ -30,6 +30,11 @@ import {
withFileMutationQueue,
} from "./tools/index.ts";
// Preserve the pre-0.81 fallback for extensions that construct Agent instances
// or invoke low-level agent loops without supplying streamFn. Agent core remains
// provider-agnostic and does not import pi-ai/compat itself.
setDefaultStreamFn(streamSimple);
export interface CreateAgentSessionOptions {
/** Working directory for project-local discovery. Default: process.cwd() */
cwd?: string;
@@ -294,7 +299,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
tools: [],
},
convertToLlm: convertToLlmWithBlockImages,
streamFunction: async (model, context, options) => {
streamFn: async (model, context, options) => {
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
+2 -1
View File
@@ -808,7 +808,8 @@ export async function main(args: string[], options?: MainOptions) {
process.exit(1);
}
if (!offlineMode && (appMode === "interactive" || appMode === "rpc")) {
// RPC refreshes catalogs here in the background; interactive mode starts its refresh after TUI initialization.
if (!offlineMode && appMode === "rpc") {
void modelRuntime.refresh().catch(() => {});
}
@@ -826,6 +826,13 @@ export class InteractiveMode {
async run(): Promise<void> {
await this.init();
if (!process.env.PI_OFFLINE) {
void this.session.modelRuntime
.refresh()
.then(() => this.updateAvailableProviderCount())
.catch(() => {});
}
// Start version check asynchronously
checkForNewPiVersion(this.version).then((newRelease) => {
if (newRelease) {
@@ -3110,6 +3117,32 @@ export class InteractiveMode {
this.ui.requestRender();
break;
}
case "summarization_retry_scheduled": {
this.showError(event.errorMessage);
this.showStatusIndicator(
new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs),
);
this.ui.requestRender();
break;
}
case "summarization_retry_attempt_start": {
this.clearStatusIndicator("retry");
if (event.source === "branchSummary") {
this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui));
} else {
this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason));
}
this.ui.requestRender();
break;
}
case "summarization_retry_finished": {
this.clearStatusIndicator("retry");
this.ui.requestRender();
break;
}
}
}
@@ -4326,10 +4359,13 @@ export class InteractiveMode {
}
}
/** Update the footer's available provider count from current model candidates */
private async updateAvailableProviderCount(): Promise<void> {
const models = await this.getModelCandidates();
const uniqueProviders = new Set(models.map((m) => m.provider));
/** Update the footer's available provider count from the current snapshot without refreshing catalogs. */
private updateAvailableProviderCount(): void {
const models =
this.session.scopedModels.length > 0
? this.session.scopedModels.map((scoped) => scoped.model)
: this.session.modelRuntime.getAvailableSnapshot();
const uniqueProviders = new Set(models.map((model) => model.provider));
this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size);
}
@@ -24,7 +24,7 @@ describe("AgentSession auto-compaction queue resume", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
model,
systemPrompt: "Test",
@@ -49,7 +49,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => API_KEY,
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
model,
systemPrompt: "You are a helpful assistant. Be concise.",
@@ -90,7 +90,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test",
tools: [],
},
streamFunction: (_model, _context, options) => {
streamFn: (_model, _context, options) => {
abortSignal = options?.signal;
const stream = new MockAssistantStream();
queueMicrotask(() => {
@@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test",
tools: [],
},
streamFunction: (_model, context, options) => {
streamFn: (_model, context, options) => {
abortSignal = options?.signal;
const stream = new MockAssistantStream();
queueMicrotask(() => {
@@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test",
tools: [],
},
streamFunction: () => {
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") });
@@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test",
tools: [tool],
},
streamFunction: async (_model, context) => {
streamFn: async (_model, context) => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
@@ -508,7 +508,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test",
tools: [tool],
},
streamFunction: async (_model, context) => {
streamFn: async (_model, context) => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
const hasToolResult = context.messages.some((message) => message.role === "toolResult");
@@ -82,7 +82,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] },
streamFunction: () => {
streamFn: () => {
callCount++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
@@ -203,7 +203,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] },
streamFunction: streamFn,
streamFn: streamFn,
});
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
@@ -255,7 +255,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] },
streamFunction: () => {
streamFn: () => {
callCount++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
@@ -75,7 +75,7 @@ async function createSession() {
const session = new AgentSession({
agent: new Agent({
getApiKey: () => "test-key",
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
model,
systemPrompt: "You are a helpful assistant.",
@@ -89,7 +89,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => API_KEY,
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
model,
systemPrompt: "You are a helpful assistant. Be concise.",
@@ -652,7 +652,7 @@ describe("ModelRegistry", () => {
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
});
}, 60_000);
test("removing custom models from models.json keeps built-in provider models", async () => {
writeModelsJson({
@@ -114,7 +114,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs:
systemPrompt: "Test",
tools: [],
},
streamFunction: (_model, _context, _options) => {
streamFn: (_model, _context, _options) => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") });
+1 -1
View File
@@ -137,7 +137,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
const agent = new Agent({
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
model,
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
@@ -61,7 +61,7 @@ describe("regression #5596: missing configured theme export", () => {
tools: [],
},
convertToLlm,
streamFunction: streamSimple,
streamFn: streamSimple,
});
const session = new AgentSession({
agent,
@@ -0,0 +1,192 @@
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, type Harness } from "../harness.ts";
/**
* Regression for #6647: compaction runs a single non-retried summarization call, so a
* transient mid-stream socket death (`terminated`) failed the whole compaction.
* Verifies that summarization now reuses `settings.retry` (bounded retries with
* exponential backoff gated on isRetryableAssistantError), emits
* `summarization_retry_*` events, and that aborts / non-retryable errors are not retried.
*/
describe("#6647 compaction retries transient summarization failures", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
function createUsage(totalTokens: number) {
return {
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function seedCompactableSession(harness: Harness): void {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const now = Date.now();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
const model = harness.getModel();
const assistant: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "stop", timestamp: now - 500 }),
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(100),
};
assistant.content = [{ type: "text", text: "assistant response to compact" }];
harness.sessionManager.appendMessage(assistant);
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
}
/** streamFn that responds with the given sequence of assistant messages across calls. */
function useScriptedStreamFn(harness: Harness, script: AssistantMessage[]): () => number {
let callCount = 0;
const streamFunction: StreamFn = (model) => {
const message = script[callCount] ?? script[script.length - 1]!;
callCount++;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({
type: "error",
reason: message.stopReason,
error: { ...message, api: model.api, provider: model.provider, model: model.id },
});
} else {
stream.push({
type: "done",
reason: message.stopReason,
message: { ...message, api: model.api, provider: model.provider, model: model.id },
});
}
});
return stream;
};
harness.session.agent.streamFunction = streamFunction;
return () => callCount;
}
it("retries a transient `terminated` summarization error and compacts successfully", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
const model = harness.getModel();
const error = (errorMessage: string): AssistantMessage => ({
...fauxAssistantMessage("", { stopReason: "error", errorMessage }),
usage: createUsage(10),
});
const success: AssistantMessage = {
...fauxAssistantMessage("recovered summary"),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error("terminated"), error("terminated"), success]);
const result = await harness.session.compact();
expect(result.summary).toContain("recovered summary");
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
const starts = harness.eventsOfType("summarization_retry_scheduled");
const ends = harness.eventsOfType("summarization_retry_finished");
expect(starts).toHaveLength(2);
expect(ends).toHaveLength(1);
expect(starts[0]).toMatchObject({ attempt: 1, maxAttempts: 3, errorMessage: "terminated" });
expect(starts[1]).toMatchObject({ attempt: 2, maxAttempts: 3 });
expect(ends[0]).toMatchObject({ type: "summarization_retry_finished" });
// model.* referenced to keep imports honest
expect(model.id).toBeTruthy();
});
it("does not retry a non-retryable error (insufficient_quota)", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error]);
await expect(harness.session.compact()).rejects.toThrow("insufficient_quota");
expect(getCallCount()).toBe(1);
expect(harness.eventsOfType("summarization_retry_scheduled")).toHaveLength(0);
});
it("does not retry when retry is disabled", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: false, maxRetries: 3, baseDelayMs: 0 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error]);
await expect(harness.session.compact()).rejects.toThrow("terminated");
expect(getCallCount()).toBe(1);
expect(harness.eventsOfType("summarization_retry_scheduled")).toHaveLength(0);
});
it("stops retrying after maxRetries and reports failure", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 2, baseDelayMs: 0 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error, error, error]);
await expect(harness.session.compact()).rejects.toThrow("terminated");
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
const starts = harness.eventsOfType("summarization_retry_scheduled");
const ends = harness.eventsOfType("summarization_retry_finished");
expect(starts).toHaveLength(2);
expect(ends).toHaveLength(1);
expect(ends[0]).toMatchObject({ type: "summarization_retry_finished" });
});
it("aborts an in-flight retry backoff via abortCompaction", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 5, baseDelayMs: 30_000 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
usage: createUsage(10),
};
useScriptedStreamFn(harness, [error, error, error]);
const compactPromise = harness.session.compact();
// Let the first error resolve and the retry backoff sleep start.
await new Promise((resolve) => setTimeout(resolve, 0));
harness.session.abortCompaction();
// The aborted retry backoff is normalized to an aborted assistant message,
// which compaction classifies as aborted.
await expect(compactPromise).rejects.toThrow();
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
expect(compactionEnd).toMatchObject({ aborted: true });
});
});
@@ -0,0 +1,28 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { createHarness } from "../harness.ts";
const wrappedDnsLookupError =
"The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)";
describe("issue #6904 DNS transport failure retry", () => {
it("retries a transient DNS lookup failure", async () => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
try {
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: wrappedDnsLookupError }),
fauxAssistantMessage("recovered after DNS retry"),
]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(2);
expect(harness.eventsOfType("auto_retry_start").map((event) => event.errorMessage)).toEqual([
wrappedDnsLookupError,
]);
expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]);
} finally {
harness.cleanup();
}
});
});
+1 -1
View File
@@ -378,7 +378,7 @@ async function createHarnessWithResourceLoader(
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
tools: options.tools ?? [],
},
streamFunction: streamFn,
streamFn: streamFn,
});
const sessionManager = SessionManager.inMemory();
+1 -1
View File
@@ -246,7 +246,7 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi
systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.",
tools: createCodingTools(process.cwd()),
},
streamFunction: streamSimple,
streamFn: streamSimple,
});
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);