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
+2
View File
@@ -287,3 +287,5 @@ QuintinShaw pr
R-Taneja pr R-Taneja pr
zaycruz pr zaycruz pr
mteam88 pr
+30 -3
View File
@@ -49,8 +49,27 @@ jobs:
node-version: '22' node-version: '22'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Build binaries - name: Create source archive
run: ./scripts/build-binaries.sh run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
mkdir -p release-assets
./scripts/create-source-archive.sh \
--version "${VERSION}" \
--ref HEAD \
--out "release-assets/pi-${VERSION}-source.tar.gz"
- name: Build binaries from source archive
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
build_root="$(mktemp -d)"
trap 'rm -rf "${build_root}"' EXIT
tar -xzf "release-assets/pi-${VERSION}-source.tar.gz" -C "${build_root}"
"${build_root}/pi-${VERSION}/scripts/build-binaries.sh" \
--out "${GITHUB_WORKSPACE}/packages/coding-agent/binaries"
- name: Prepare GitHub release payload - name: Prepare GitHub release payload
run: | run: |
@@ -83,7 +102,9 @@ jobs:
cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/" cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/"
cd "${GITHUB_WORKSPACE}/release-assets" cd "${GITHUB_WORKSPACE}/release-assets"
source_asset="pi-${VERSION}-source.tar.gz"
release_assets=( release_assets=(
"${source_asset}"
pi-darwin-arm64.tar.gz pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz pi-linux-x64.tar.gz
@@ -125,7 +146,10 @@ jobs:
cd release-assets cd release-assets
VERSION="${RELEASE_TAG#v}"
source_asset="pi-${VERSION}-source.tar.gz"
expected_assets=( expected_assets=(
"${source_asset}"
pi-darwin-arm64.tar.gz pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz pi-linux-x64.tar.gz
@@ -144,7 +168,7 @@ jobs:
sha256sum -c SHA256SUMS sha256sum -c SHA256SUMS
- name: Create draft GitHub Release and upload binaries - name: Create draft GitHub Release and upload assets
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -152,7 +176,10 @@ jobs:
cd release-assets cd release-assets
VERSION="${RELEASE_TAG#v}"
source_asset="pi-${VERSION}-source.tar.gz"
release_assets=( release_assets=(
"${source_asset}"
pi-darwin-arm64.tar.gz pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz pi-linux-x64.tar.gz
+13
View File
@@ -59,6 +59,19 @@ npm run check # Lint, format, and type check
./pi-test.sh # Run pi from sources (can be run from any directory) ./pi-test.sh # Run pi from sources (can be run from any directory)
``` ```
## Building standalone binaries from release source
GitHub releases include a versioned source archive covered by the release's `SHA256SUMS` file. Extract it and run the same build script used for the official standalone binaries:
```bash
VERSION="<release-version>"
tar -xzf "pi-${VERSION}-source.tar.gz"
cd "pi-${VERSION}"
./scripts/build-binaries.sh --platform linux-x64 --out "$PWD/out"
```
The script installs dependencies, builds the monorepo, compiles the Bun executable, and stages its runtime assets. Package maintainers who provide dependencies separately can pass `--skip-install --skip-deps`.
## Supply-chain hardening ## Supply-chain hardening
We treat npm dependency changes as reviewed code changes. We treat npm dependency changes as reviewed code changes.
+18 -18
View File
@@ -5041,10 +5041,10 @@
}, },
"packages/agent": { "packages/agent": {
"name": "@earendil-works/pi-agent-core", "name": "@earendil-works/pi-agent-core",
"version": "0.81.0", "version": "0.81.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
@@ -5394,7 +5394,7 @@
}, },
"packages/ai": { "packages/ai": {
"name": "@earendil-works/pi-ai", "name": "@earendil-works/pi-ai",
"version": "0.81.0", "version": "0.81.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
@@ -5700,12 +5700,12 @@
}, },
"packages/coding-agent": { "packages/coding-agent": {
"name": "@earendil-works/pi-coding-agent", "name": "@earendil-works/pi-coding-agent",
"version": "0.81.0", "version": "0.81.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.0", "@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -5746,32 +5746,32 @@
}, },
"packages/coding-agent/examples/extensions/custom-provider-anthropic": { "packages/coding-agent/examples/extensions/custom-provider-anthropic": {
"name": "pi-extension-custom-provider-anthropic", "name": "pi-extension-custom-provider-anthropic",
"version": "0.81.0", "version": "0.81.1",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.52.0" "@anthropic-ai/sdk": "0.52.0"
} }
}, },
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
"name": "pi-extension-custom-provider-gitlab-duo", "name": "pi-extension-custom-provider-gitlab-duo",
"version": "0.81.0" "version": "0.81.1"
}, },
"packages/coding-agent/examples/extensions/gondolin": { "packages/coding-agent/examples/extensions/gondolin": {
"name": "pi-extension-gondolin", "name": "pi-extension-gondolin",
"version": "0.81.0", "version": "0.81.1",
"dependencies": { "dependencies": {
"@earendil-works/gondolin": "0.12.0" "@earendil-works/gondolin": "0.12.0"
} }
}, },
"packages/coding-agent/examples/extensions/sandbox": { "packages/coding-agent/examples/extensions/sandbox": {
"name": "pi-extension-sandbox", "name": "pi-extension-sandbox",
"version": "1.11.0", "version": "1.11.1",
"dependencies": { "dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.26" "@anthropic-ai/sandbox-runtime": "0.0.26"
} }
}, },
"packages/coding-agent/examples/extensions/with-deps": { "packages/coding-agent/examples/extensions/with-deps": {
"name": "pi-extension-with-deps", "name": "pi-extension-with-deps",
"version": "0.81.0", "version": "0.81.1",
"dependencies": { "dependencies": {
"ms": "2.1.3" "ms": "2.1.3"
}, },
@@ -6067,10 +6067,10 @@
}, },
"packages/server": { "packages/server": {
"name": "@earendil-works/pi-server", "name": "@earendil-works/pi-server",
"version": "0.81.0", "version": "0.81.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.81.0" "@earendil-works/pi-coding-agent": "^0.81.1"
}, },
"bin": { "bin": {
"server": "dist/cli.js" "server": "dist/cli.js"
@@ -6084,11 +6084,11 @@
}, },
"packages/storage/sqlite-node": { "packages/storage/sqlite-node": {
"name": "@earendil-works/pi-storage-sqlite-node", "name": "@earendil-works/pi-storage-sqlite-node",
"version": "0.81.0", "version": "0.81.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.0" "@earendil-works/pi-ai": "^0.81.1"
}, },
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"
@@ -6096,7 +6096,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@earendil-works/pi-tui", "name": "@earendil-works/pi-tui",
"version": "0.81.0", "version": "0.81.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-east-asian-width": "1.6.0", "get-east-asian-width": "1.6.0",
+10
View File
@@ -10,6 +10,16 @@
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`. - Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`.
## [0.81.1] - 2026-07-21
### Added
- Added retry policy support and lifecycle events for compaction and branch-summary operations in `AgentHarness` ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)).
### Fixed
- Restored the `Agent` `streamFn` option and host-configurable fallback for omitted agent-loop stream functions without reintroducing a `pi-ai/compat` dependency ([#6915](https://github.com/earendil-works/pi/issues/6915)).
## [0.81.0] - 2026-07-21 ## [0.81.0] - 2026-07-21
### Breaking Changes ### Breaking Changes
+7 -7
View File
@@ -29,7 +29,7 @@ const agent = new Agent({
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model, model,
}, },
streamFunction: models.streamSimple.bind(models), streamFn: models.streamSimple.bind(models),
}); });
agent.subscribe((event) => { agent.subscribe((event) => {
@@ -199,7 +199,7 @@ const agent = new Agent({
followUpMode: "one-at-a-time", followUpMode: "one-at-a-time",
// Required stream function // Required stream function
streamFunction: models.streamSimple.bind(models), streamFn: models.streamSimple.bind(models),
// Session ID for provider caching // Session ID for provider caching
sessionId: "session-123", sessionId: "session-123",
@@ -386,7 +386,7 @@ Handle custom types in `convertToLlm`:
```typescript ```typescript
const agent = new Agent({ const agent = new Agent({
streamFunction: models.streamSimple.bind(models), streamFn: models.streamSimple.bind(models),
convertToLlm: (messages) => messages.flatMap(m => { convertToLlm: (messages) => messages.flatMap(m => {
if (m.role === "notification") return []; // Filter out if (m.role === "notification") return []; // Filter out
return [m]; return [m];
@@ -457,7 +457,7 @@ For browser apps that proxy through a backend:
import { Agent, streamProxy } from "@earendil-works/pi-agent-core"; import { Agent, streamProxy } from "@earendil-works/pi-agent-core";
const agent = new Agent({ const agent = new Agent({
streamFunction: (model, context, options) => streamFn: (model, context, options) =>
streamProxy(model, context, { streamProxy(model, context, {
...options, ...options,
authToken: "...", authToken: "...",
@@ -489,13 +489,13 @@ const config: AgentLoopConfig = {
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() }; const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
const streamFunction = models.streamSimple.bind(models); const streamFn = models.streamSimple.bind(models);
for await (const event of agentLoop([userMessage], context, config, undefined, streamFunction)) { for await (const event of agentLoop([userMessage], context, config, undefined, streamFn)) {
console.log(event.type); console.log(event.type);
} }
// Continue from existing context // Continue from existing context
for await (const event of agentLoopContinue(context, config, undefined, streamFunction)) { for await (const event of agentLoopContinue(context, config, undefined, streamFn)) {
console.log(event.type); console.log(event.type);
} }
``` ```
+10
View File
@@ -183,6 +183,16 @@ Summary:
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing. Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
### Summarization retry events
When the harness is configured with a retry policy, generated compaction and branch-summary requests emit retry lifecycle events for transient provider errors:
- `retry_scheduled`: a retry was scheduled. Includes `operation: "compaction" | "branch_summary"`, `attempt`, `maxAttempts`, `delayMs`, and `errorMessage`.
- `retry_attempt_start`: the backoff delay completed and the retried summarization request is starting. Includes `operation`.
- `retry_finished`: the retry loop finished after success, exhaustion, or abort. Includes `operation`.
These events are observational and do not accept hook results.
## Planned session facade ## Planned session facade
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes. Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-agent-core", "name": "@earendil-works/pi-agent-core",
"version": "0.81.0", "version": "0.81.1",
"description": "General-purpose agent with transport abstraction, state management, and attachment support", "description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -29,7 +29,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
+9 -8
View File
@@ -10,6 +10,7 @@ import {
type ToolResultMessage, type ToolResultMessage,
validateToolArguments, validateToolArguments,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type { import type {
AgentContext, AgentContext,
AgentEvent, AgentEvent,
@@ -32,7 +33,7 @@ export function agentLoop(
context: AgentContext, context: AgentContext,
config: AgentLoopConfig, config: AgentLoopConfig,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
streamFunction: StreamFn, streamFn: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> { ): EventStream<AgentEvent, AgentMessage[]> {
const stream = createAgentStream(); const stream = createAgentStream();
@@ -44,7 +45,7 @@ export function agentLoop(
stream.push(event); stream.push(event);
}, },
signal, signal,
streamFunction, streamFn,
).then((messages) => { ).then((messages) => {
stream.end(messages); stream.end(messages);
}); });
@@ -64,7 +65,7 @@ export function agentLoopContinue(
context: AgentContext, context: AgentContext,
config: AgentLoopConfig, config: AgentLoopConfig,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
streamFunction: StreamFn, streamFn: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> { ): EventStream<AgentEvent, AgentMessage[]> {
if (context.messages.length === 0) { if (context.messages.length === 0) {
throw new Error("Cannot continue: no messages in context"); throw new Error("Cannot continue: no messages in context");
@@ -83,7 +84,7 @@ export function agentLoopContinue(
stream.push(event); stream.push(event);
}, },
signal, signal,
streamFunction, streamFn,
).then((messages) => { ).then((messages) => {
stream.end(messages); stream.end(messages);
}); });
@@ -97,7 +98,7 @@ export async function runAgentLoop(
config: AgentLoopConfig, config: AgentLoopConfig,
emit: AgentEventSink, emit: AgentEventSink,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
streamFunction: StreamFn, streamFn: StreamFn,
): Promise<AgentMessage[]> { ): Promise<AgentMessage[]> {
const newMessages: AgentMessage[] = [...prompts]; const newMessages: AgentMessage[] = [...prompts];
const currentContext: AgentContext = { const currentContext: AgentContext = {
@@ -112,7 +113,7 @@ export async function runAgentLoop(
await emit({ type: "message_end", message: prompt }); await emit({ type: "message_end", message: prompt });
} }
await runLoop(currentContext, newMessages, config, signal, emit, streamFunction); await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
return newMessages; return newMessages;
} }
@@ -121,7 +122,7 @@ export async function runAgentLoopContinue(
config: AgentLoopConfig, config: AgentLoopConfig,
emit: AgentEventSink, emit: AgentEventSink,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
streamFunction: StreamFn, streamFn: StreamFn,
): Promise<AgentMessage[]> { ): Promise<AgentMessage[]> {
if (context.messages.length === 0) { if (context.messages.length === 0) {
throw new Error("Cannot continue: no messages in context"); throw new Error("Cannot continue: no messages in context");
@@ -137,7 +138,7 @@ export async function runAgentLoopContinue(
await emit({ type: "agent_start" }); await emit({ type: "agent_start" });
await emit({ type: "turn_start" }); await emit({ type: "turn_start" });
await runLoop(currentContext, newMessages, config, signal, emit, streamFunction); await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
return newMessages; return newMessages;
} }
+22 -19
View File
@@ -8,6 +8,7 @@ import type {
Transport, Transport,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type { import type {
AfterToolCallContext, AfterToolCallContext,
AfterToolCallResult, AfterToolCallResult,
@@ -97,7 +98,7 @@ export interface AgentOptions {
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>; initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>; convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>; transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
streamFunction: StreamFn; streamFn: StreamFn;
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined; getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
onPayload?: SimpleStreamOptions["onPayload"]; onPayload?: SimpleStreamOptions["onPayload"];
onResponse?: SimpleStreamOptions["onResponse"]; onResponse?: SimpleStreamOptions["onResponse"];
@@ -207,24 +208,26 @@ export class Agent {
public toolExecution: ToolExecutionMode; public toolExecution: ToolExecutionMode;
constructor(options: AgentOptions) { constructor(options: AgentOptions) {
this._state = createMutableAgentState(options.initialState); // Older compiled consumers may omit options or streamFn even though the current API requires them.
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; const runtimeOptions: Partial<AgentOptions> = options ?? {};
this.transformContext = options.transformContext; this._state = createMutableAgentState(runtimeOptions.initialState);
this.streamFunction = options.streamFunction; this.convertToLlm = runtimeOptions.convertToLlm ?? defaultConvertToLlm;
this.getApiKey = options.getApiKey; this.transformContext = runtimeOptions.transformContext;
this.onPayload = options.onPayload; this.streamFunction = runtimeOptions.streamFn ?? getDefaultStreamFn();
this.onResponse = options.onResponse; this.getApiKey = runtimeOptions.getApiKey;
this.beforeToolCall = options.beforeToolCall; this.onPayload = runtimeOptions.onPayload;
this.afterToolCall = options.afterToolCall; this.onResponse = runtimeOptions.onResponse;
this.prepareNextTurn = options.prepareNextTurn; this.beforeToolCall = runtimeOptions.beforeToolCall;
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext; this.afterToolCall = runtimeOptions.afterToolCall;
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); this.prepareNextTurn = runtimeOptions.prepareNextTurn;
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); this.prepareNextTurnWithContext = runtimeOptions.prepareNextTurnWithContext;
this.sessionId = options.sessionId; this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time");
this.thinkingBudgets = options.thinkingBudgets; this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time");
this.transport = options.transport ?? "auto"; this.sessionId = runtimeOptions.sessionId;
this.maxRetryDelayMs = options.maxRetryDelayMs; this.thinkingBudgets = runtimeOptions.thinkingBudgets;
this.toolExecution = options.toolExecution ?? "parallel"; this.transport = runtimeOptions.transport ?? "auto";
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
} }
/** /**
+25 -1
View File
@@ -4,6 +4,8 @@ import {
type ImageContent, type ImageContent,
type Model, type Model,
type Models, type Models,
type RetryCallbacks,
type RetryPolicy,
type UserMessage, type UserMessage,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { runAgentLoop } from "../agent-loop.ts"; import { runAgentLoop } from "../agent-loop.ts";
@@ -182,6 +184,7 @@ export class AgentHarness<
private systemPrompt: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>["systemPrompt"]; private systemPrompt: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>["systemPrompt"];
private toolContext: AgentHarnessToolContextSource<TContext> | undefined; private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
private streamOptions: AgentHarnessStreamOptions; private streamOptions: AgentHarnessStreamOptions;
private retry: RetryPolicy | undefined;
private resources: AgentHarnessResources<TSkill, TPromptTemplate>; private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
private tools = new Map<string, TTool>(); private tools = new Map<string, TTool>();
private activeToolNames: string[]; private activeToolNames: string[];
@@ -197,6 +200,7 @@ export class AgentHarness<
this.models = options.models; this.models = options.models;
this.resources = options.resources ?? {}; this.resources = options.resources ?? {};
this.streamOptions = cloneStreamOptions(options.streamOptions); this.streamOptions = cloneStreamOptions(options.streamOptions);
this.retry = options.retry;
this.systemPrompt = options.systemPrompt; this.systemPrompt = options.systemPrompt;
this.toolContext = options.toolContext; this.toolContext = options.toolContext;
this.validateUniqueNames( this.validateUniqueNames(
@@ -260,6 +264,15 @@ export class AgentHarness<
return lastResult; return lastResult;
} }
private retryCallbacks(operation: "compaction" | "branch_summary"): RetryCallbacks {
return {
onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) =>
this.emitOwn({ type: "retry_scheduled", operation, attempt, maxAttempts, delayMs, errorMessage }),
onRetryAttemptStart: () => this.emitOwn({ type: "retry_attempt_start", operation }),
onRetryFinished: () => this.emitOwn({ type: "retry_finished", operation }),
};
}
private async emitBeforeProviderRequest( private async emitBeforeProviderRequest(
model: Model<any>, model: Model<any>,
sessionId: string, sessionId: string,
@@ -741,7 +754,16 @@ export class AgentHarness<
const provided = hookResult?.compaction; const provided = hookResult?.compaction;
const compactResult = provided const compactResult = provided
? { ok: true as const, value: provided } ? { ok: true as const, value: provided }
: await compact(preparation, this.models, model, customInstructions, undefined, this.thinkingLevel); : await compact(
preparation,
this.models,
model,
customInstructions,
undefined,
this.thinkingLevel,
this.retry,
this.retryCallbacks("compaction"),
);
if (!compactResult.ok) throw compactResult.error; if (!compactResult.ok) throw compactResult.error;
const result = compactResult.value; const result = compactResult.value;
const entryId = await this.session.appendCompaction( const entryId = await this.session.appendCompaction(
@@ -803,6 +825,8 @@ export class AgentHarness<
signal: new AbortController().signal, signal: new AbortController().signal,
customInstructions: hookResult?.customInstructions ?? options?.customInstructions, customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
retry: this.retry,
callbacks: this.retryCallbacks("branch_summary"),
}); });
if (!branchSummary.ok) { if (!branchSummary.ok) {
if (branchSummary.error.code === "aborted") return { cancelled: true }; if (branchSummary.error.code === "aborted") return { cancelled: true };
@@ -1,4 +1,4 @@
import { contentText, type Model, type Models } from "@earendil-works/pi-ai"; import { contentText, type Model, type Models, type RetryCallbacks, type RetryPolicy } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.ts"; import type { AgentMessage } from "../../types.ts";
import { import {
@@ -9,7 +9,7 @@ import {
} from "../messages.ts"; } from "../messages.ts";
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts"; import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts"; import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts"; import { completeSimpleWithRetries, estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
import { import {
computeFileLists, computeFileLists,
createFileOps, createFileOps,
@@ -61,6 +61,10 @@ export interface GenerateBranchSummaryOptions {
replaceInstructions?: boolean; replaceInstructions?: boolean;
/** Tokens reserved for prompt and model output. Defaults to 16384. */ /** Tokens reserved for prompt and model output. Defaults to 16384. */
reserveTokens?: number; reserveTokens?: number;
/** Optional retry policy for transient summarization errors. */
retry?: RetryPolicy;
/** Optional callbacks for retry reporting. */
callbacks?: RetryCallbacks;
} }
/** Collect entries that should be summarized before navigating to a different session tree entry. */ /** Collect entries that should be summarized before navigating to a different session tree entry. */
@@ -200,7 +204,16 @@ export async function generateBranchSummary(
entries: SessionTreeEntry[], entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions, options: GenerateBranchSummaryOptions,
): Promise<Result<BranchSummaryResult, BranchSummaryError>> { ): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { models, model, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options; const {
models,
model,
signal,
customInstructions,
replaceInstructions,
reserveTokens = 16384,
retry,
callbacks,
} = options;
const contextWindow = model.contextWindow || 128000; const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens; const tokenBudget = contextWindow - reserveTokens;
@@ -228,10 +241,13 @@ export async function generateBranchSummary(
timestamp: Date.now(), timestamp: Date.now(),
}, },
]; ];
const response = await models.completeSimple( const response = await completeSimpleWithRetries(
models,
model, model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ signal, maxTokens: 2048 }, { signal, maxTokens: 2048 },
retry,
callbacks,
); );
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted")); return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
@@ -1,9 +1,14 @@
import { import {
type AssistantMessage, type AssistantMessage,
type Context,
contentText, contentText,
type ImageContent, type ImageContent,
type Model, type Model,
type Models, type Models,
type RetryCallbacks,
type RetryPolicy,
retryAssistantCall,
type SimpleStreamOptions,
type TextContent, type TextContent,
type Usage, type Usage,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
@@ -109,6 +114,17 @@ export interface CompactionResult<T = unknown> {
details?: T; details?: T;
} }
export async function completeSimpleWithRetries(
models: Models,
model: Model<any>,
context: Context,
options: SimpleStreamOptions,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
return retryAssistantCall(() => models.completeSimple(model, context, options), retry, options.signal, callbacks);
}
function combineUsage(first: Usage, second: Usage): Usage { function combineUsage(first: Usage, second: Usage): Usage {
return { return {
input: first.input + second.input, input: first.input + second.input,
@@ -501,6 +517,8 @@ export async function generateSummary(
customInstructions?: string, customInstructions?: string,
previousSummary?: string, previousSummary?: string,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<string, CompactionError>> { ): Promise<Result<string, CompactionError>> {
const result = await generateSummaryWithUsage( const result = await generateSummaryWithUsage(
currentMessages, currentMessages,
@@ -511,6 +529,8 @@ export async function generateSummary(
customInstructions, customInstructions,
previousSummary, previousSummary,
thinkingLevel, thinkingLevel,
retry,
callbacks,
); );
return result.ok ? ok(result.value.text) : err(result.error); return result.ok ? ok(result.value.text) : err(result.error);
} }
@@ -525,6 +545,8 @@ export async function generateSummaryWithUsage(
customInstructions?: string, customInstructions?: string,
previousSummary?: string, previousSummary?: string,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> { ): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
const maxTokens = Math.min( const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens), Math.floor(0.8 * reserveTokens),
@@ -555,10 +577,13 @@ export async function generateSummaryWithUsage(
? { maxTokens, signal, reasoning: thinkingLevel } ? { maxTokens, signal, reasoning: thinkingLevel }
: { maxTokens, signal }; : { maxTokens, signal };
const response = await models.completeSimple( const response = await completeSimpleWithRetries(
models,
model, model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions, completionOptions,
retry,
callbacks,
); );
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted")); return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
@@ -700,6 +725,8 @@ export async function compact(
customInstructions?: string, customInstructions?: string,
signal?: AbortSignal, signal?: AbortSignal,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<CompactionResult, CompactionError>> { ): Promise<Result<CompactionResult, CompactionError>> {
const { const {
firstKeptEntryId, firstKeptEntryId,
@@ -733,6 +760,8 @@ export async function compact(
customInstructions, customInstructions,
previousSummary, previousSummary,
thinkingLevel, thinkingLevel,
retry,
callbacks,
); );
if (!historyResult.ok) return err(historyResult.error); if (!historyResult.ok) return err(historyResult.error);
historyText = historyResult.value.text; historyText = historyResult.value.text;
@@ -745,6 +774,8 @@ export async function compact(
settings.reserveTokens, settings.reserveTokens,
signal, signal,
thinkingLevel, thinkingLevel,
retry,
callbacks,
); );
if (!turnPrefixResult.ok) return err(turnPrefixResult.error); if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`; summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
@@ -761,6 +792,8 @@ export async function compact(
customInstructions, customInstructions,
previousSummary, previousSummary,
thinkingLevel, thinkingLevel,
retry,
callbacks,
); );
if (!summaryResult.ok) return err(summaryResult.error); if (!summaryResult.ok) return err(summaryResult.error);
summary = summaryResult.value.text; summary = summaryResult.value.text;
@@ -786,6 +819,8 @@ async function generateTurnPrefixSummary(
reserveTokens: number, reserveTokens: number,
signal?: AbortSignal, signal?: AbortSignal,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> { ): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
const maxTokens = Math.min( const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens), Math.floor(0.5 * reserveTokens),
@@ -802,12 +837,17 @@ async function generateTurnPrefixSummary(
}, },
]; ];
const response = await models.completeSimple( const completionOptions =
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
model.reasoning && thinkingLevel && thinkingLevel !== "off" model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, reasoning: thinkingLevel } ? { maxTokens, signal, reasoning: thinkingLevel }
: { maxTokens, signal }, : { maxTokens, signal };
const response = await completeSimpleWithRetries(
models,
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
retry,
callbacks,
); );
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted")); return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
+28
View File
@@ -2,6 +2,7 @@ import type {
ImageContent, ImageContent,
Model, Model,
Models, Models,
RetryPolicy,
SimpleStreamOptions, SimpleStreamOptions,
TextContent, TextContent,
Transport, Transport,
@@ -659,6 +660,25 @@ export interface SessionTreeEvent {
fromHook?: boolean; fromHook?: boolean;
} }
export interface RetryScheduledEvent {
type: "retry_scheduled";
operation: "compaction" | "branch_summary";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
export interface RetryAttemptStartEvent {
type: "retry_attempt_start";
operation: "compaction" | "branch_summary";
}
export interface RetryFinishedEvent {
type: "retry_finished";
operation: "compaction" | "branch_summary";
}
export interface ModelUpdateEvent { export interface ModelUpdateEvent {
type: "model_update"; type: "model_update";
model: Model<any>; model: Model<any>;
@@ -709,6 +729,9 @@ export type AgentHarnessOwnEvent<
| SessionCompactEvent | SessionCompactEvent
| SessionBeforeTreeEvent | SessionBeforeTreeEvent
| SessionTreeEvent | SessionTreeEvent
| RetryScheduledEvent
| RetryAttemptStartEvent
| RetryFinishedEvent
| ModelUpdateEvent | ModelUpdateEvent
| ThinkingLevelUpdateEvent | ThinkingLevelUpdateEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate> | ResourcesUpdateEvent<TSkill, TPromptTemplate>
@@ -778,6 +801,9 @@ export type AgentHarnessEventResultMap = {
session_compact: undefined; session_compact: undefined;
session_before_tree: SessionBeforeTreeResult | undefined; session_before_tree: SessionBeforeTreeResult | undefined;
session_tree: undefined; session_tree: undefined;
retry_scheduled: undefined;
retry_attempt_start: undefined;
retry_finished: undefined;
model_update: undefined; model_update: undefined;
thinking_level_update: undefined; thinking_level_update: undefined;
resources_update: undefined; resources_update: undefined;
@@ -897,6 +923,8 @@ export interface AgentHarnessOptions<
}) => string | Promise<string>); }) => string | Promise<string>);
/** Curated stream/provider request options. Snapshotted at turn start. */ /** Curated stream/provider request options. Snapshotted at turn start. */
streamOptions?: AgentHarnessStreamOptions; streamOptions?: AgentHarnessStreamOptions;
/** Optional retry policy for generated compaction and branch-summary requests. */
retry?: RetryPolicy;
model: Model<any>; model: Model<any>;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
activeToolNames?: string[]; activeToolNames?: string[];
+2
View File
@@ -44,5 +44,7 @@ export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts"; export * from "./harness/utils/truncate.ts";
// Proxy utilities // Proxy utilities
export * from "./proxy.ts"; export * from "./proxy.ts";
// Stream defaults
export { setDefaultStreamFn } from "./stream-fn.ts";
// Types // Types
export * from "./types.ts"; export * from "./types.ts";
+2 -2
View File
@@ -84,12 +84,12 @@ export interface ProxyStreamOptions extends ProxySerializableStreamOptions {
* The server strips the partial field from delta events to reduce bandwidth. * The server strips the partial field from delta events to reduce bandwidth.
* We reconstruct the partial message client-side. * We reconstruct the partial message client-side.
* *
* Use this as the `streamFunction` option when creating an Agent that needs to go through a proxy. * Use this as the `streamFn` option when creating an Agent that needs to go through a proxy.
* *
* @example * @example
* ```typescript * ```typescript
* const agent = new Agent({ * const agent = new Agent({
* streamFunction: (model, context, options) => * streamFn: (model, context, options) =>
* streamProxy(model, context, { * streamProxy(model, context, {
* ...options, * ...options,
* authToken: await getAuthToken(), * authToken: await getAuthToken(),
+20
View File
@@ -0,0 +1,20 @@
import type { StreamFn } from "./types.ts";
let defaultStreamFn: StreamFn | undefined;
/**
* Configure the fallback used by Agent and low-level loops when callers omit streamFn.
*
* Hosts that provide a default model runtime can install its stream function here
* without making pi-agent-core depend on a provider catalog or compatibility layer.
*/
export function setDefaultStreamFn(streamFn: StreamFn | undefined): void {
defaultStreamFn = streamFn;
}
export function getDefaultStreamFn(): StreamFn {
if (!defaultStreamFn) {
throw new Error("No default stream function configured. Pass streamFn explicitly or call setDefaultStreamFn().");
}
return defaultStreamFn;
}
+35
View File
@@ -9,6 +9,7 @@ import {
import { Type } from "typebox"; import { Type } from "typebox";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts"; import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
import { setDefaultStreamFn } from "../src/index.ts";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts"; import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
// Mock stream for testing - mimics MockAssistantStream // Mock stream for testing - mimics MockAssistantStream
@@ -80,6 +81,40 @@ function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
} }
describe("default stream function compatibility", () => {
it("uses the configured default when a legacy caller omits streamFn", async () => {
let calls = 0;
setDefaultStreamFn(() => {
calls++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "fallback" }]),
});
});
return stream;
});
try {
const context: AgentContext = { systemPrompt: "", messages: [], tools: [] };
const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };
const stream = Reflect.apply(agentLoop, undefined, [
[createUserMessage("Hello")],
context,
config,
undefined,
]) as ReturnType<typeof agentLoop>;
await stream.result();
expect(calls).toBe(1);
} finally {
setDefaultStreamFn(undefined);
}
});
});
describe("agentLoop with AgentMessage", () => { describe("agentLoop with AgentMessage", () => {
it("should emit events with AgentMessage types", async () => { it("should emit events with AgentMessage types", async () => {
const context: AgentContext = { const context: AgentContext = {
+48 -20
View File
@@ -1,7 +1,14 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat"; import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
import { Type } from "typebox"; import { Type } from "typebox";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback, type StreamFn } from "../src/index.ts"; import {
Agent,
type AgentEvent,
type AgentTool,
type AgentToolUpdateCallback,
type StreamFn,
setDefaultStreamFn,
} from "../src/index.ts";
// Mock stream that mimics AssistantMessageEventStream // Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> { class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -75,8 +82,29 @@ function createDeferred(): {
} }
describe("Agent", () => { describe("Agent", () => {
it("uses the configured default when a legacy caller omits streamFn", async () => {
let calls = 0;
setDefaultStreamFn(() => {
calls++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
const message = createAssistantMessage("fallback");
stream.push({ type: "done", reason: "stop", message });
});
return stream;
});
try {
const agent = Reflect.construct(Agent, [{}]) as Agent;
await agent.prompt("Hello");
expect(calls).toBe(1);
} finally {
setDefaultStreamFn(undefined);
}
});
it("should create an agent instance with default state", () => { it("should create an agent instance with default state", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction }); const agent = new Agent({ streamFn: unusedStreamFunction });
expect(agent.state).toBeDefined(); expect(agent.state).toBeDefined();
expect(agent.state.systemPrompt).toBe(""); expect(agent.state.systemPrompt).toBe("");
@@ -93,7 +121,7 @@ describe("Agent", () => {
it("should create an agent instance with custom initial state", () => { it("should create an agent instance with custom initial state", () => {
const customModel = getModel("openai", "gpt-4o-mini"); const customModel = getModel("openai", "gpt-4o-mini");
const agent = new Agent({ const agent = new Agent({
streamFunction: unusedStreamFunction, streamFn: unusedStreamFunction,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model: customModel, model: customModel,
@@ -107,7 +135,7 @@ describe("Agent", () => {
}); });
it("should subscribe to events", () => { it("should subscribe to events", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction }); const agent = new Agent({ streamFn: unusedStreamFunction });
let eventCount = 0; let eventCount = 0;
const unsubscribe = agent.subscribe((_event) => { const unsubscribe = agent.subscribe((_event) => {
@@ -130,7 +158,7 @@ describe("Agent", () => {
it("emits full lifecycle events for thrown run failures", async () => { it("emits full lifecycle events for thrown run failures", async () => {
const agent = new Agent({ const agent = new Agent({
streamFunction: () => { streamFn: () => {
throw new Error("provider exploded"); throw new Error("provider exploded");
}, },
}); });
@@ -162,7 +190,7 @@ describe("Agent", () => {
it("should await async subscribers before prompt resolves", async () => { it("should await async subscribers before prompt resolves", async () => {
const barrier = createDeferred(); const barrier = createDeferred();
const agent = new Agent({ const agent = new Agent({
streamFunction: () => { streamFn: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
@@ -200,7 +228,7 @@ describe("Agent", () => {
it("waitForIdle should wait for async subscribers", async () => { it("waitForIdle should wait for async subscribers", async () => {
const barrier = createDeferred(); const barrier = createDeferred();
const agent = new Agent({ const agent = new Agent({
streamFunction: () => { streamFn: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
@@ -235,7 +263,7 @@ describe("Agent", () => {
it("should pass the active abort signal to subscribers", async () => { it("should pass the active abort signal to subscribers", async () => {
let receivedSignal: AbortSignal | undefined; let receivedSignal: AbortSignal | undefined;
const agent = new Agent({ const agent = new Agent({
streamFunction: (_model, _context, options) => { streamFn: (_model, _context, options) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") }); stream.push({ type: "start", partial: createAssistantMessage("") });
@@ -298,7 +326,7 @@ describe("Agent", () => {
}; };
const agent = new Agent({ const agent = new Agent({
initialState: { tools: [tool] }, initialState: { tools: [tool] },
streamFunction: () => { streamFn: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ stream.push({
@@ -373,7 +401,7 @@ describe("Agent", () => {
}; };
const agent = new Agent({ const agent = new Agent({
initialState: { tools: [settledTool, slowTool] }, initialState: { tools: [settledTool, slowTool] },
streamFunction: () => { streamFn: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ stream.push({
@@ -412,7 +440,7 @@ describe("Agent", () => {
}); });
it("should update state with mutators", () => { it("should update state with mutators", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction }); const agent = new Agent({ streamFn: unusedStreamFunction });
// Test setSystemPrompt // Test setSystemPrompt
agent.state.systemPrompt = "Custom prompt"; agent.state.systemPrompt = "Custom prompt";
@@ -451,7 +479,7 @@ describe("Agent", () => {
}); });
it("should support steering message queue", async () => { it("should support steering message queue", async () => {
const agent = new Agent({ streamFunction: unusedStreamFunction }); const agent = new Agent({ streamFn: unusedStreamFunction });
const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() }; const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() };
agent.steer(message); agent.steer(message);
@@ -461,7 +489,7 @@ describe("Agent", () => {
}); });
it("should support follow-up message queue", async () => { it("should support follow-up message queue", async () => {
const agent = new Agent({ streamFunction: unusedStreamFunction }); const agent = new Agent({ streamFn: unusedStreamFunction });
const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() }; const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() };
agent.followUp(message); agent.followUp(message);
@@ -471,7 +499,7 @@ describe("Agent", () => {
}); });
it("should handle abort controller", () => { it("should handle abort controller", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction }); const agent = new Agent({ streamFn: unusedStreamFunction });
// Should not throw even if nothing is running // Should not throw even if nothing is running
expect(() => agent.abort()).not.toThrow(); expect(() => agent.abort()).not.toThrow();
@@ -481,7 +509,7 @@ describe("Agent", () => {
let abortSignal: AbortSignal | undefined; let abortSignal: AbortSignal | undefined;
const agent = new Agent({ const agent = new Agent({
// Use a stream function that responds to abort // Use a stream function that responds to abort
streamFunction: (_model, _context, options) => { streamFn: (_model, _context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -520,7 +548,7 @@ describe("Agent", () => {
it("should throw when continue() called while streaming", async () => { it("should throw when continue() called while streaming", async () => {
let abortSignal: AbortSignal | undefined; let abortSignal: AbortSignal | undefined;
const agent = new Agent({ const agent = new Agent({
streamFunction: (_model, _context, options) => { streamFn: (_model, _context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -555,7 +583,7 @@ describe("Agent", () => {
it("continue() should process queued follow-up messages after an assistant turn", async () => { it("continue() should process queued follow-up messages after an assistant turn", async () => {
const agent = new Agent({ const agent = new Agent({
streamFunction: () => { streamFn: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") }); stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
@@ -594,7 +622,7 @@ describe("Agent", () => {
it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => { it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => {
let responseCount = 0; let responseCount = 0;
const agent = new Agent({ const agent = new Agent({
streamFunction: () => { streamFn: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
responseCount++; responseCount++;
queueMicrotask(() => { queueMicrotask(() => {
@@ -652,7 +680,7 @@ describe("Agent", () => {
sawAbortSignal = signal instanceof AbortSignal; sawAbortSignal = signal instanceof AbortSignal;
return undefined; return undefined;
}, },
streamFunction: () => { streamFn: () => {
requestCount++; requestCount++;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -680,7 +708,7 @@ describe("Agent", () => {
let receivedSessionId: string | undefined; let receivedSessionId: string | undefined;
const agent = new Agent({ const agent = new Agent({
sessionId: "session-abc", sessionId: "session-abc",
streamFunction: (_model, _context, options) => { streamFn: (_model, _context, options) => {
receivedSessionId = options?.sessionId; receivedSessionId = options?.sessionId;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
+10 -10
View File
@@ -38,7 +38,7 @@ afterEach(() => {
async function basicPrompt(model: Model<string>) { async function basicPrompt(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant. Keep your responses concise.", systemPrompt: "You are a helpful assistant. Keep your responses concise.",
model, model,
@@ -61,7 +61,7 @@ async function basicPrompt(model: Model<string>) {
async function toolExecution(model: Model<string>) { async function toolExecution(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.", systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.",
model, model,
@@ -101,7 +101,7 @@ async function toolExecution(model: Model<string>) {
async function abortExecution(model: Model<string>) { async function abortExecution(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model, model,
@@ -129,7 +129,7 @@ async function abortExecution(model: Model<string>) {
async function stateUpdates(model: Model<string>) { async function stateUpdates(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model, model,
@@ -162,7 +162,7 @@ async function stateUpdates(model: Model<string>) {
async function multiTurnConversation(model: Model<string>) { async function multiTurnConversation(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model, model,
@@ -244,7 +244,7 @@ describe("Agent integration with faux provider", () => {
faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]); faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]);
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model: faux.getModel(), model: faux.getModel(),
@@ -269,7 +269,7 @@ describe("Agent.continue() with faux provider", () => {
it("throws when no messages in context", async () => { it("throws when no messages in context", async () => {
const faux = createFauxRegistration(); const faux = createFauxRegistration();
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "Test", systemPrompt: "Test",
model: faux.getModel(), model: faux.getModel(),
@@ -283,7 +283,7 @@ describe("Agent.continue() with faux provider", () => {
const faux = createFauxRegistration(); const faux = createFauxRegistration();
const model = faux.getModel(); const model = faux.getModel();
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "Test", systemPrompt: "Test",
model, model,
@@ -318,7 +318,7 @@ describe("Agent.continue() with faux provider", () => {
const faux = createFauxRegistration(); const faux = createFauxRegistration();
faux.setResponses([fauxAssistantMessage("HELLO WORLD")]); faux.setResponses([fauxAssistantMessage("HELLO WORLD")]);
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant. Follow instructions exactly.", systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
model: faux.getModel(), model: faux.getModel(),
@@ -353,7 +353,7 @@ describe("Agent.continue() with faux provider", () => {
const model = faux.getModel(); const model = faux.getModel();
faux.setResponses([fauxAssistantMessage("The answer is 8.")]); faux.setResponses([fauxAssistantMessage("The answer is 8.")]);
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
systemPrompt: systemPrompt:
"You are a helpful assistant. After getting a calculation result, state the answer clearly.", "You are a helpful assistant. After getting a calculation result, state the answer clearly.",
@@ -605,6 +605,176 @@ describe("AgentHarness", () => {
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage); expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage);
}); });
describe("summarization retries", () => {
it("retries transient compaction errors and emits retry events", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
},
() => {
calls++;
return fauxAssistantMessage("## Goal\nRecovered summary");
},
]);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
const result = await harness.compact();
expect(result.summary).toContain("Recovered summary");
expect(calls).toBe(2);
expect(retryEvents).toEqual([
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_finished:compaction",
]);
});
it("does not retry non-retryable compaction errors", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" });
},
]);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(event.type);
}
});
await expect(harness.compact()).rejects.toThrow("insufficient_quota");
expect(calls).toBe(1);
expect(retryEvents).toEqual([]);
});
it("exhausts transient compaction retries after maxRetries failures", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses(
Array.from({ length: 4 }, () => () => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
}),
);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
await expect(harness.compact()).rejects.toThrow("terminated");
expect(calls).toBe(4);
expect(retryEvents).toEqual([
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_finished:compaction",
]);
});
it("retries transient branch summary errors and emits retry events", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
},
() => {
calls++;
return fauxAssistantMessage("## Goal\nRecovered branch summary");
},
]);
const session = new Session(new InMemorySessionStorage());
const targetId = await session.appendMessage(createUserMessage("first branch"));
await session.appendMessage(createAssistantMessage("first reply"));
await session.appendMessage(createUserMessage("abandoned work"));
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
const result = await harness.navigateTree(targetId, { summarize: true });
expect(result.summaryEntry?.summary).toContain("Recovered branch summary");
expect(calls).toBe(2);
expect(retryEvents).toEqual([
"retry_scheduled:branch_summary",
"retry_attempt_start:branch_summary",
"retry_finished:branch_summary",
]);
});
});
it("persists generated branch summary usage", async () => { it("persists generated branch summary usage", async () => {
const registration = newFaux(); const registration = newFaux();
registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]); registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]);
+15
View File
@@ -2,6 +2,20 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Fixed OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)).
## [0.81.1] - 2026-07-21
### Added
- Added `retryAssistantCall()` for bounded retries of transient assistant failures with lifecycle callbacks and abort handling ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)).
### Fixed
- Fixed 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 ## [0.81.0] - 2026-07-21
### Added ### Added
@@ -10,6 +24,7 @@
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)). - Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)). - Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)). - Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
- Added Kimi Code subscription OAuth login (device authorization grant) for the `kimi-coding` provider, with token refresh and `KIMI_CODE_OAUTH_HOST`/`KIMI_OAUTH_HOST` host overrides.
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-ai", "name": "@earendil-works/pi-ai",
"version": "0.81.0", "version": "0.81.1",
"description": "Unified LLM API with automatic model discovery and provider configuration", "description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+75 -14
View File
@@ -3,6 +3,7 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
import { dirname, join, resolve } from "path"; import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
import { import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL, CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
@@ -83,6 +84,7 @@ interface ModelsDevModel {
name: string; name: string;
tool_call?: boolean; tool_call?: boolean;
reasoning?: boolean; reasoning?: boolean;
reasoning_options?: ModelsDevReasoningOption[];
limit?: { limit?: {
context?: number; context?: number;
output?: number; output?: number;
@@ -112,6 +114,12 @@ interface ModelsDevModel {
}; };
} }
interface ModelsDevProvider {
models?: Record<string, ModelsDevModel>;
}
type ModelsDevCatalog = Record<string, ModelsDevProvider>;
interface NvidiaNimModelListItem { interface NvidiaNimModelListItem {
id: string; id: string;
} }
@@ -257,15 +265,6 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
max: "max", max: "max",
} as const; } as const;
const KIMI_K3_THINKING_LEVEL_MAP = {
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
} as const;
const KIMI_K3_MAX_TOKENS = 131072; const KIMI_K3_MAX_TOKENS = 131072;
const KIMI_K3_COST = { const KIMI_K3_COST = {
input: 3, input: 3,
@@ -398,6 +397,43 @@ function mergeThinkingLevelMap(model: Model<any>, map: NonNullable<Model<any>["t
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
} }
const modelsDevReasoningOptions = new Map<string, ModelsDevReasoningOption[]>();
function getModelKey(model: Pick<Model<Api>, "provider" | "id">): string {
return `${model.provider}:${model.id}`;
}
function recordModelsDevReasoningOptions(provider: string, id: string, sourceModel: ModelsDevModel): void {
if (sourceModel.reasoning_options !== undefined) {
modelsDevReasoningOptions.set(`${provider}:${id}`, sourceModel.reasoning_options);
}
}
function supportsDirectReasoningEffort(model: Model<Api>): boolean {
if (model.api === "anthropic-messages") return model.compat?.forceAdaptiveThinking === true;
if (
model.api === "openai-responses" ||
model.api === "azure-openai-responses" ||
model.api === "openai-codex-responses"
) {
return true;
}
if (model.api !== "openai-completions") return false;
const compat = {
...detectOpenAICompletionsCompat(model as Model<"openai-completions">),
...(model.compat as OpenAICompletionsCompat | undefined),
};
return compat.thinkingFormat === "openai" && compat.supportsReasoningEffort;
}
function applyModelsDevReasoningOptionMetadata(model: Model<Api>): void {
const reasoningOptions = modelsDevReasoningOptions.get(getModelKey(model));
if (!reasoningOptions || !supportsDirectReasoningEffort(model)) return;
const thinkingLevelMap = getEffortThinkingLevelMap(reasoningOptions);
if (thinkingLevelMap) mergeThinkingLevelMap(model, thinkingLevelMap);
}
function getTogetherCompat(modelId: string, reasoning: boolean): OpenAICompletionsCompat { function getTogetherCompat(modelId: string, reasoning: boolean): OpenAICompletionsCompat {
if (!reasoning) return TOGETHER_BASE_COMPAT; if (!reasoning) return TOGETHER_BASE_COMPAT;
if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_REASONING_EFFORT_COMPAT; if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_REASONING_EFFORT_COMPAT;
@@ -536,7 +572,8 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
const isOpenRouterDeveloperRoleModel = const isOpenRouterDeveloperRoleModel =
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; const cacheControlFormat =
provider === "openrouter" && /^~?anthropic\//.test(model.id) ? "anthropic" : undefined;
return { return {
supportsStore: !isNonStandard, supportsStore: !isNonStandard,
@@ -957,7 +994,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
console.log("Fetching models from models.dev API..."); console.log("Fetching models from models.dev API...");
const response = await fetch("https://models.dev/api.json"); const response = await fetch("https://models.dev/api.json");
if (!response.ok) throw new Error(`models.dev API returned ${response.status}`); if (!response.ok) throw new Error(`models.dev API returned ${response.status}`);
const data = await response.json(); const data = (await response.json()) as ModelsDevCatalog;
const models: Model<any>[] = []; const models: Model<any>[] = [];
const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>(); const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>();
@@ -997,6 +1034,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("amazon-bedrock" as const, id, m);
} }
} }
@@ -1023,6 +1061,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("anthropic", modelId, m);
} }
} }
@@ -1056,6 +1095,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: source.limit?.context || 4096, contextWindow: source.limit?.context || 4096,
maxTokens: source.limit?.output || 4096, maxTokens: source.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("google", modelId, source);
} }
} }
@@ -1097,6 +1137,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: source.limit?.context || 4096, contextWindow: source.limit?.context || 4096,
maxTokens: source.limit?.output || 4096, maxTokens: source.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("google-vertex", modelId, source);
} }
} }
@@ -1125,6 +1166,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("openai", modelId, m);
} }
} }
@@ -1151,6 +1193,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("groq", modelId, m);
} }
} }
@@ -1177,6 +1220,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("cerebras", modelId, m);
} }
} }
@@ -1204,6 +1248,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
compat: { sendSessionAffinityHeaders: true }, compat: { sendSessionAffinityHeaders: true },
}); });
recordModelsDevReasoningOptions("cloudflare-workers-ai", modelId, m);
} }
} }
@@ -1260,6 +1305,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
...(compat ? { compat } : {}), ...(compat ? { compat } : {}),
}); });
recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m);
} }
} }
@@ -1288,6 +1334,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("xai", modelId, m);
} }
} }
@@ -1330,6 +1377,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions(provider, modelId, m);
} }
} }
} }
@@ -1357,6 +1405,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("mistral", modelId, m);
} }
} }
@@ -1386,6 +1435,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("huggingface", modelId, m);
} }
} }
@@ -1423,6 +1473,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
supportsLongCacheRetention: false, supportsLongCacheRetention: false,
}, },
}); });
recordModelsDevReasoningOptions("fireworks", modelId, m);
} }
} }
@@ -1457,6 +1508,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("nvidia", liveModelId, m);
} }
} }
@@ -1489,6 +1541,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("together", modelId, m);
} }
} }
@@ -1596,6 +1649,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions(variant.provider, modelId, m);
} }
} }
@@ -1646,6 +1700,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}; };
models.push(copilotModel); models.push(copilotModel);
recordModelsDevReasoningOptions("github-copilot", modelId, m);
} }
} }
@@ -1679,6 +1734,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions(provider, modelId, m);
} }
} }
} }
@@ -1716,7 +1772,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
forceAdaptiveThinking: true, forceAdaptiveThinking: true,
}, },
reasoning: isKimiK3 || m.reasoning === true, reasoning: isKimiK3 || m.reasoning === true,
...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}),
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: { cost: {
input: m.cost?.input || impliedCost?.input || 0, input: m.cost?.input || impliedCost?.input || 0,
@@ -1727,6 +1782,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions("kimi-coding", normalizedId, m);
} }
} }
@@ -1761,6 +1817,8 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
if (isKimiK3) { if (isKimiK3) {
compat.requiresReasoningContentOnAssistantMessages = true; compat.requiresReasoningContentOnAssistantMessages = true;
compat.deferredToolsMode = "kimi"; compat.deferredToolsMode = "kimi";
compat.thinkingFormat = "openai";
compat.supportsReasoningEffort = true;
} }
models.push({ models.push({
id: modelId, id: modelId,
@@ -1769,7 +1827,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
provider, provider,
baseUrl, baseUrl,
reasoning: isKimiK3 || m.reasoning === true, reasoning: isKimiK3 || m.reasoning === true,
...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}),
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: { cost: {
input: m.cost?.input || (isKimiK3 ? KIMI_K3_COST.input : 0), input: m.cost?.input || (isKimiK3 ? KIMI_K3_COST.input : 0),
@@ -1781,6 +1838,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
compat, compat,
}); });
recordModelsDevReasoningOptions(provider, modelId, m);
} }
} }
@@ -1837,6 +1895,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions(provider, modelId, m);
} }
} }
@@ -1888,6 +1947,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
}); });
recordModelsDevReasoningOptions(provider, modelId, m);
} }
} }
@@ -2392,8 +2452,9 @@ async function generateModels() {
allModels.push(...azureOpenAiModels); allModels.push(...azureOpenAiModels);
for (const model of allModels) { for (const model of allModels) {
applyThinkingLevelMetadata(model);
applyOpenAICompletionsCompatMetadata(model); applyOpenAICompletionsCompatMetadata(model);
applyModelsDevReasoningOptionMetadata(model);
applyThinkingLevelMetadata(model);
applyOpenAIToolSearchMetadata(model); applyOpenAIToolSearchMetadata(model);
} }
@@ -0,0 +1,30 @@
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
export type ModelsDevReasoningOption =
| { type: "toggle" }
| {
type: "effort";
values: Array<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default" | null>;
}
| { type: "budget_tokens"; min?: number; max?: number };
const THINKING_LEVELS: readonly ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh", "max"];
/**
* Converts models.dev verified effort values into Pi's selectable thinking levels.
* Values without a Pi equivalent (`default` and JSON `null`) are intentionally
* omitted.
*/
export function getEffortThinkingLevelMap(options: readonly ModelsDevReasoningOption[]): ThinkingLevelMap | undefined {
const effortValues = options.flatMap((option) => (option.type === "effort" ? option.values : []));
if (effortValues.length === 0) return undefined;
const supported = new Set(effortValues);
if (!THINKING_LEVELS.some((level) => supported.has(level)) && !supported.has("none")) return undefined;
const map: ThinkingLevelMap = { off: supported.has("none") ? "none" : null };
for (const level of THINKING_LEVELS) {
map[level] = supported.has(level) ? level : null;
}
return map;
}
+3 -2
View File
@@ -809,7 +809,7 @@ function addCacheControlToLastConversationMessage(
): void { ): void {
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]; const message = messages[i];
if (message.role === "user" || message.role === "assistant") { if (message.role === "user" || message.role === "assistant" || message.role === "tool") {
if (addCacheControlToMessage(message, cacheControl)) { if (addCacheControlToMessage(message, cacheControl)) {
return; return;
} }
@@ -840,7 +840,7 @@ function addCacheControlToMessage(
message: ChatCompletionMessageParam, message: ChatCompletionMessageParam,
cacheControl: OpenAICompatCacheControl, cacheControl: OpenAICompatCacheControl,
): boolean { ): boolean {
if (message.role === "user" || message.role === "assistant") { if (message.role === "user" || message.role === "assistant" || message.role === "tool") {
return addCacheControlToTextContent(message, cacheControl); return addCacheControlToTextContent(message, cacheControl);
} }
return false; return false;
@@ -850,6 +850,7 @@ function addCacheControlToTextContent(
message: message:
| ChatCompletionInstructionMessageParam | ChatCompletionInstructionMessageParam
| ChatCompletionAssistantMessageParam | ChatCompletionAssistantMessageParam
| ChatCompletionToolMessageParam
| Extract<ChatCompletionMessageParam, { role: "user" }>, | Extract<ChatCompletionMessageParam, { role: "user" }>,
cacheControl: OpenAICompatCacheControl, cacheControl: OpenAICompatCacheControl,
): boolean { ): boolean {
+302
View File
@@ -0,0 +1,302 @@
/**
* Kimi Code (subscription) OAuth flow
*
* RFC 8628 device authorization grant against https://auth.kimi.com with JSON
* responses. The access token authenticates requests to
* https://api.kimi.com/coding as an `Authorization: Bearer` header.
*/
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
const REQUEST_TIMEOUT_MS = 30 * 1000;
const REFRESH_MAX_RETRIES = 3;
type DeviceAuthorization = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete: string;
intervalSeconds: number;
expiresInSeconds: number;
};
type TokenResponse = {
access: string;
refresh: string;
expires: number;
};
function getOauthHost(): string {
const override = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST");
return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, "");
}
function requestSignal(signal?: AbortSignal): AbortSignal {
return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(signal ? [signal] : [])]);
}
function formUrlEncode(fields: Record<string, string>): string {
return new URLSearchParams(fields).toString();
}
async function readJson(response: Response): Promise<Record<string, unknown> | null> {
try {
const json = await response.json();
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
} catch {
return null;
}
}
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
function trustedHttpUrl(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}
async function startDeviceAuthorization(oauthHost: string, signal?: AbortSignal): Promise<DeviceAuthorization> {
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: formUrlEncode({ client_id: CLIENT_ID }),
signal: requestSignal(signal),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Kimi Code device authorization failed with status ${response.status}${text ? `: ${text}` : ""}`);
}
const json = await readJson(response);
const deviceCode = json?.device_code;
const userCode = json?.user_code;
const verificationUri = json?.verification_uri;
const verificationUriComplete = json?.verification_uri_complete;
if (
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof verificationUriComplete !== "string" ||
!trustedHttpUrl(verificationUriComplete) ||
!trustedHttpUrl(verificationUri)
) {
throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`);
}
const interval = json?.interval;
const expiresIn = json?.expires_in;
return {
deviceCode,
userCode,
verificationUri,
verificationUriComplete,
intervalSeconds:
typeof interval === "number" && Number.isFinite(interval) && interval > 0
? interval
: DEFAULT_POLL_INTERVAL_SECONDS,
expiresInSeconds:
typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0
? expiresIn
: DEVICE_CODE_TIMEOUT_SECONDS,
};
}
function parseTokenResponse(json: Record<string, unknown> | null, operation: string): TokenResponse {
const accessToken = json?.access_token;
const refreshToken = json?.refresh_token;
const expiresIn = json?.expires_in;
if (
typeof accessToken !== "string" ||
!accessToken ||
typeof refreshToken !== "string" ||
!refreshToken ||
typeof expiresIn !== "number" ||
!Number.isFinite(expiresIn) ||
expiresIn <= 0
) {
throw new Error(`Kimi Code token ${operation} response missing fields: ${JSON.stringify(json)}`);
}
return {
access: accessToken,
refresh: refreshToken,
expires: Date.now() + expiresIn * 1000,
};
}
async function pollForToken(
oauthHost: string,
device: DeviceAuthorization,
signal?: AbortSignal,
): Promise<TokenResponse> {
return pollOAuthDeviceCodeFlow<TokenResponse>({
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const response = await fetch(`${oauthHost}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: formUrlEncode({
client_id: CLIENT_ID,
device_code: device.deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
signal: requestSignal(signal),
});
if (response.status >= 500) {
const text = await response.text().catch(() => "");
return {
status: "failed",
message: `Kimi Code device token request failed with status ${response.status}${text ? `: ${text}` : ""}`,
};
}
const json = await readJson(response);
if (response.ok && typeof json?.access_token === "string") {
try {
return { status: "complete", value: parseTokenResponse(json, "poll") };
} catch (error) {
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
}
}
const error = json?.error;
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
if (error === "authorization_pending") {
return { status: "pending" };
}
if (error === "slow_down") {
const interval = json?.interval;
return {
status: "slow_down",
intervalSeconds: typeof interval === "number" && interval > 0 ? interval : undefined,
};
}
if (error === "expired_token") {
return { status: "failed", message: "Kimi Code device authorization expired. Please restart login." };
}
if (error === "access_denied") {
return { status: "failed", message: "Kimi Code login was denied." };
}
return {
status: "failed",
message: `Kimi Code device token request failed (status ${response.status})${typeof error === "string" ? `: ${error}${description}` : ""}`,
};
},
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableRefreshFailure(response: Response): boolean {
return response.status === 429 || response.status >= 500;
}
async function refreshToken(
oauthHost: string,
refreshTokenValue: string,
signal?: AbortSignal,
): Promise<TokenResponse> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= REFRESH_MAX_RETRIES; attempt++) {
if (attempt > 0) {
await sleep(1000 * 2 ** (attempt - 1));
}
if (signal?.aborted) {
throw new Error("Kimi Code token refresh aborted");
}
let response: Response;
try {
response = await fetch(`${oauthHost}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: formUrlEncode({
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token: refreshTokenValue,
}),
signal: requestSignal(signal),
});
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
continue;
}
const json = await readJson(response);
if (response.ok) {
return parseTokenResponse(json, "refresh");
}
// Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") {
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
throw new Error(`Kimi Code token refresh unauthorized (status ${response.status})${description}`);
}
if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) {
lastError = new Error(`Kimi Code token refresh failed with status ${response.status}`);
continue;
}
const text = JSON.stringify(json);
throw new Error(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`);
}
throw lastError ?? new Error("Kimi Code token refresh failed");
}
async function loginKimiCoding(interaction: AuthInteraction): Promise<OAuthCredential> {
const oauthHost = getOauthHost();
const device = await startDeviceAuthorization(oauthHost, interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.userCode,
verificationUri: device.verificationUriComplete,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
});
const token = await pollForToken(oauthHost, device, interaction.signal);
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
}
export const kimiCodingOAuth: OAuthAuth = {
name: "Kimi Code (subscription)",
loginLabel: "Sign in with Kimi Code",
login: loginKimiCoding,
refresh: async (credential, signal) => {
const token = await refreshToken(getOauthHost(), credential.refresh, signal);
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
},
async toAuth(credential) {
return { headers: { Authorization: `Bearer ${credential.access}` } };
},
};
+6
View File
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
anthropic: () => OAuthAuth | Promise<OAuthAuth>; anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>; openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>; githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
xai: () => OAuthAuth | Promise<OAuthAuth>; xai: () => OAuthAuth | Promise<OAuthAuth>;
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>; radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
}; };
@@ -41,6 +42,11 @@ export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
}; };
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.kimiCoding();
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
};
export const loadXaiOAuth = async (): Promise<OAuthAuth> => { export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.xai(); if (bundledLoaders) return bundledLoaders.xai();
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
+2
View File
@@ -1,5 +1,6 @@
import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
import { createRadiusOAuth } from "./auth/oauth/radius.ts"; import { createRadiusOAuth } from "./auth/oauth/radius.ts";
@@ -11,6 +12,7 @@ export function registerBunOAuthFlows(): void {
anthropic: () => anthropicOAuth, anthropic: () => anthropicOAuth,
openaiCodex: () => openaiCodexOAuth, openaiCodex: () => openaiCodexOAuth,
githubCopilot: () => githubCopilotOAuth, githubCopilot: () => githubCopilotOAuth,
kimiCoding: () => kimiCodingOAuth,
xai: () => xaiOAuth, xai: () => xaiOAuth,
radius: createRadiusOAuth, radius: createRadiusOAuth,
}); });
@@ -53,6 +53,14 @@ export const GOOGLE_MODELS = values as {
id: "gemini-3.5-flash"; id: "gemini-3.5-flash";
provider: "google"; provider: "google";
}; };
"gemini-3.5-flash-lite": Model<"google-generative-ai"> & {
id: "gemini-3.5-flash-lite";
provider: "google";
};
"gemini-3.6-flash": Model<"google-generative-ai"> & {
id: "gemini-3.6-flash";
provider: "google";
};
"gemini-flash-latest": Model<"google-generative-ai"> & { "gemini-flash-latest": Model<"google-generative-ai"> & {
id: "gemini-flash-latest"; id: "gemini-flash-latest";
provider: "google"; provider: "google";
+10 -2
View File
@@ -1,5 +1,6 @@
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadKimiCodingOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts"; import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts";
@@ -8,7 +9,14 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> {
id: "kimi-coding", id: "kimi-coding",
name: "Kimi For Coding", name: "Kimi For Coding",
baseUrl: "https://api.kimi.com/coding", baseUrl: "https://api.kimi.com/coding",
auth: { apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]) }, auth: {
apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]),
oauth: lazyOAuth({
name: "Kimi Code (subscription)",
loginLabel: "Sign in with Kimi Code",
load: loadKimiCodingOAuth,
}),
},
models: Object.values(KIMI_CODING_MODELS), models: Object.values(KIMI_CODING_MODELS),
api: anthropicMessagesApi(), api: anthropicMessagesApi(),
}); });
@@ -77,6 +77,14 @@ export const OPENCODE_MODELS = values as {
id: "gemini-3.5-flash"; id: "gemini-3.5-flash";
provider: "opencode"; provider: "opencode";
}; };
"gemini-3.5-flash-lite": Model<"google-generative-ai"> & {
id: "gemini-3.5-flash-lite";
provider: "opencode";
};
"gemini-3.6-flash": Model<"google-generative-ai"> & {
id: "gemini-3.6-flash";
provider: "opencode";
};
"glm-5": Model<"openai-completions"> & { "glm-5": Model<"openai-completions"> & {
id: "glm-5"; id: "glm-5";
provider: "opencode"; provider: "opencode";
@@ -185,6 +193,10 @@ export const OPENCODE_MODELS = values as {
id: "kimi-k2.7-code"; id: "kimi-k2.7-code";
provider: "opencode"; provider: "opencode";
}; };
"laguna-s-2.1-free": Model<"openai-completions"> & {
id: "laguna-s-2.1-free";
provider: "opencode";
};
"mimo-v2.5-free": Model<"openai-completions"> & { "mimo-v2.5-free": Model<"openai-completions"> & {
id: "mimo-v2.5-free"; id: "mimo-v2.5-free";
provider: "opencode"; provider: "opencode";
@@ -229,6 +229,14 @@ export const OPENROUTER_MODELS = values as {
id: "google/gemini-3.5-flash"; id: "google/gemini-3.5-flash";
provider: "openrouter"; provider: "openrouter";
}; };
"google/gemini-3.5-flash-lite": Model<"openai-completions"> & {
id: "google/gemini-3.5-flash-lite";
provider: "openrouter";
};
"google/gemini-3.6-flash": Model<"openai-completions"> & {
id: "google/gemini-3.6-flash";
provider: "openrouter";
};
"google/gemma-3-12b-it": Model<"openai-completions"> & { "google/gemma-3-12b-it": Model<"openai-completions"> & {
id: "google/gemma-3-12b-it"; id: "google/gemma-3-12b-it";
provider: "openrouter"; provider: "openrouter";
@@ -737,6 +745,14 @@ export const OPENROUTER_MODELS = values as {
id: "poolside/laguna-m.1:free"; id: "poolside/laguna-m.1:free";
provider: "openrouter"; provider: "openrouter";
}; };
"poolside/laguna-s-2.1": Model<"openai-completions"> & {
id: "poolside/laguna-s-2.1";
provider: "openrouter";
};
"poolside/laguna-s-2.1:free": Model<"openai-completions"> & {
id: "poolside/laguna-s-2.1:free";
provider: "openrouter";
};
"poolside/laguna-xs-2.1": Model<"openai-completions"> & { "poolside/laguna-xs-2.1": Model<"openai-completions"> & {
id: "poolside/laguna-xs-2.1"; id: "poolside/laguna-xs-2.1";
provider: "openrouter"; provider: "openrouter";
@@ -265,6 +265,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as {
id: "google/gemini-3.5-flash"; id: "google/gemini-3.5-flash";
provider: "vercel-ai-gateway"; provider: "vercel-ai-gateway";
}; };
"google/gemini-3.5-flash-lite": Model<"anthropic-messages"> & {
id: "google/gemini-3.5-flash-lite";
provider: "vercel-ai-gateway";
};
"google/gemini-3.6-flash": Model<"anthropic-messages"> & {
id: "google/gemini-3.6-flash";
provider: "vercel-ai-gateway";
};
"google/gemma-4-26b-a4b-it": Model<"anthropic-messages"> & { "google/gemma-4-26b-a4b-it": Model<"anthropic-messages"> & {
id: "google/gemma-4-26b-a4b-it"; id: "google/gemma-4-26b-a4b-it";
provider: "vercel-ai-gateway"; provider: "vercel-ai-gateway";
@@ -629,6 +637,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as {
id: "openai/o4-mini"; id: "openai/o4-mini";
provider: "vercel-ai-gateway"; provider: "vercel-ai-gateway";
}; };
"poolside/laguna-s-2.1": Model<"anthropic-messages"> & {
id: "poolside/laguna-s-2.1";
provider: "vercel-ai-gateway";
};
"poolside/laguna-s-2.1-free": Model<"anthropic-messages"> & {
id: "poolside/laguna-s-2.1-free";
provider: "vercel-ai-gateway";
};
"sakana/fugu-ultra": Model<"anthropic-messages"> & { "sakana/fugu-ultra": Model<"anthropic-messages"> & {
id: "sakana/fugu-ultra"; id: "sakana/fugu-ultra";
provider: "vercel-ai-gateway"; provider: "vercel-ai-gateway";
+1 -1
View File
@@ -524,7 +524,7 @@ export interface OpenAICompletionsCompat {
zaiToolStream?: boolean; zaiToolStream?: boolean;
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */ /** Whether the provider supports the `strict` field in tool definitions. Default: true. */
supportsStrictMode?: boolean; supportsStrictMode?: boolean;
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */ /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. */
cacheControlFormat?: "anthropic"; cacheControlFormat?: "anthropic";
/** Whether to send session-affinity data from `options.sessionId`. Default: false. */ /** Whether to send session-affinity data from `options.sessionId`. Default: false. */
sendSessionAffinityHeaders?: boolean; sendSessionAffinityHeaders?: boolean;
+125
View File
@@ -51,6 +51,9 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
"connection.?lost", "connection.?lost",
"other side closed", "other side closed",
"fetch failed", "fetch failed",
"getaddrinfo",
"ENOTFOUND",
"EAI_AGAIN",
"upstream.?connect", "upstream.?connect",
"reset before headers", "reset before headers",
"socket hang up", "socket hang up",
@@ -85,6 +88,128 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
"ResourceExhausted", "ResourceExhausted",
]); ]);
/**
* Retry policy: bounded attempts with exponential backoff (`baseDelayMs * 2^(attempt-1)`).
* Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`) in coding-agent; kept
* here so the classifier and the policy-driven retry loop live together and stay reusable
* by the SDK and other callers.
*/
export interface RetryPolicy {
enabled: boolean;
/** Max retry attempts (0 = no retries). The initial call never counts as a retry. */
maxRetries: number;
/** Base delay in ms. Per-attempt delay is `baseDelayMs * 2^(attempt-1)` before jitter. */
baseDelayMs: number;
}
/** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */
export interface RetryCallbacks {
/** Emitted before the backoff sleep of each retry attempt (1-indexed). */
onRetryScheduled?: (
attempt: number,
maxAttempts: number,
delayMs: number,
errorMessage: string,
) => void | Promise<void>;
/** Emitted after the backoff sleep, immediately before the retried call starts. */
onRetryAttemptStart?: () => void | Promise<void>;
/** Emitted once when the loop ends: success if a later call completed normally. */
onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise<void>;
}
class RetrySleepAbortError extends Error {
constructor() {
super("Aborted");
}
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new RetrySleepAbortError());
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new RetrySleepAbortError());
},
{ once: true },
);
});
}
/**
* Run a single assistant-producing call with bounded retry on transient errors.
*
* Behavior:
* - A successful response is returned immediately. Aborts are terminal and never
* retried, but reported as unsuccessful if they happen after a retry was scheduled.
* Aborts during the backoff sleep are normalized to an aborted `AssistantMessage`
* too, so callers do not need to care when cancellation happened.
* - A non-retryable error (per {@link isRetryableAssistantError}, including quota/
* billing exhaustion) is returned immediately so deterministic errors fail fast.
* - Otherwise retries up to `maxRetries` times with exponential backoff, emitting
* `onRetryScheduled` before each sleep, `onRetryAttemptStart` after each sleep before
* the retried call starts, and `onRetryFinished` once at the end (whether the loop
* ends in success, exhausted retries, or an aborted backoff).
*
* When `policy` is undefined or disabled, the first response is returned unchanged
* (equivalent to calling `produce()` directly).
*/
export async function retryAssistantCall(
produce: () => Promise<AssistantMessage>,
policy: RetryPolicy | undefined,
signal: AbortSignal | undefined,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
const maxAttempts = policy?.enabled ? policy.maxRetries : 0;
let attempt = 0;
let lastRetry: { attempt: number; errorMessage: string } | undefined;
for (;;) {
const response = await produce();
// Abort: terminal but not successful. Never retry an aborted message.
if (response.stopReason === "aborted") {
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt);
return response;
}
// Success: non-error, non-abort responses return as-is.
if (response.stopReason !== "error") {
if (lastRetry) await callbacks?.onRetryFinished?.(true, lastRetry.attempt);
return response;
}
// Non-retryable, or budget exhausted: return the final error message.
if (attempt >= maxAttempts || !isRetryableAssistantError(response)) {
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt, response.errorMessage);
return response;
}
attempt++;
lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" };
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
await callbacks?.onRetryScheduled?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
// Normalize aborts during retry backoff to the same AssistantMessage shape as
// provider stream aborts, so callers do not need to care when cancellation happened.
try {
await sleep(delayMs, signal);
} catch (error) {
await callbacks?.onRetryFinished?.(false, attempt, lastRetry.errorMessage);
if (error instanceof RetrySleepAbortError) {
return { ...response, stopReason: "aborted", errorMessage: undefined };
}
throw error;
}
await callbacks?.onRetryAttemptStart?.();
}
}
/** /**
* Classifies whether a failed assistant message looks like a transient provider * Classifies whether a failed assistant message looks like a transient provider
* or transport error, so callers can decide if the last assistant turn should be * or transport error, so callers can decide if the last assistant turn should be
+260
View File
@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
import type { AuthInteraction } from "../src/auth/types.ts";
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const OAUTH_HOST = "https://auth.kimi.com";
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function getUrl(input: unknown): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
if (input instanceof Request) return input.url;
throw new Error(`Unsupported fetch input: ${String(input)}`);
}
function deviceAuthorizationResponse(overrides?: Record<string, unknown>): Response {
return jsonResponse({
user_code: "ABCD-1234",
device_code: "device-code-123",
verification_uri: "https://www.kimi.com/code",
verification_uri_complete: "https://www.kimi.com/code?user_code=ABCD-1234",
interval: 5,
expires_in: 600,
...overrides,
});
}
function createInteraction(events: Array<Record<string, unknown>>): AuthInteraction {
return {
prompt: async () => {
throw new Error("Kimi Code login should not prompt");
},
notify: (event) => events.push(event),
};
}
describe("Kimi Code OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
});
it("logs in with the device authorization flow", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-07-20T00:00:00Z");
vi.setSystemTime(startTime);
const events: Array<Record<string, unknown>> = [];
const pollResponses = [
jsonResponse({ error: "authorization_pending" }, 400),
jsonResponse({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }),
];
const pollTimes: number[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
});
expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID);
return deviceAuthorizationResponse();
}
if (url === `${OAUTH_HOST}/api/oauth/token`) {
pollTimes.push(Date.now());
const params = new URLSearchParams(String(init?.body));
expect(params.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
expect(params.get("client_id")).toBe(CLIENT_ID);
expect(params.get("device_code")).toBe("device-code-123");
const response = pollResponses.shift();
if (!response) throw new Error("Unexpected extra token poll");
return response;
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction(events));
for (let i = 0; i < 5 && events.length === 0; i++) {
await vi.advanceTimersByTimeAsync(0);
}
expect(events).toEqual([
{
type: "device_code",
userCode: "ABCD-1234",
verificationUri: "https://www.kimi.com/code?user_code=ABCD-1234",
intervalSeconds: 5,
expiresInSeconds: 600,
},
]);
// waitBeforeFirstPoll: first poll happens after the 5s interval.
await vi.advanceTimersByTimeAsync(4999);
expect(pollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(pollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(5000);
await expect(credentialPromise).resolves.toEqual({
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: startTime.getTime() + 10000 + 3600 * 1000,
});
expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10000]);
});
it("fails when the device code expires", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
return deviceAuthorizationResponse();
}
if (url === `${OAUTH_HOST}/api/oauth/token`) {
return jsonResponse({ error: "expired_token" }, 400);
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
const assertion = expect(credentialPromise).rejects.toThrow("expired");
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
it("fails when the user denies the login", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
return deviceAuthorizationResponse();
}
if (url === `${OAUTH_HOST}/api/oauth/token`) {
return jsonResponse({ error: "access_denied" }, 400);
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
const assertion = expect(credentialPromise).rejects.toThrow("denied");
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
it("honors the KIMI_CODE_OAUTH_HOST override", async () => {
vi.useFakeTimers();
vi.stubEnv("KIMI_CODE_OAUTH_HOST", "https://auth.example.com/");
const urls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
urls.push(url);
if (url === "https://auth.example.com/api/oauth/device_authorization") {
return deviceAuthorizationResponse({ interval: 1 });
}
if (url === "https://auth.example.com/api/oauth/token") {
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
}),
);
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
await vi.advanceTimersByTimeAsync(1000);
await expect(credentialPromise).resolves.toMatchObject({ access: "a", refresh: "r" });
expect(urls).toEqual([
"https://auth.example.com/api/oauth/device_authorization",
"https://auth.example.com/api/oauth/token",
]);
});
it("refreshes tokens and returns a Bearer header for requests", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
const url = getUrl(input);
expect(url).toBe(`${OAUTH_HOST}/api/oauth/token`);
const params = new URLSearchParams(String(init?.body));
expect(params.get("grant_type")).toBe("refresh_token");
expect(params.get("refresh_token")).toBe("old-refresh");
expect(params.get("client_id")).toBe(CLIENT_ID);
return jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 });
}),
);
const before = Date.now();
const credential = await kimiCodingOAuth.refresh({
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: before,
});
expect(credential).toEqual({
type: "oauth",
access: "new-access",
refresh: "new-refresh",
expires: expect.any(Number),
});
expect(credential.expires).toBeGreaterThanOrEqual(before + 3600 * 1000);
await expect(kimiCodingOAuth.toAuth(credential)).resolves.toEqual({
headers: { Authorization: "Bearer new-access" },
});
});
it("retries refresh on 429 and fails unauthorized on invalid_grant", async () => {
vi.useFakeTimers();
// 429 once, then success.
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => {
calls += 1;
if (calls === 1) return jsonResponse({ error: "temporarily_unavailable" }, 429);
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
}),
);
const refreshPromise = kimiCodingOAuth.refresh({
type: "oauth",
access: "old",
refresh: "old",
expires: 0,
});
await vi.advanceTimersByTimeAsync(1000);
await expect(refreshPromise).resolves.toMatchObject({ access: "a" });
expect(calls).toBe(2);
// invalid_grant is not retried.
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => jsonResponse({ error: "invalid_grant" }, 400)),
);
await expect(
kimiCodingOAuth.refresh({ type: "oauth", access: "old", refresh: "old", expires: 0 }),
).rejects.toThrow("unauthorized");
});
});
@@ -2,7 +2,7 @@ import { Type } from "typebox";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import { getModel } from "../src/compat.ts"; import { getModel } from "../src/compat.ts";
import type { Model } from "../src/types.ts"; import type { Message, Model } from "../src/types.ts";
interface CacheControl { interface CacheControl {
type: "ephemeral"; type: "ephemeral";
@@ -74,6 +74,7 @@ vi.mock("openai", () => {
async function capturePayload( async function capturePayload(
model: Model<"openai-completions">, model: Model<"openai-completions">,
options?: { cacheRetention?: "none" | "short" | "long" }, options?: { cacheRetention?: "none" | "short" | "long" },
messages?: Message[],
): Promise<CapturedParams> { ): Promise<CapturedParams> {
const timestamp = Date.now(); const timestamp = Date.now();
@@ -81,7 +82,7 @@ async function capturePayload(
model, model,
{ {
systemPrompt: "System prompt", systemPrompt: "System prompt",
messages: [{ role: "user", content: "Hello", timestamp }], messages: messages ?? [{ role: "user", content: "Hello", timestamp }],
tools: [ tools: [
{ {
name: "read", name: "read",
@@ -158,6 +159,47 @@ describe("openai-completions cacheControlFormat", () => {
expectAnthropicCacheMarkers(params); expectAnthropicCacheMarkers(params);
}); });
it("moves the conversation cache marker to a tool result", async () => {
const model = getModel("openrouter", "anthropic/claude-sonnet-4");
const timestamp = Date.now();
const params = await capturePayload(model, undefined, [
{ role: "user", content: "Read the file", timestamp },
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }],
api: "openai-completions",
provider: "openrouter",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp,
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
isError: false,
timestamp,
},
]);
const userMessage = params.messages.find((message) => message.role === "user");
expect(userMessage?.content).toBe("Read the file");
const toolMessage = params.messages[params.messages.length - 1];
expect(toolMessage.role).toBe("tool");
expect(Array.isArray(toolMessage.content)).toBe(true);
expect((toolMessage.content as TextPart[])[0]?.cache_control).toEqual({ type: "ephemeral" });
});
it("omits Anthropic-style cache markers when cacheRetention is none", async () => { it("omits Anthropic-style cache markers when cacheRetention is none", async () => {
const model: Model<"openai-completions"> = { const model: Model<"openai-completions"> = {
id: "custom-qwen", id: "custom-qwen",
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { getModel } from "../src/compat.ts";
const OPENROUTER_ANTHROPIC_LATEST_MODEL_IDS = [
"~anthropic/claude-fable-latest",
"~anthropic/claude-haiku-latest",
"~anthropic/claude-opus-latest",
"~anthropic/claude-sonnet-latest",
] as const;
describe("OpenRouter Anthropic cache control metadata", () => {
it.each(OPENROUTER_ANTHROPIC_LATEST_MODEL_IDS)("enables cache control for %s", (modelId) => {
expect(getModel("openrouter", modelId).compat?.cacheControlFormat).toBe("anthropic");
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { getEffortThinkingLevelMap } from "../scripts/models-dev-reasoning-options.ts";
describe("getEffortThinkingLevelMap", () => {
it("exposes only verified effort values and none", () => {
expect(
getEffortThinkingLevelMap([{ type: "toggle" }, { type: "effort", values: ["none", "low", "high", "max"] }]),
).toEqual({
off: "none",
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
});
});
it("does not infer thinking-off from an effort list", () => {
expect(getEffortThinkingLevelMap([{ type: "effort", values: ["low", "high", "max"] }])).toEqual({
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
});
});
it("leaves toggle and budget controls for their adapter-specific implementations", () => {
expect(getEffortThinkingLevelMap([{ type: "toggle" }])).toBeUndefined();
expect(getEffortThinkingLevelMap([{ type: "budget_tokens", min: 1024, max: 32000 }])).toBeUndefined();
expect(getEffortThinkingLevelMap([{ type: "effort", values: [null, "default"] }])).toBeUndefined();
});
});
+146 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { fauxAssistantMessage } from "../src/providers/faux.ts"; import { fauxAssistantMessage } from "../src/providers/faux.ts";
import { isRetryableAssistantError } from "../src/utils/retry.ts"; import { isRetryableAssistantError, type RetryPolicy, retryAssistantCall } from "../src/utils/retry.ts";
const openAIExplicitRetryMessage = const openAIExplicitRetryMessage =
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message."; "An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message.";
@@ -10,6 +10,8 @@ const nvidiaNIMResourceExhaustedMessage = "ResourceExhausted: Worker local total
const bunFetchSocketClosedMessage = const bunFetchSocketClosedMessage =
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()"; "The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()";
const openAIResponsesEarlyEofMessage = "OpenAI Responses stream ended before a terminal response event"; const openAIResponsesEarlyEofMessage = "OpenAI Responses stream ended before a terminal response event";
const wrappedDnsLookupError =
"The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)";
describe("provider retry classification", () => { describe("provider retry classification", () => {
it("matches explicit provider retry guidance", () => { it("matches explicit provider retry guidance", () => {
@@ -38,6 +40,15 @@ describe("provider retry classification", () => {
).toBe(true); ).toBe(true);
}); });
it.each([
wrappedDnsLookupError,
"connect ENOTFOUND api.example.com",
"EAI_AGAIN api.example.com",
"getaddrinfo failed for api.example.com",
])("matches DNS transport failure wording: %s", (errorMessage) => {
expect(isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage }))).toBe(true);
});
it("matches OpenAI Responses streams that end before terminal events", () => { it("matches OpenAI Responses streams that end before terminal events", () => {
expect( expect(
isRetryableAssistantError( isRetryableAssistantError(
@@ -66,3 +77,136 @@ describe("provider retry classification", () => {
expect(isRetryableAssistantError(fauxAssistantMessage("not an error"))).toBe(false); expect(isRetryableAssistantError(fauxAssistantMessage("not an error"))).toBe(false);
}); });
}); });
describe("retryAssistantCall", () => {
const disabled: RetryPolicy = { enabled: false, maxRetries: 3, baseDelayMs: 0 };
const enabled: RetryPolicy = { enabled: true, maxRetries: 3, baseDelayMs: 0 };
it("returns a successful response immediately without retrying", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("ok"));
const res = await retryAssistantCall(produce, enabled, undefined);
expect(res.content).toEqual([{ type: "text", text: "ok" }]);
expect(produce).toHaveBeenCalledTimes(1);
});
it("does not retry an aborted message", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "aborted" }));
const onRetryScheduled = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled });
expect(res.stopReason).toBe("aborted");
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetryScheduled).not.toHaveBeenCalled();
});
it("does not retry a non-retryable error (quota/billing)", async () => {
const produce = vi.fn(async () =>
fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
);
const onRetryScheduled = vi.fn();
const onRetryFinished = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
expect(res.stopReason).toBe("error");
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetryScheduled).not.toHaveBeenCalled();
expect(onRetryFinished).not.toHaveBeenCalled();
});
it("retries a transient error up to maxRetries then returns the final error", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
const onRetryScheduled = vi.fn();
const onRetryFinished = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
expect(res.stopReason).toBe("error");
expect(produce).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
expect(onRetryScheduled).toHaveBeenCalledTimes(3);
expect(onRetryFinished).toHaveBeenCalledWith(false, 3, "terminated");
});
it("stops retrying once a call succeeds", async () => {
let n = 0;
const produce = vi.fn(async () => {
n++;
return n < 3
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
: fauxAssistantMessage("recovered");
});
const onRetryFinished = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished });
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
expect(produce).toHaveBeenCalledTimes(3);
expect(onRetryFinished).toHaveBeenCalledWith(true, 2);
});
it("reports an aborted retried call as unsuccessful", async () => {
let n = 0;
const produce = vi.fn(async () => {
n++;
return n === 1
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
: fauxAssistantMessage("", { stopReason: "aborted" });
});
const onRetryFinished = vi.fn();
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished });
expect(res.stopReason).toBe("aborted");
expect(produce).toHaveBeenCalledTimes(2);
expect(onRetryFinished).toHaveBeenCalledWith(false, 1);
});
it("does not retry when policy is disabled", async () => {
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
const onRetryScheduled = vi.fn();
const onRetryFinished = vi.fn();
const res = await retryAssistantCall(produce, disabled, undefined, { onRetryScheduled, onRetryFinished });
expect(res.stopReason).toBe("error");
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetryScheduled).not.toHaveBeenCalled();
expect(onRetryFinished).not.toHaveBeenCalled();
});
it("emits onRetryAttemptStart after backoff before each retried call", async () => {
const events: string[] = [];
let n = 0;
const produce = vi.fn(async () => {
events.push(`produce:${n}`);
n++;
return n < 3
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
: fauxAssistantMessage("recovered");
});
const onRetryScheduled = vi.fn((attempt: number) => {
events.push(`retry:${attempt}`);
});
const onRetryAttemptStart = vi.fn(() => {
events.push("attempt-start");
});
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryAttemptStart });
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
expect(onRetryScheduled).toHaveBeenCalledTimes(2);
expect(onRetryAttemptStart).toHaveBeenCalledTimes(2);
expect(events).toEqual([
"produce:0",
"retry:1",
"attempt-start",
"produce:1",
"retry:2",
"attempt-start",
"produce:2",
]);
});
it("aborts backoff sleep via signal, returns an aborted message, and emits onRetryFinished(false)", async () => {
const controller = new AbortController();
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
const policy: RetryPolicy = { enabled: true, maxRetries: 5, baseDelayMs: 10_000 };
const onRetryFinished = vi.fn();
const p = retryAssistantCall(produce, policy, controller.signal, { onRetryFinished });
// Let one error call resolve and the first backoff sleep start, then abort.
await vi.waitFor(() => expect(produce).toHaveBeenCalled());
controller.abort();
const res = await p;
expect(res.stopReason).toBe("aborted");
expect(res.errorMessage).toBeUndefined();
expect(produce).toHaveBeenCalledTimes(1);
expect(onRetryFinished).toHaveBeenCalledWith(false, 1, "terminated");
});
});
+7 -9
View File
@@ -59,15 +59,7 @@ describe("getSupportedThinkingLevels", () => {
(modelId) => { (modelId) => {
const model = getModel("openai", modelId); const model = getModel("openai", modelId);
expect(model).toBeDefined(); expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).toEqual([ expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "medium", "high", "xhigh", "max"]);
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
}, },
); );
@@ -110,6 +102,12 @@ describe("getSupportedThinkingLevels", () => {
} }
}); });
it.each(["moonshotai", "moonshotai-cn"] as const)("uses the verified effort options for %s Kimi K3", (provider) => {
const model = getModel(provider, "kimi-k3");
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).toEqual(["low", "high", "max"]);
});
it("includes only low, high, max for Kimi Coding K3", () => { it("includes only low, high, max for Kimi Coding K3", () => {
const model = getModel("kimi-coding", "k3"); const model = getModel("kimi-coding", "k3");
expect(model).toBeDefined(); expect(model).toBeDefined();
+9 -1
View File
@@ -44,7 +44,15 @@ describe("Together models", () => {
it("models Together reasoning controls from the Together API surface", () => { it("models Together reasoning controls from the Together API surface", () => {
const gptOss = getModel("together", "openai/gpt-oss-120b"); const gptOss = getModel("together", "openai/gpt-oss-120b");
expect(gptOss.thinkingLevelMap).toEqual({ off: null, minimal: null }); expect(gptOss.thinkingLevelMap).toEqual({
off: null,
minimal: null,
low: "low",
medium: "medium",
high: "high",
max: null,
xhigh: null,
});
expect(gptOss.compat).toMatchObject({ expect(gptOss.compat).toMatchObject({
supportsReasoningEffort: true, supportsReasoningEffort: true,
thinkingFormat: "openai", thinkingFormat: "openai",
+18
View File
@@ -2,6 +2,24 @@
## [Unreleased] ## [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 ## [0.81.0] - 2026-07-21
### New Features ### 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 `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. 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" } }`. `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_start"; reason: "manual" | "threshold" | "overflow" }
| { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string } | { 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_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. `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 | | `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 | | `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 | | `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`. | | `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. | | `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 | | `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 | | `compaction_end` | Compaction completes |
| `auto_retry_start` | Auto-retry begins (after transient error) | | `auto_retry_start` | Auto-retry begins (after transient error) |
| `auto_retry_end` | Auto-retry completes (success or final failure) | | `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 | | `extension_error` | Extension threw an error |
### agent_start ### 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 ### extension_error
Emitted when an extension throws an error. Emitted when an extension throws an error.
+3
View File
@@ -319,6 +319,9 @@ session.subscribe((event) => {
case "compaction_end": case "compaction_end":
case "auto_retry_start": case "auto_retry_start":
case "auto_retry_end": case "auto_retry_end":
case "summarization_retry_scheduled":
case "summarization_retry_attempt_start":
case "summarization_retry_finished":
break; break;
} }
}); });
@@ -1,12 +1,12 @@
{ {
"name": "pi-extension-custom-provider", "name": "pi-extension-custom-provider",
"version": "0.81.0", "version": "0.81.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pi-extension-custom-provider", "name": "pi-extension-custom-provider",
"version": "0.81.0", "version": "0.81.1",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.52.0" "@anthropic-ai/sdk": "^0.52.0"
} }
@@ -1,7 +1,7 @@
{ {
"name": "pi-extension-custom-provider-anthropic", "name": "pi-extension-custom-provider-anthropic",
"private": true, "private": true,
"version": "0.81.0", "version": "0.81.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "echo 'nothing to clean'", "clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
{ {
"name": "pi-extension-custom-provider-gitlab-duo", "name": "pi-extension-custom-provider-gitlab-duo",
"private": true, "private": true,
"version": "0.81.0", "version": "0.81.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "echo 'nothing to clean'", "clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{ {
"name": "pi-extension-gondolin", "name": "pi-extension-gondolin",
"version": "0.81.0", "version": "0.81.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pi-extension-gondolin", "name": "pi-extension-gondolin",
"version": "0.81.0", "version": "0.81.1",
"dependencies": { "dependencies": {
"@earendil-works/gondolin": "0.12.0" "@earendil-works/gondolin": "0.12.0"
} }
@@ -1,7 +1,7 @@
{ {
"name": "pi-extension-gondolin", "name": "pi-extension-gondolin",
"private": true, "private": true,
"version": "0.81.0", "version": "0.81.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "echo 'nothing to clean'", "clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{ {
"name": "pi-extension-sandbox", "name": "pi-extension-sandbox",
"version": "1.11.0", "version": "1.11.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pi-extension-sandbox", "name": "pi-extension-sandbox",
"version": "1.11.0", "version": "1.11.1",
"dependencies": { "dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26" "@anthropic-ai/sandbox-runtime": "^0.0.26"
} }
@@ -1,7 +1,7 @@
{ {
"name": "pi-extension-sandbox", "name": "pi-extension-sandbox",
"private": true, "private": true,
"version": "1.11.0", "version": "1.11.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "echo 'nothing to clean'", "clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{ {
"name": "pi-extension-with-deps", "name": "pi-extension-with-deps",
"version": "0.81.0", "version": "0.81.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pi-extension-with-deps", "name": "pi-extension-with-deps",
"version": "0.81.0", "version": "0.81.1",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -1,7 +1,7 @@
{ {
"name": "pi-extension-with-deps", "name": "pi-extension-with-deps",
"private": true, "private": true,
"version": "0.81.0", "version": "0.81.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "echo 'nothing to clean'", "clean": "echo 'nothing to clean'",
+15 -15
View File
@@ -1,14 +1,14 @@
{ {
"name": "@earendil-works/pi-coding-agent-install", "name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.0", "version": "0.81.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@earendil-works/pi-coding-agent-install", "name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.0", "version": "0.81.1",
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "0.81.0" "@earendil-works/pi-coding-agent": "0.81.1"
}, },
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"
@@ -450,11 +450,11 @@
} }
}, },
"node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-agent-core": {
"version": "0.81.0", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
@@ -465,8 +465,8 @@
} }
}, },
"node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-ai": {
"version": "0.81.0", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
@@ -489,13 +489,13 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent": { "node_modules/@earendil-works/pi-coding-agent": {
"version": "0.81.0", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.0", "@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -523,8 +523,8 @@
} }
}, },
"node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-tui": {
"version": "0.81.0", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-east-asian-width": "1.6.0", "get-east-asian-width": "1.6.0",
@@ -1,10 +1,10 @@
{ {
"name": "@earendil-works/pi-coding-agent-install", "name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.0", "version": "0.81.1",
"private": true, "private": true,
"description": "Lockfile root used by the Pi installer and updater.", "description": "Lockfile root used by the Pi installer and updater.",
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "0.81.0" "@earendil-works/pi-coding-agent": "0.81.1"
}, },
"overrides": { "overrides": {
"rimraf": "6.1.2", "rimraf": "6.1.2",
+12 -12
View File
@@ -1,17 +1,17 @@
{ {
"name": "@earendil-works/pi-coding-agent", "name": "@earendil-works/pi-coding-agent",
"version": "0.81.0", "version": "0.81.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@earendil-works/pi-coding-agent", "name": "@earendil-works/pi-coding-agent",
"version": "0.81.0", "version": "0.81.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.0", "@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -474,11 +474,11 @@
} }
}, },
"node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-agent-core": {
"version": "0.81.0", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
@@ -489,8 +489,8 @@
} }
}, },
"node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-ai": {
"version": "0.81.0", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
@@ -513,8 +513,8 @@
} }
}, },
"node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-tui": {
"version": "0.81.0", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-east-asian-width": "1.6.0", "get-east-asian-width": "1.6.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-coding-agent", "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", "description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module", "type": "module",
"piConfig": { "piConfig": {
@@ -39,9 +39,9 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.81.0", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.0", "@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -41,6 +41,7 @@ import {
isContextOverflow, isContextOverflow,
isRetryableAssistantError, isRetryableAssistantError,
modelsAreEqual, modelsAreEqual,
type RetryCallbacks,
resetApiProviders, resetApiProviders,
streamSimple, streamSimple,
} from "@earendil-works/pi-ai/compat"; } from "@earendil-works/pi-ai/compat";
@@ -161,7 +162,21 @@ export type AgentSessionEvent =
errorMessage?: string; errorMessage?: string;
} }
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; 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 */ /** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void; export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -1838,6 +1853,8 @@ export class AgentSession {
this.thinkingLevel, this.thinkingLevel,
this.agent.streamFunction, this.agent.streamFunction,
env, env,
this.settingsManager.getRetrySettings(),
this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),
); );
summary = result.summary; summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId; firstKeptEntryId = result.firstKeptEntryId;
@@ -2114,6 +2131,8 @@ export class AgentSession {
this.thinkingLevel, this.thinkingLevel,
this.agent.streamFunction, this.agent.streamFunction,
env, env,
this.settingsManager.getRetrySettings(),
this._summarizationRetryCallbacks({ source: "compaction", reason }),
); );
summary = compactResult.summary; summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId; firstKeptEntryId = compactResult.firstKeptEntryId;
@@ -2620,6 +2639,37 @@ export class AgentSession {
return isRetryableAssistantError(message); 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. * Prepare a retryable error for continuation with exponential backoff.
* @returns true if the caller should continue the agent, false otherwise * @returns true if the caller should continue the agent, false otherwise
@@ -2934,6 +2984,8 @@ export class AgentSession {
replaceInstructions, replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens, reserveTokens: branchSummarySettings.reserveTokens,
streamFn: this.agent.streamFunction, streamFn: this.agent.streamFunction,
retry: this.settingsManager.getRetrySettings(),
callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
}); });
if (result.aborted) { if (result.aborted) {
return { cancelled: true, aborted: true }; return { cancelled: true, aborted: true };
@@ -6,9 +6,9 @@
*/ */
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; 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 { contentText } from "@earendil-works/pi-ai";
import type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import { import {
convertToLlm, convertToLlm,
createBranchSummaryMessage, createBranchSummaryMessage,
@@ -16,7 +16,7 @@ import {
createCustomMessage, createCustomMessage,
} from "../messages.ts"; } from "../messages.ts";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
import { estimateTokens } from "./compaction.ts"; import { completeSummarization, estimateTokens } from "./compaction.ts";
import { import {
computeFileLists, computeFileLists,
createFileOps, createFileOps,
@@ -83,6 +83,10 @@ export interface GenerateBranchSummaryOptions {
reserveTokens?: number; reserveTokens?: number;
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */ /** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
streamFn?: StreamFn; 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, replaceInstructions,
reserveTokens = 16384, reserveTokens = 16384,
streamFn, streamFn,
retry,
callbacks,
} = options; } = options;
// Token budget = context window minus reserved space for prompt + response // 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 // Call LLM for summarization. Prefer the session stream function so SDK
// request behavior (timeouts, retries, attribution headers) stays consistent // 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 context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 }; const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
const response = streamFn const response = await completeSummarization(model, context, requestOptions, streamFn, retry, callbacks);
? await (await streamFn(model, context, requestOptions)).result()
: await completeSimple(model, context, requestOptions);
// Check if aborted or errored // Check if aborted or errored
if (response.stopReason === "aborted") { if (response.stopReason === "aborted") {
@@ -6,7 +6,7 @@
*/ */
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; 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 type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat";
import { convertToLlm } from "../messages.ts"; import { convertToLlm } from "../messages.ts";
@@ -552,17 +552,24 @@ function createSummarizationOptions(
return options; 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>, model: Model<any>,
context: Context, context: Context,
options: SimpleStreamOptions, options: SimpleStreamOptions,
streamFn?: StreamFn, streamFn?: StreamFn,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> { ): Promise<AssistantMessage> {
if (!streamFn) { const produce = async (): Promise<AssistantMessage> =>
return completeSimple(model, context, options); streamFn ? (await streamFn(model, context, options)).result() : completeSimple(model, context, options);
} return retryAssistantCall(produce, retry, options.signal, callbacks);
const stream = await streamFn(model, context, options);
return stream.result();
} }
/** /**
@@ -581,6 +588,8 @@ export async function generateSummary(
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn, streamFn?: StreamFn,
env?: Record<string, string>, env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<string> { ): Promise<string> {
return ( return (
await generateSummaryWithUsage( await generateSummaryWithUsage(
@@ -595,6 +604,8 @@ export async function generateSummary(
thinkingLevel, thinkingLevel,
streamFn, streamFn,
env, env,
retry,
callbacks,
) )
).text; ).text;
} }
@@ -612,6 +623,8 @@ export async function generateSummaryWithUsage(
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn, streamFn?: StreamFn,
env?: Record<string, string>, env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<{ text: string; usage: Usage }> { ): Promise<{ text: string; usage: Usage }> {
const maxTokens = Math.min( const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens), Math.floor(0.8 * reserveTokens),
@@ -651,6 +664,8 @@ export async function generateSummaryWithUsage(
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions, completionOptions,
streamFn, streamFn,
retry,
callbacks,
); );
if (response.stopReason === "error") { if (response.stopReason === "error") {
@@ -801,6 +816,8 @@ export async function compact(
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn, streamFn?: StreamFn,
env?: Record<string, string>, env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<CompactionResult> { ): Promise<CompactionResult> {
const { const {
firstKeptEntryId, firstKeptEntryId,
@@ -833,6 +850,8 @@ export async function compact(
thinkingLevel, thinkingLevel,
streamFn, streamFn,
env, env,
retry,
callbacks,
); );
historyText = historyResult.text; historyText = historyResult.text;
historyUsage = historyResult.usage; historyUsage = historyResult.usage;
@@ -847,6 +866,8 @@ export async function compact(
signal, signal,
thinkingLevel, thinkingLevel,
streamFn, streamFn,
retry,
callbacks,
); );
// Merge into single summary // Merge into single summary
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;
@@ -865,6 +886,8 @@ export async function compact(
thinkingLevel, thinkingLevel,
streamFn, streamFn,
env, env,
retry,
callbacks,
); );
summary = result.text; summary = result.text;
summaryUsage = result.usage; summaryUsage = result.usage;
@@ -900,6 +923,8 @@ async function generateTurnPrefixSummary(
signal?: AbortSignal, signal?: AbortSignal,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn, streamFn?: StreamFn,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<{ text: string; usage: Usage }> { ): Promise<{ text: string; usage: Usage }> {
const maxTokens = Math.min( const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens), Math.floor(0.5 * reserveTokens),
@@ -921,6 +946,8 @@ async function generateTurnPrefixSummary(
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
streamFn, streamFn,
retry,
callbacks,
); );
if (response.stopReason === "error") { if (response.stopReason === "error") {
+8 -3
View File
@@ -1,6 +1,6 @@
import { join } from "node:path"; import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core"; import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model } from "@earendil-works/pi-ai/compat"; import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";
import { getAgentDir } from "../config.ts"; import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts"; import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts"; import { AgentSession } from "./agent-session.ts";
@@ -30,6 +30,11 @@ import {
withFileMutationQueue, withFileMutationQueue,
} from "./tools/index.ts"; } 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 { export interface CreateAgentSessionOptions {
/** Working directory for project-local discovery. Default: process.cwd() */ /** Working directory for project-local discovery. Default: process.cwd() */
cwd?: string; cwd?: string;
@@ -294,7 +299,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
tools: [], tools: [],
}, },
convertToLlm: convertToLlmWithBlockImages, convertToLlm: convertToLlmWithBlockImages,
streamFunction: async (model, context, options) => { streamFn: async (model, context, options) => {
const providerRetrySettings = settingsManager.getProviderRetrySettings(); const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". // 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); 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(() => {}); void modelRuntime.refresh().catch(() => {});
} }
@@ -826,6 +826,13 @@ export class InteractiveMode {
async run(): Promise<void> { async run(): Promise<void> {
await this.init(); await this.init();
if (!process.env.PI_OFFLINE) {
void this.session.modelRuntime
.refresh()
.then(() => this.updateAvailableProviderCount())
.catch(() => {});
}
// Start version check asynchronously // Start version check asynchronously
checkForNewPiVersion(this.version).then((newRelease) => { checkForNewPiVersion(this.version).then((newRelease) => {
if (newRelease) { if (newRelease) {
@@ -3110,6 +3117,32 @@ export class InteractiveMode {
this.ui.requestRender(); this.ui.requestRender();
break; 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 */ /** Update the footer's available provider count from the current snapshot without refreshing catalogs. */
private async updateAvailableProviderCount(): Promise<void> { private updateAvailableProviderCount(): void {
const models = await this.getModelCandidates(); const models =
const uniqueProviders = new Set(models.map((m) => m.provider)); 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); this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size);
} }
@@ -24,7 +24,7 @@ describe("AgentSession auto-compaction queue resume", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!; const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "Test", systemPrompt: "Test",
@@ -49,7 +49,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!; const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({ const agent = new Agent({
getApiKey: () => API_KEY, getApiKey: () => API_KEY,
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant. Be concise.", systemPrompt: "You are a helpful assistant. Be concise.",
@@ -90,7 +90,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFunction: (_model, _context, options) => { streamFn: (_model, _context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFunction: (_model, context, options) => { streamFn: (_model, context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFunction: () => { streamFn: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") }); stream.push({ type: "start", partial: createAssistantMessage("") });
@@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [tool], tools: [tool],
}, },
streamFunction: async (_model, context) => { streamFn: async (_model, context) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length; const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
@@ -508,7 +508,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [tool], tools: [tool],
}, },
streamFunction: async (_model, context) => { streamFn: async (_model, context) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
const hasToolResult = context.messages.some((message) => message.role === "toolResult"); const hasToolResult = context.messages.some((message) => message.role === "toolResult");
@@ -82,7 +82,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({ const agent = new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] }, initialState: { model, systemPrompt: "Test", tools: [] },
streamFunction: () => { streamFn: () => {
callCount++; callCount++;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -203,7 +203,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({ const agent = new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] }, initialState: { model, systemPrompt: "Test", tools: [] },
streamFunction: streamFn, streamFn: streamFn,
}); });
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
@@ -255,7 +255,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({ const agent = new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] }, initialState: { model, systemPrompt: "Test", tools: [] },
streamFunction: () => { streamFn: () => {
callCount++; callCount++;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -75,7 +75,7 @@ async function createSession() {
const session = new AgentSession({ const session = new AgentSession({
agent: new Agent({ agent: new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant.", 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 model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({ const agent = new Agent({
getApiKey: () => API_KEY, getApiKey: () => API_KEY,
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant. Be concise.", 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")).toBe(false);
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true); expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
expect(anthropicModels.some((m) => m.id.includes("claude"))).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 () => { test("removing custom models from models.json keeps built-in provider models", async () => {
writeModelsJson({ writeModelsJson({
@@ -114,7 +114,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs:
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFunction: (_model, _context, _options) => { streamFn: (_model, _context, _options) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") }); 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({ const agent = new Agent({
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined), getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
streamFunction: streamSimple, streamFn: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: options.systemPrompt ?? "You are a test assistant.", systemPrompt: options.systemPrompt ?? "You are a test assistant.",
@@ -61,7 +61,7 @@ describe("regression #5596: missing configured theme export", () => {
tools: [], tools: [],
}, },
convertToLlm, convertToLlm,
streamFunction: streamSimple, streamFn: streamSimple,
}); });
const session = new AgentSession({ const session = new AgentSession({
agent, 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.", systemPrompt: options.systemPrompt ?? "You are a test assistant.",
tools: options.tools ?? [], tools: options.tools ?? [],
}, },
streamFunction: streamFn, streamFn: streamFn,
}); });
const sessionManager = SessionManager.inMemory(); 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.", systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.",
tools: createCodingTools(process.cwd()), tools: createCodingTools(process.cwd()),
}, },
streamFunction: streamSimple, streamFn: streamSimple,
}); });
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir); const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
+2
View File
@@ -2,6 +2,8 @@
## [Unreleased] ## [Unreleased]
## [0.81.1] - 2026-07-21
## [0.81.0] - 2026-07-21 ## [0.81.0] - 2026-07-21
### Changed ### Changed
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-server", "name": "@earendil-works/pi-server",
"version": "0.81.0", "version": "0.81.1",
"description": "experimental server package for pi", "description": "experimental server package for pi",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -40,7 +40,7 @@
"node": ">=22.19.0" "node": ">=22.19.0"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.81.0" "@earendil-works/pi-coding-agent": "^0.81.1"
}, },
"devDependencies": { "devDependencies": {
"shx": "0.4.0" "shx": "0.4.0"
@@ -2,6 +2,8 @@
## [Unreleased] ## [Unreleased]
## [0.81.1] - 2026-07-21
## [0.81.0] - 2026-07-21 ## [0.81.0] - 2026-07-21
### Added ### Added
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-storage-sqlite-node", "name": "@earendil-works/pi-storage-sqlite-node",
"version": "0.81.0", "version": "0.81.1",
"description": "Node sqlite storage backend for @earendil-works/pi-agent-core sessions", "description": "Node sqlite storage backend for @earendil-works/pi-agent-core sessions",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -32,7 +32,7 @@
"node": ">=22.19.0" "node": ">=22.19.0"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-agent-core": "^0.81.0" "@earendil-works/pi-agent-core": "^0.81.1"
} }
} }
+2
View File
@@ -2,6 +2,8 @@
## [Unreleased] ## [Unreleased]
## [0.81.1] - 2026-07-21
## [0.81.0] - 2026-07-21 ## [0.81.0] - 2026-07-21
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-tui", "name": "@earendil-works/pi-tui",
"version": "0.81.0", "version": "0.81.1",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications", "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+1 -1
View File
@@ -9,5 +9,5 @@ if (!model) throw new Error("Anthropic smoke-test model not found");
export const agent = new Agent({ export const agent = new Agent({
initialState: { model }, initialState: { model },
streamFunction: models.streamSimple.bind(models), streamFn: models.streamSimple.bind(models),
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ const model = getModel("google", "gemini-2.5-flash");
const schema = Type.Object({ prompt: Type.String() }); const schema = Type.Object({ prompt: Type.String() });
const stream = createAssistantMessageEventStream(); const stream = createAssistantMessageEventStream();
const agent = new Agent({ initialState: { model }, streamFunction: streamSimple }); const agent = new Agent({ initialState: { model }, streamFn: streamSimple });
agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 }); agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 });
const repo = new InMemorySessionRepo(); const repo = new InMemorySessionRepo();
const result = getOrThrow(ok({ value: 1 })); const result = getOrThrow(ok({ value: 1 }));
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Create the deterministic source archive uploaded with GitHub releases.
#
# Usage:
# ./scripts/create-source-archive.sh --version <version> --ref <git-ref> --out <archive.tar.gz>
set -euo pipefail
version=""
source_ref="HEAD"
output=""
invocation_dir="$PWD"
usage() {
echo "Usage: $0 --version <version> [--ref <git-ref>] --out <archive.tar.gz>"
}
require_value() {
if [[ $# -lt 2 || -z "$2" ]]; then
echo "$1 requires a value" >&2
usage >&2
exit 1
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
require_value "$@"
version="$2"
shift 2
;;
--ref)
require_value "$@"
source_ref="$2"
shift 2
;;
--out)
require_value "$@"
output="$2"
shift 2
;;
--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [[ -z "$version" || -z "$output" ]]; then
usage >&2
exit 1
fi
if [[ ! "$version" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then
echo "Invalid version: $version" >&2
exit 1
fi
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
commit="$(git rev-parse --verify --end-of-options "${source_ref}^{commit}")"
package_version="$(git show "${commit}:packages/coding-agent/package.json" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version')"
if [[ "$package_version" != "$version" ]]; then
echo "Version ${version} does not match package version ${package_version} at ${source_ref}" >&2
exit 1
fi
if [[ "$output" != /* ]]; then
output="$invocation_dir/$output"
fi
mkdir -p "$(dirname "$output")"
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
temporary_archive="$(mktemp "${output}.tmp.XXXXXX")"
manifest="$(mktemp "${output}.manifest.XXXXXX")"
trap 'rm -f "$temporary_archive" "$manifest"' EXIT
archive_root="pi-${version}"
git archive --format=tar --prefix="${archive_root}/" "$commit" | gzip -n -9 > "$temporary_archive"
tar -tzf "$temporary_archive" > "$manifest"
required_paths=(
"package.json"
"package-lock.json"
"scripts/build-binaries.sh"
"packages/coding-agent/package.json"
"packages/coding-agent/src/utils/image-resize-worker.ts"
"packages/coding-agent/src/core/export-html/template.css"
)
for path in "${required_paths[@]}"; do
if ! grep -Fxq "${archive_root}/${path}" "$manifest"; then
echo "Source archive is missing required path: $path" >&2
exit 1
fi
done
if ! awk -v prefix="${archive_root}/" 'index($0, prefix) != 1 { exit 1 }' "$manifest"; then
echo "Source archive contains a path outside ${archive_root}/" >&2
exit 1
fi
if grep -Eq '(^|/)node_modules/|(^|/)packages/coding-agent/binaries/' "$manifest"; then
echo "Source archive contains generated dependencies or binaries" >&2
exit 1
fi
mv "$temporary_archive" "$output"
trap 'rm -f "$manifest"' EXIT
printf '%s\n' "$output"
+76 -10
View File
@@ -1,19 +1,22 @@
#!/usr/bin/env node #!/usr/bin/env node
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
function printUsage() { function printUsage() {
console.log(`Usage: node scripts/diff-model-catalog.mjs [provider ...] console.log(`Usage: node scripts/diff-model-catalog.mjs [--thinking] [provider ...]
Generates the model catalog at HEAD and in the current worktree, then shows Generates the model catalog at HEAD and in the current worktree, then shows
JSON differences. If providers are omitted, all providers are compared. JSON differences. If providers are omitted, all providers are compared.
--thinking compares each worktree's effective thinking levels using that
worktree's getSupportedThinkingLevels() implementation.
Examples: Examples:
node scripts/diff-model-catalog.mjs github-copilot node scripts/diff-model-catalog.mjs github-copilot
npm run diff:model-catalog -- github-copilot npm run diff:model-catalog -- --thinking moonshotai kimi-coding
`); `);
} }
@@ -43,7 +46,9 @@ if (args.includes("--help")) {
printUsage(); printUsage();
process.exit(0); process.exit(0);
} }
if (args.some((arg) => arg.startsWith("-"))) { const thinkingOnly = args.includes("--thinking");
const requestedProviders = args.filter((arg) => arg !== "--thinking");
if (requestedProviders.some((arg) => arg.startsWith("-"))) {
printUsage(); printUsage();
process.exit(1); process.exit(1);
} }
@@ -53,6 +58,8 @@ const temporaryRoot = mkdtempSync(join(tmpdir(), "pi-model-catalog-diff-"));
const baselineWorktree = join(temporaryRoot, "baseline-worktree"); const baselineWorktree = join(temporaryRoot, "baseline-worktree");
const baselineOutput = join(temporaryRoot, "before"); const baselineOutput = join(temporaryRoot, "before");
const currentOutput = join(temporaryRoot, "after"); const currentOutput = join(temporaryRoot, "after");
const baselineThinkingOutput = join(temporaryRoot, "before-thinking");
const currentThinkingOutput = join(temporaryRoot, "after-thinking");
let worktreeAdded = false; let worktreeAdded = false;
function generateCatalog(cwd, outputDir, pretty = false) { function generateCatalog(cwd, outputDir, pretty = false) {
@@ -76,8 +83,54 @@ function readProviderCatalog(outputDir, provider) {
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined; return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined;
} }
function generateThinkingCatalog(cwd, catalogPath, outputDir) {
run(process.execPath, ["scripts/generate-thinking-capabilities.mjs", catalogPath, outputDir], {
cwd,
capture: true,
});
}
const THINKING_LEVEL_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
const THINKING_LEVEL_RANKS = new Map(THINKING_LEVEL_ORDER.map((key, index) => [key, index]));
function sortJsonKeys(keys, parentKey) {
if (parentKey !== "thinkingLevelMap" && parentKey !== "values") return keys.sort();
return keys.sort((left, right) => {
const leftRank = THINKING_LEVEL_RANKS.get(left) ?? Number.POSITIVE_INFINITY;
const rightRank = THINKING_LEVEL_RANKS.get(right) ?? Number.POSITIVE_INFINITY;
return leftRank - rightRank || left.localeCompare(right);
});
}
function canonicalizeJson(value, parentKey) {
if (Array.isArray(value)) return value.map((entry) => canonicalizeJson(entry));
if (value === null || typeof value !== "object") return value;
const result = {};
for (const key of sortJsonKeys(Object.keys(value), parentKey)) {
result[key] = canonicalizeJson(value[key], key);
}
return result;
}
function formatJsonForDiff(value, indent = "") {
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
const childIndent = `${indent} `;
return `[\n${value.map((entry) => `${childIndent}${formatJsonForDiff(entry, childIndent)},`).join("\n")}\n${indent}]`;
}
if (value === null || typeof value !== "object") return JSON.stringify(value);
const entries = Object.entries(value);
if (entries.length === 0) return "{}";
const childIndent = `${indent} `;
return `{\n${entries
.map(([key, entry]) => `${childIndent}${JSON.stringify(key)}: ${formatJsonForDiff(entry, childIndent)},`)
.join("\n")}\n${indent}}`;
}
function writeModelSnapshot(path, model) { function writeModelSnapshot(path, model) {
writeFileSync(path, model === undefined ? "" : `${JSON.stringify(model, null, 2)}\n`); writeFileSync(path, model === undefined ? "" : `${formatJsonForDiff(canonicalizeJson(model))}\n`);
} }
function writeChangedLines(output) { function writeChangedLines(output) {
@@ -94,6 +147,10 @@ function writeChangedLines(output) {
try { try {
run("git", ["worktree", "add", "--detach", baselineWorktree, "HEAD"], { cwd: repoRoot }); run("git", ["worktree", "add", "--detach", baselineWorktree, "HEAD"], { cwd: repoRoot });
worktreeAdded = true; worktreeAdded = true;
copyFileSync(
join(repoRoot, "scripts", "generate-thinking-capabilities.mjs"),
join(baselineWorktree, "scripts", "generate-thinking-capabilities.mjs"),
);
const nodeModules = join(repoRoot, "node_modules"); const nodeModules = join(repoRoot, "node_modules");
if (existsSync(nodeModules)) { if (existsSync(nodeModules)) {
@@ -107,17 +164,26 @@ try {
generateCatalog(repoRoot, currentOutput, true); generateCatalog(repoRoot, currentOutput, true);
formatProviderCatalogs(currentOutput); formatProviderCatalogs(currentOutput);
if (thinkingOnly) {
console.log("Computing effective thinking capabilities...");
generateThinkingCatalog(baselineWorktree, join(baselineOutput, "models.json"), baselineThinkingOutput);
generateThinkingCatalog(repoRoot, join(currentOutput, "models.json"), currentThinkingOutput);
}
const beforeProviders = JSON.parse(readFileSync(join(baselineOutput, "providers.json"), "utf8")); const beforeProviders = JSON.parse(readFileSync(join(baselineOutput, "providers.json"), "utf8"));
const afterProviders = JSON.parse(readFileSync(join(currentOutput, "providers.json"), "utf8")); const afterProviders = JSON.parse(readFileSync(join(currentOutput, "providers.json"), "utf8"));
const providers = args.length > 0 ? args : [...new Set([...beforeProviders, ...afterProviders])].sort(); const providers =
requestedProviders.length > 0 ? requestedProviders : [...new Set([...beforeProviders, ...afterProviders])].sort();
const beforeCatalogOutput = thinkingOnly ? baselineThinkingOutput : baselineOutput;
const currentCatalogOutput = thinkingOnly ? currentThinkingOutput : currentOutput;
const beforeModelPath = "before-model.json"; const beforeModelPath = "before-model.json";
const afterModelPath = "after-model.json"; const afterModelPath = "after-model.json";
const changedModels = []; const changedModels = [];
let differences = 0; let differences = 0;
for (const provider of providers) { for (const provider of providers) {
const beforeModels = readProviderCatalog(baselineOutput, provider); const beforeModels = readProviderCatalog(beforeCatalogOutput, provider);
const afterModels = readProviderCatalog(currentOutput, provider); const afterModels = readProviderCatalog(currentCatalogOutput, provider);
if (beforeModels === undefined && afterModels === undefined) { if (beforeModels === undefined && afterModels === undefined) {
throw new Error(`Unknown provider: ${provider}`); throw new Error(`Unknown provider: ${provider}`);
} }
@@ -126,7 +192,7 @@ try {
for (const modelId of modelIds) { for (const modelId of modelIds) {
const beforeModel = beforeModels?.[modelId]; const beforeModel = beforeModels?.[modelId];
const afterModel = afterModels?.[modelId]; const afterModel = afterModels?.[modelId];
if (JSON.stringify(beforeModel) === JSON.stringify(afterModel)) continue; if (JSON.stringify(canonicalizeJson(beforeModel)) === JSON.stringify(canonicalizeJson(afterModel))) continue;
writeModelSnapshot(join(temporaryRoot, beforeModelPath), beforeModel); writeModelSnapshot(join(temporaryRoot, beforeModelPath), beforeModel);
writeModelSnapshot(join(temporaryRoot, afterModelPath), afterModel); writeModelSnapshot(join(temporaryRoot, afterModelPath), afterModel);
@@ -156,7 +222,7 @@ try {
} }
if (differences === 0) { if (differences === 0) {
console.log(`No model catalog changes${args.length === 1 ? ` for ${args[0]}` : ""}.`); console.log(`No model catalog changes${requestedProviders.length === 1 ? ` for ${requestedProviders[0]}` : ""}.`);
} else { } else {
console.log(`\n${differences} model catalog entr${differences === 1 ? "y" : "ies"} changed.`); console.log(`\n${differences} model catalog entr${differences === 1 ? "y" : "ies"} changed.`);
for (const changedModel of changedModels) { for (const changedModel of changedModels) {
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getSupportedThinkingLevels } from "../packages/ai/src/models.ts";
const [catalogPath, outputDir] = process.argv.slice(2);
if (!catalogPath || !outputDir) {
throw new Error("Usage: node scripts/generate-thinking-capabilities.mjs <catalog-path> <output-dir>");
}
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
const providersDir = join(outputDir, "providers");
mkdirSync(providersDir, { recursive: true });
for (const [provider, models] of Object.entries(catalog)) {
const capabilities = Object.fromEntries(
Object.entries(models).map(([id, model]) => {
const levels = getSupportedThinkingLevels(model);
const values = Object.fromEntries(
levels.flatMap((level) => {
const value = model.thinkingLevelMap?.[level];
return value !== undefined && value !== level ? [[level, value]] : [];
}),
);
return [id, Object.keys(values).length > 0 ? { levels, values } : { levels }];
}),
);
writeFileSync(join(providersDir, `${provider}.json`), JSON.stringify(capabilities));
}