diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index c69fbdba..7ed45d03 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -287,3 +287,5 @@ QuintinShaw pr R-Taneja pr zaycruz pr + +mteam88 pr diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 93e781d8..f646791c 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -49,8 +49,27 @@ jobs: node-version: '22' registry-url: 'https://registry.npmjs.org' - - name: Build binaries - run: ./scripts/build-binaries.sh + - name: Create source archive + 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 run: | @@ -83,7 +102,9 @@ jobs: cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/" cd "${GITHUB_WORKSPACE}/release-assets" + source_asset="pi-${VERSION}-source.tar.gz" release_assets=( + "${source_asset}" pi-darwin-arm64.tar.gz pi-darwin-x64.tar.gz pi-linux-x64.tar.gz @@ -125,7 +146,10 @@ jobs: cd release-assets + VERSION="${RELEASE_TAG#v}" + source_asset="pi-${VERSION}-source.tar.gz" expected_assets=( + "${source_asset}" pi-darwin-arm64.tar.gz pi-darwin-x64.tar.gz pi-linux-x64.tar.gz @@ -144,7 +168,7 @@ jobs: sha256sum -c SHA256SUMS - - name: Create draft GitHub Release and upload binaries + - name: Create draft GitHub Release and upload assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -152,7 +176,10 @@ jobs: cd release-assets + VERSION="${RELEASE_TAG#v}" + source_asset="pi-${VERSION}-source.tar.gz" release_assets=( + "${source_asset}" pi-darwin-arm64.tar.gz pi-darwin-x64.tar.gz pi-linux-x64.tar.gz diff --git a/README.md b/README.md index 900779b0..f140437d 100644 --- a/README.md +++ b/README.md @@ -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) ``` +## 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="" +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 We treat npm dependency changes as reviewed code changes. diff --git a/package-lock.json b/package-lock.json index ec3248f7..e0a31b45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5041,10 +5041,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.81.0", + "version": "0.81.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.81.0", + "@earendil-works/pi-ai": "^0.81.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.1.38", @@ -5394,7 +5394,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.81.0", + "version": "0.81.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -5700,12 +5700,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.81.0", + "version": "0.81.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.81.0", - "@earendil-works/pi-ai": "^0.81.0", - "@earendil-works/pi-tui": "^0.81.0", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-tui": "^0.81.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5746,32 +5746,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.81.0", + "version": "0.81.1", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/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": { "name": "pi-extension-gondolin", - "version": "0.81.0", + "version": "0.81.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.11.0", + "version": "1.11.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.81.0", + "version": "0.81.1", "dependencies": { "ms": "2.1.3" }, @@ -6067,10 +6067,10 @@ }, "packages/server": { "name": "@earendil-works/pi-server", - "version": "0.81.0", + "version": "0.81.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-coding-agent": "^0.81.0" + "@earendil-works/pi-coding-agent": "^0.81.1" }, "bin": { "server": "dist/cli.js" @@ -6084,11 +6084,11 @@ }, "packages/storage/sqlite-node": { "name": "@earendil-works/pi-storage-sqlite-node", - "version": "0.81.0", + "version": "0.81.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.81.0", - "@earendil-works/pi-ai": "^0.81.0" + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1" }, "engines": { "node": ">=22.19.0" @@ -6096,7 +6096,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.81.0", + "version": "0.81.1", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e9e4b11b..e70ecaf1 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -10,6 +10,16 @@ - 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 ### Breaking Changes diff --git a/packages/agent/README.md b/packages/agent/README.md index 543f4c8f..a4d5fdd9 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -29,7 +29,7 @@ const agent = new Agent({ systemPrompt: "You are a helpful assistant.", model, }, - streamFunction: models.streamSimple.bind(models), + streamFn: models.streamSimple.bind(models), }); agent.subscribe((event) => { @@ -199,7 +199,7 @@ const agent = new Agent({ followUpMode: "one-at-a-time", // Required stream function - streamFunction: models.streamSimple.bind(models), + streamFn: models.streamSimple.bind(models), // Session ID for provider caching sessionId: "session-123", @@ -386,7 +386,7 @@ Handle custom types in `convertToLlm`: ```typescript const agent = new Agent({ - streamFunction: models.streamSimple.bind(models), + streamFn: models.streamSimple.bind(models), convertToLlm: (messages) => messages.flatMap(m => { if (m.role === "notification") return []; // Filter out return [m]; @@ -457,7 +457,7 @@ For browser apps that proxy through a backend: import { Agent, streamProxy } from "@earendil-works/pi-agent-core"; const agent = new Agent({ - streamFunction: (model, context, options) => + streamFn: (model, context, options) => streamProxy(model, context, { ...options, authToken: "...", @@ -489,13 +489,13 @@ const config: AgentLoopConfig = { const userMessage = { role: "user", content: "Hello", timestamp: Date.now() }; -const streamFunction = models.streamSimple.bind(models); -for await (const event of agentLoop([userMessage], context, config, undefined, streamFunction)) { +const streamFn = models.streamSimple.bind(models); +for await (const event of agentLoop([userMessage], context, config, undefined, streamFn)) { console.log(event.type); } // 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); } ``` diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md index 6b48c803..7c8d49c3 100644 --- a/packages/agent/docs/agent-harness.md +++ b/packages/agent/docs/agent-harness.md @@ -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. +### 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 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. diff --git a/packages/agent/package.json b/packages/agent/package.json index 4473d6bd..c81900be 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "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", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.81.0", + "@earendil-works/pi-ai": "^0.81.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.1.38", diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index fff06996..3a12506c 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -10,6 +10,7 @@ import { type ToolResultMessage, validateToolArguments, } from "@earendil-works/pi-ai"; +import { getDefaultStreamFn } from "./stream-fn.ts"; import type { AgentContext, AgentEvent, @@ -32,7 +33,7 @@ export function agentLoop( context: AgentContext, config: AgentLoopConfig, signal: AbortSignal | undefined, - streamFunction: StreamFn, + streamFn: StreamFn, ): EventStream { const stream = createAgentStream(); @@ -44,7 +45,7 @@ export function agentLoop( stream.push(event); }, signal, - streamFunction, + streamFn, ).then((messages) => { stream.end(messages); }); @@ -64,7 +65,7 @@ export function agentLoopContinue( context: AgentContext, config: AgentLoopConfig, signal: AbortSignal | undefined, - streamFunction: StreamFn, + streamFn: StreamFn, ): EventStream { if (context.messages.length === 0) { throw new Error("Cannot continue: no messages in context"); @@ -83,7 +84,7 @@ export function agentLoopContinue( stream.push(event); }, signal, - streamFunction, + streamFn, ).then((messages) => { stream.end(messages); }); @@ -97,7 +98,7 @@ export async function runAgentLoop( config: AgentLoopConfig, emit: AgentEventSink, signal: AbortSignal | undefined, - streamFunction: StreamFn, + streamFn: StreamFn, ): Promise { const newMessages: AgentMessage[] = [...prompts]; const currentContext: AgentContext = { @@ -112,7 +113,7 @@ export async function runAgentLoop( 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; } @@ -121,7 +122,7 @@ export async function runAgentLoopContinue( config: AgentLoopConfig, emit: AgentEventSink, signal: AbortSignal | undefined, - streamFunction: StreamFn, + streamFn: StreamFn, ): Promise { if (context.messages.length === 0) { 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: "turn_start" }); - await runLoop(currentContext, newMessages, config, signal, emit, streamFunction); + await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn()); return newMessages; } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 653adf8e..147edc83 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -8,6 +8,7 @@ import type { Transport, } from "@earendil-works/pi-ai"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; +import { getDefaultStreamFn } from "./stream-fn.ts"; import type { AfterToolCallContext, AfterToolCallResult, @@ -97,7 +98,7 @@ export interface AgentOptions { initialState?: Partial>; convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - streamFunction: StreamFn; + streamFn: StreamFn; getApiKey?: (provider: string) => Promise | string | undefined; onPayload?: SimpleStreamOptions["onPayload"]; onResponse?: SimpleStreamOptions["onResponse"]; @@ -207,24 +208,26 @@ export class Agent { public toolExecution: ToolExecutionMode; constructor(options: AgentOptions) { - this._state = createMutableAgentState(options.initialState); - this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; - this.transformContext = options.transformContext; - this.streamFunction = options.streamFunction; - this.getApiKey = options.getApiKey; - this.onPayload = options.onPayload; - this.onResponse = options.onResponse; - this.beforeToolCall = options.beforeToolCall; - this.afterToolCall = options.afterToolCall; - this.prepareNextTurn = options.prepareNextTurn; - this.prepareNextTurnWithContext = options.prepareNextTurnWithContext; - this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); - this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); - this.sessionId = options.sessionId; - this.thinkingBudgets = options.thinkingBudgets; - this.transport = options.transport ?? "auto"; - this.maxRetryDelayMs = options.maxRetryDelayMs; - this.toolExecution = options.toolExecution ?? "parallel"; + // Older compiled consumers may omit options or streamFn even though the current API requires them. + const runtimeOptions: Partial = options ?? {}; + this._state = createMutableAgentState(runtimeOptions.initialState); + this.convertToLlm = runtimeOptions.convertToLlm ?? defaultConvertToLlm; + this.transformContext = runtimeOptions.transformContext; + this.streamFunction = runtimeOptions.streamFn ?? getDefaultStreamFn(); + this.getApiKey = runtimeOptions.getApiKey; + this.onPayload = runtimeOptions.onPayload; + this.onResponse = runtimeOptions.onResponse; + this.beforeToolCall = runtimeOptions.beforeToolCall; + this.afterToolCall = runtimeOptions.afterToolCall; + this.prepareNextTurn = runtimeOptions.prepareNextTurn; + this.prepareNextTurnWithContext = runtimeOptions.prepareNextTurnWithContext; + this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time"); + this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time"); + this.sessionId = runtimeOptions.sessionId; + this.thinkingBudgets = runtimeOptions.thinkingBudgets; + this.transport = runtimeOptions.transport ?? "auto"; + this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs; + this.toolExecution = runtimeOptions.toolExecution ?? "parallel"; } /** diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 032ea6e4..85405a5d 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -4,6 +4,8 @@ import { type ImageContent, type Model, type Models, + type RetryCallbacks, + type RetryPolicy, type UserMessage, } from "@earendil-works/pi-ai"; import { runAgentLoop } from "../agent-loop.ts"; @@ -182,6 +184,7 @@ export class AgentHarness< private systemPrompt: AgentHarnessOptions["systemPrompt"]; private toolContext: AgentHarnessToolContextSource | undefined; private streamOptions: AgentHarnessStreamOptions; + private retry: RetryPolicy | undefined; private resources: AgentHarnessResources; private tools = new Map(); private activeToolNames: string[]; @@ -197,6 +200,7 @@ export class AgentHarness< this.models = options.models; this.resources = options.resources ?? {}; this.streamOptions = cloneStreamOptions(options.streamOptions); + this.retry = options.retry; this.systemPrompt = options.systemPrompt; this.toolContext = options.toolContext; this.validateUniqueNames( @@ -260,6 +264,15 @@ export class AgentHarness< 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( model: Model, sessionId: string, @@ -741,7 +754,16 @@ export class AgentHarness< const provided = hookResult?.compaction; const compactResult = 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; const result = compactResult.value; const entryId = await this.session.appendCompaction( @@ -803,6 +825,8 @@ export class AgentHarness< signal: new AbortController().signal, customInstructions: hookResult?.customInstructions ?? options?.customInstructions, replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, + retry: this.retry, + callbacks: this.retryCallbacks("branch_summary"), }); if (!branchSummary.ok) { if (branchSummary.error.code === "aborted") return { cancelled: true }; diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index ab56f3ac..51683e09 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -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 { @@ -9,7 +9,7 @@ import { } from "../messages.ts"; import type { BranchSummaryResult, Session, SessionTreeEntry } 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 { computeFileLists, createFileOps, @@ -61,6 +61,10 @@ export interface GenerateBranchSummaryOptions { replaceInstructions?: boolean; /** Tokens reserved for prompt and model output. Defaults to 16384. */ 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. */ @@ -200,7 +204,16 @@ export async function generateBranchSummary( entries: SessionTreeEntry[], options: GenerateBranchSummaryOptions, ): Promise> { - 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 tokenBudget = contextWindow - reserveTokens; @@ -228,10 +241,13 @@ export async function generateBranchSummary( timestamp: Date.now(), }, ]; - const response = await models.completeSimple( + const response = await completeSimpleWithRetries( + models, model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { signal, maxTokens: 2048 }, + retry, + callbacks, ); if (response.stopReason === "aborted") { return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted")); diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 0dc82c7c..6a5e8257 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,9 +1,14 @@ import { type AssistantMessage, + type Context, contentText, type ImageContent, type Model, type Models, + type RetryCallbacks, + type RetryPolicy, + retryAssistantCall, + type SimpleStreamOptions, type TextContent, type Usage, } from "@earendil-works/pi-ai"; @@ -109,6 +114,17 @@ export interface CompactionResult { details?: T; } +export async function completeSimpleWithRetries( + models: Models, + model: Model, + context: Context, + options: SimpleStreamOptions, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, +): Promise { + return retryAssistantCall(() => models.completeSimple(model, context, options), retry, options.signal, callbacks); +} + function combineUsage(first: Usage, second: Usage): Usage { return { input: first.input + second.input, @@ -501,6 +517,8 @@ export async function generateSummary( customInstructions?: string, previousSummary?: string, thinkingLevel?: ThinkingLevel, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise> { const result = await generateSummaryWithUsage( currentMessages, @@ -511,6 +529,8 @@ export async function generateSummary( customInstructions, previousSummary, thinkingLevel, + retry, + callbacks, ); return result.ok ? ok(result.value.text) : err(result.error); } @@ -525,6 +545,8 @@ export async function generateSummaryWithUsage( customInstructions?: string, previousSummary?: string, thinkingLevel?: ThinkingLevel, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), @@ -555,10 +577,13 @@ export async function generateSummaryWithUsage( ? { maxTokens, signal, reasoning: thinkingLevel } : { maxTokens, signal }; - const response = await models.completeSimple( + const response = await completeSimpleWithRetries( + models, model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, completionOptions, + retry, + callbacks, ); if (response.stopReason === "aborted") { return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted")); @@ -700,6 +725,8 @@ export async function compact( customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise> { const { firstKeptEntryId, @@ -733,6 +760,8 @@ export async function compact( customInstructions, previousSummary, thinkingLevel, + retry, + callbacks, ); if (!historyResult.ok) return err(historyResult.error); historyText = historyResult.value.text; @@ -745,6 +774,8 @@ export async function compact( settings.reserveTokens, signal, thinkingLevel, + retry, + callbacks, ); if (!turnPrefixResult.ok) return err(turnPrefixResult.error); summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`; @@ -761,6 +792,8 @@ export async function compact( customInstructions, previousSummary, thinkingLevel, + retry, + callbacks, ); if (!summaryResult.ok) return err(summaryResult.error); summary = summaryResult.value.text; @@ -786,6 +819,8 @@ async function generateTurnPrefixSummary( reserveTokens: number, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), @@ -802,12 +837,17 @@ async function generateTurnPrefixSummary( }, ]; - const response = await models.completeSimple( - model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + const completionOptions = model.reasoning && thinkingLevel && thinkingLevel !== "off" ? { 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") { return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted")); diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 014291f3..ae19d379 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -2,6 +2,7 @@ import type { ImageContent, Model, Models, + RetryPolicy, SimpleStreamOptions, TextContent, Transport, @@ -659,6 +660,25 @@ export interface SessionTreeEvent { 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 { type: "model_update"; model: Model; @@ -709,6 +729,9 @@ export type AgentHarnessOwnEvent< | SessionCompactEvent | SessionBeforeTreeEvent | SessionTreeEvent + | RetryScheduledEvent + | RetryAttemptStartEvent + | RetryFinishedEvent | ModelUpdateEvent | ThinkingLevelUpdateEvent | ResourcesUpdateEvent @@ -778,6 +801,9 @@ export type AgentHarnessEventResultMap = { session_compact: undefined; session_before_tree: SessionBeforeTreeResult | undefined; session_tree: undefined; + retry_scheduled: undefined; + retry_attempt_start: undefined; + retry_finished: undefined; model_update: undefined; thinking_level_update: undefined; resources_update: undefined; @@ -897,6 +923,8 @@ export interface AgentHarnessOptions< }) => string | Promise); /** Curated stream/provider request options. Snapshotted at turn start. */ streamOptions?: AgentHarnessStreamOptions; + /** Optional retry policy for generated compaction and branch-summary requests. */ + retry?: RetryPolicy; model: Model; thinkingLevel?: ThinkingLevel; activeToolNames?: string[]; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 6c33beee..e9bed155 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -44,5 +44,7 @@ export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/truncate.ts"; // Proxy utilities export * from "./proxy.ts"; +// Stream defaults +export { setDefaultStreamFn } from "./stream-fn.ts"; // Types export * from "./types.ts"; diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5d4b7a55..5f0925c9 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -84,12 +84,12 @@ export interface ProxyStreamOptions extends ProxySerializableStreamOptions { * The server strips the partial field from delta events to reduce bandwidth. * 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 * ```typescript * const agent = new Agent({ - * streamFunction: (model, context, options) => + * streamFn: (model, context, options) => * streamProxy(model, context, { * ...options, * authToken: await getAuthToken(), diff --git a/packages/agent/src/stream-fn.ts b/packages/agent/src/stream-fn.ts new file mode 100644 index 00000000..337d16b3 --- /dev/null +++ b/packages/agent/src/stream-fn.ts @@ -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; +} diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 47380c42..8a2a7388 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -9,6 +9,7 @@ import { import { Type } from "typebox"; import { describe, expect, it } from "vitest"; 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"; // 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[]; } +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; + + await stream.result(); + expect(calls).toBe(1); + } finally { + setDefaultStreamFn(undefined); + } + }); +}); + describe("agentLoop with AgentMessage", () => { it("should emit events with AgentMessage types", async () => { const context: AgentContext = { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 6cd77f11..50d20480 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,7 +1,14 @@ import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat"; import { Type } from "typebox"; 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 class MockAssistantStream extends EventStream { @@ -75,8 +82,29 @@ function createDeferred(): { } 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", () => { - const agent = new Agent({ streamFunction: unusedStreamFunction }); + const agent = new Agent({ streamFn: unusedStreamFunction }); expect(agent.state).toBeDefined(); expect(agent.state.systemPrompt).toBe(""); @@ -93,7 +121,7 @@ describe("Agent", () => { it("should create an agent instance with custom initial state", () => { const customModel = getModel("openai", "gpt-4o-mini"); const agent = new Agent({ - streamFunction: unusedStreamFunction, + streamFn: unusedStreamFunction, initialState: { systemPrompt: "You are a helpful assistant.", model: customModel, @@ -107,7 +135,7 @@ describe("Agent", () => { }); it("should subscribe to events", () => { - const agent = new Agent({ streamFunction: unusedStreamFunction }); + const agent = new Agent({ streamFn: unusedStreamFunction }); let eventCount = 0; const unsubscribe = agent.subscribe((_event) => { @@ -130,7 +158,7 @@ describe("Agent", () => { it("emits full lifecycle events for thrown run failures", async () => { const agent = new Agent({ - streamFunction: () => { + streamFn: () => { throw new Error("provider exploded"); }, }); @@ -162,7 +190,7 @@ describe("Agent", () => { it("should await async subscribers before prompt resolves", async () => { const barrier = createDeferred(); const agent = new Agent({ - streamFunction: () => { + streamFn: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); @@ -200,7 +228,7 @@ describe("Agent", () => { it("waitForIdle should wait for async subscribers", async () => { const barrier = createDeferred(); const agent = new Agent({ - streamFunction: () => { + streamFn: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { 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 () => { let receivedSignal: AbortSignal | undefined; const agent = new Agent({ - streamFunction: (_model, _context, options) => { + streamFn: (_model, _context, options) => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); @@ -298,7 +326,7 @@ describe("Agent", () => { }; const agent = new Agent({ initialState: { tools: [tool] }, - streamFunction: () => { + streamFn: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ @@ -373,7 +401,7 @@ describe("Agent", () => { }; const agent = new Agent({ initialState: { tools: [settledTool, slowTool] }, - streamFunction: () => { + streamFn: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ @@ -412,7 +440,7 @@ describe("Agent", () => { }); it("should update state with mutators", () => { - const agent = new Agent({ streamFunction: unusedStreamFunction }); + const agent = new Agent({ streamFn: unusedStreamFunction }); // Test setSystemPrompt agent.state.systemPrompt = "Custom prompt"; @@ -451,7 +479,7 @@ describe("Agent", () => { }); 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() }; agent.steer(message); @@ -461,7 +489,7 @@ describe("Agent", () => { }); 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() }; agent.followUp(message); @@ -471,7 +499,7 @@ describe("Agent", () => { }); 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 expect(() => agent.abort()).not.toThrow(); @@ -481,7 +509,7 @@ describe("Agent", () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ // Use a stream function that responds to abort - streamFunction: (_model, _context, options) => { + streamFn: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -520,7 +548,7 @@ describe("Agent", () => { it("should throw when continue() called while streaming", async () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ - streamFunction: (_model, _context, options) => { + streamFn: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -555,7 +583,7 @@ describe("Agent", () => { it("continue() should process queued follow-up messages after an assistant turn", async () => { const agent = new Agent({ - streamFunction: () => { + streamFn: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { 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 () => { let responseCount = 0; const agent = new Agent({ - streamFunction: () => { + streamFn: () => { const stream = new MockAssistantStream(); responseCount++; queueMicrotask(() => { @@ -652,7 +680,7 @@ describe("Agent", () => { sawAbortSignal = signal instanceof AbortSignal; return undefined; }, - streamFunction: () => { + streamFn: () => { requestCount++; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -680,7 +708,7 @@ describe("Agent", () => { let receivedSessionId: string | undefined; const agent = new Agent({ sessionId: "session-abc", - streamFunction: (_model, _context, options) => { + streamFn: (_model, _context, options) => { receivedSessionId = options?.sessionId; const stream = new MockAssistantStream(); queueMicrotask(() => { diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index 484ef079..6b27c744 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -38,7 +38,7 @@ afterEach(() => { async function basicPrompt(model: Model) { const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Keep your responses concise.", model, @@ -61,7 +61,7 @@ async function basicPrompt(model: Model) { async function toolExecution(model: Model) { const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.", model, @@ -101,7 +101,7 @@ async function toolExecution(model: Model) { async function abortExecution(model: Model) { const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -129,7 +129,7 @@ async function abortExecution(model: Model) { async function stateUpdates(model: Model) { const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -162,7 +162,7 @@ async function stateUpdates(model: Model) { async function multiTurnConversation(model: Model) { const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -244,7 +244,7 @@ describe("Agent integration with faux provider", () => { faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]); const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model: faux.getModel(), @@ -269,7 +269,7 @@ describe("Agent.continue() with faux provider", () => { it("throws when no messages in context", async () => { const faux = createFauxRegistration(); const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "Test", model: faux.getModel(), @@ -283,7 +283,7 @@ describe("Agent.continue() with faux provider", () => { const faux = createFauxRegistration(); const model = faux.getModel(); const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "Test", model, @@ -318,7 +318,7 @@ describe("Agent.continue() with faux provider", () => { const faux = createFauxRegistration(); faux.setResponses([fauxAssistantMessage("HELLO WORLD")]); const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Follow instructions exactly.", model: faux.getModel(), @@ -353,7 +353,7 @@ describe("Agent.continue() with faux provider", () => { const model = faux.getModel(); faux.setResponses([fauxAssistantMessage("The answer is 8.")]); const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. After getting a calculation result, state the answer clearly.", diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index ce058804..239ffd9a 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -605,6 +605,176 @@ describe("AgentHarness", () => { 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 () => { const registration = newFaux(); registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c559aed0..4ce1b715 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,20 @@ ## [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 ### 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 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 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 diff --git a/packages/ai/package.json b/packages/ai/package.json index f7627894..94f5318a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.81.0", + "version": "0.81.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 66e64e5a..07ae2ef3 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; +import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts"; import { CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL, CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, @@ -83,6 +84,7 @@ interface ModelsDevModel { name: string; tool_call?: boolean; reasoning?: boolean; + reasoning_options?: ModelsDevReasoningOption[]; limit?: { context?: number; output?: number; @@ -112,6 +114,12 @@ interface ModelsDevModel { }; } +interface ModelsDevProvider { + models?: Record; +} + +type ModelsDevCatalog = Record; + interface NvidiaNimModelListItem { id: string; } @@ -257,15 +265,6 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { max: "max", } 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_COST = { input: 3, @@ -398,6 +397,43 @@ function mergeThinkingLevelMap(model: Model, map: NonNullable["t model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; } +const modelsDevReasoningOptions = new Map(); + +function getModelKey(model: Pick, "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): 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): 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 { if (!reasoning) return TOGETHER_BASE_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 isOpenRouterDeveloperRoleModel = 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 { supportsStore: !isNonStandard, @@ -957,7 +994,7 @@ async function loadModelsDevData(): Promise[]> { console.log("Fetching models from models.dev API..."); const response = await fetch("https://models.dev/api.json"); 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[] = []; const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map(); @@ -997,6 +1034,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("amazon-bedrock" as const, id, m); } } @@ -1023,6 +1061,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("anthropic", modelId, m); } } @@ -1056,6 +1095,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: source.limit?.context || 4096, maxTokens: source.limit?.output || 4096, }); + recordModelsDevReasoningOptions("google", modelId, source); } } @@ -1097,6 +1137,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: source.limit?.context || 4096, maxTokens: source.limit?.output || 4096, }); + recordModelsDevReasoningOptions("google-vertex", modelId, source); } } @@ -1125,6 +1166,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("openai", modelId, m); } } @@ -1151,6 +1193,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("groq", modelId, m); } } @@ -1177,6 +1220,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("cerebras", modelId, m); } } @@ -1204,6 +1248,7 @@ async function loadModelsDevData(): Promise[]> { maxTokens: m.limit?.output || 4096, compat: { sendSessionAffinityHeaders: true }, }); + recordModelsDevReasoningOptions("cloudflare-workers-ai", modelId, m); } } @@ -1260,6 +1305,7 @@ async function loadModelsDevData(): Promise[]> { maxTokens: m.limit?.output || 4096, ...(compat ? { compat } : {}), }); + recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); } } @@ -1288,6 +1334,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("xai", modelId, m); } } @@ -1330,6 +1377,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions(provider, modelId, m); } } } @@ -1357,6 +1405,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("mistral", modelId, m); } } @@ -1386,6 +1435,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("huggingface", modelId, m); } } @@ -1423,6 +1473,7 @@ async function loadModelsDevData(): Promise[]> { supportsLongCacheRetention: false, }, }); + recordModelsDevReasoningOptions("fireworks", modelId, m); } } @@ -1457,6 +1508,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("nvidia", liveModelId, m); } } @@ -1489,6 +1541,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("together", modelId, m); } } @@ -1596,6 +1649,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions(variant.provider, modelId, m); } } @@ -1646,6 +1700,7 @@ async function loadModelsDevData(): Promise[]> { }; models.push(copilotModel); + recordModelsDevReasoningOptions("github-copilot", modelId, m); } } @@ -1679,6 +1734,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions(provider, modelId, m); } } } @@ -1716,7 +1772,6 @@ async function loadModelsDevData(): Promise[]> { forceAdaptiveThinking: true, }, reasoning: isKimiK3 || m.reasoning === true, - ...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}), input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { input: m.cost?.input || impliedCost?.input || 0, @@ -1727,6 +1782,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions("kimi-coding", normalizedId, m); } } @@ -1761,6 +1817,8 @@ async function loadModelsDevData(): Promise[]> { if (isKimiK3) { compat.requiresReasoningContentOnAssistantMessages = true; compat.deferredToolsMode = "kimi"; + compat.thinkingFormat = "openai"; + compat.supportsReasoningEffort = true; } models.push({ id: modelId, @@ -1769,7 +1827,6 @@ async function loadModelsDevData(): Promise[]> { provider, baseUrl, reasoning: isKimiK3 || m.reasoning === true, - ...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}), input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { input: m.cost?.input || (isKimiK3 ? KIMI_K3_COST.input : 0), @@ -1781,6 +1838,7 @@ async function loadModelsDevData(): Promise[]> { maxTokens: m.limit?.output || 4096, compat, }); + recordModelsDevReasoningOptions(provider, modelId, m); } } @@ -1837,6 +1895,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions(provider, modelId, m); } } @@ -1888,6 +1947,7 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + recordModelsDevReasoningOptions(provider, modelId, m); } } @@ -2392,8 +2452,9 @@ async function generateModels() { allModels.push(...azureOpenAiModels); for (const model of allModels) { - applyThinkingLevelMetadata(model); applyOpenAICompletionsCompatMetadata(model); + applyModelsDevReasoningOptionMetadata(model); + applyThinkingLevelMetadata(model); applyOpenAIToolSearchMetadata(model); } diff --git a/packages/ai/scripts/models-dev-reasoning-options.ts b/packages/ai/scripts/models-dev-reasoning-options.ts new file mode 100644 index 00000000..9d071139 --- /dev/null +++ b/packages/ai/scripts/models-dev-reasoning-options.ts @@ -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; +} diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 01eefcb0..1455b6f8 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -809,7 +809,7 @@ function addCacheControlToLastConversationMessage( ): void { for (let i = messages.length - 1; i >= 0; 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)) { return; } @@ -840,7 +840,7 @@ function addCacheControlToMessage( message: ChatCompletionMessageParam, cacheControl: OpenAICompatCacheControl, ): boolean { - if (message.role === "user" || message.role === "assistant") { + if (message.role === "user" || message.role === "assistant" || message.role === "tool") { return addCacheControlToTextContent(message, cacheControl); } return false; @@ -850,6 +850,7 @@ function addCacheControlToTextContent( message: | ChatCompletionInstructionMessageParam | ChatCompletionAssistantMessageParam + | ChatCompletionToolMessageParam | Extract, cacheControl: OpenAICompatCacheControl, ): boolean { diff --git a/packages/ai/src/auth/oauth/kimi-coding.ts b/packages/ai/src/auth/oauth/kimi-coding.ts new file mode 100644 index 00000000..93e7062d --- /dev/null +++ b/packages/ai/src/auth/oauth/kimi-coding.ts @@ -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 { + return new URLSearchParams(fields).toString(); +} + +async function readJson(response: Response): Promise | null> { + try { + const json = await response.json(); + return json && typeof json === "object" ? (json as Record) : 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 { + 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 | 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 { + return pollOAuthDeviceCodeFlow({ + 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 { + 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 { + 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 { + 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}` } }; + }, +}; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index ca1c87d2..011e93e1 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -15,6 +15,7 @@ type OAuthFlowLoaders = { anthropic: () => OAuthAuth | Promise; openaiCodex: () => OAuthAuth | Promise; githubCopilot: () => OAuthAuth | Promise; + kimiCoding: () => OAuthAuth | Promise; xai: () => OAuthAuth | Promise; radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise; }; @@ -41,6 +42,11 @@ export const loadGitHubCopilotOAuth = async (): Promise => { return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; }; +export const loadKimiCodingOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.kimiCoding(); + return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth; +}; + export const loadXaiOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.xai(); return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index 31cdec98..8c3be5be 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -1,5 +1,6 @@ import { anthropicOAuth } from "./auth/oauth/anthropic.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 { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; import { createRadiusOAuth } from "./auth/oauth/radius.ts"; @@ -11,6 +12,7 @@ export function registerBunOAuthFlows(): void { anthropic: () => anthropicOAuth, openaiCodex: () => openaiCodexOAuth, githubCopilot: () => githubCopilotOAuth, + kimiCoding: () => kimiCodingOAuth, xai: () => xaiOAuth, radius: createRadiusOAuth, }); diff --git a/packages/ai/src/providers/google.models.ts b/packages/ai/src/providers/google.models.ts index 8a416453..bcd1f6c3 100644 --- a/packages/ai/src/providers/google.models.ts +++ b/packages/ai/src/providers/google.models.ts @@ -53,6 +53,14 @@ export const GOOGLE_MODELS = values as { id: "gemini-3.5-flash"; 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"> & { id: "gemini-flash-latest"; provider: "google"; diff --git a/packages/ai/src/providers/kimi-coding.ts b/packages/ai/src/providers/kimi-coding.ts index 865ae28c..ecd87d56 100644 --- a/packages/ai/src/providers/kimi-coding.ts +++ b/packages/ai/src/providers/kimi-coding.ts @@ -1,5 +1,6 @@ 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 { KIMI_CODING_MODELS } from "./kimi-coding.models.ts"; @@ -8,7 +9,14 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> { id: "kimi-coding", name: "Kimi For 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), api: anthropicMessagesApi(), }); diff --git a/packages/ai/src/providers/opencode.models.ts b/packages/ai/src/providers/opencode.models.ts index 6907cb86..69a1fda6 100644 --- a/packages/ai/src/providers/opencode.models.ts +++ b/packages/ai/src/providers/opencode.models.ts @@ -77,6 +77,14 @@ export const OPENCODE_MODELS = values as { id: "gemini-3.5-flash"; 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"> & { id: "glm-5"; provider: "opencode"; @@ -185,6 +193,10 @@ export const OPENCODE_MODELS = values as { id: "kimi-k2.7-code"; 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"> & { id: "mimo-v2.5-free"; provider: "opencode"; diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index c2358136..3a23c668 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -229,6 +229,14 @@ export const OPENROUTER_MODELS = values as { id: "google/gemini-3.5-flash"; 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"> & { id: "google/gemma-3-12b-it"; provider: "openrouter"; @@ -737,6 +745,14 @@ export const OPENROUTER_MODELS = values as { id: "poolside/laguna-m.1:free"; 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"> & { id: "poolside/laguna-xs-2.1"; provider: "openrouter"; diff --git a/packages/ai/src/providers/vercel-ai-gateway.models.ts b/packages/ai/src/providers/vercel-ai-gateway.models.ts index cabae880..59007046 100644 --- a/packages/ai/src/providers/vercel-ai-gateway.models.ts +++ b/packages/ai/src/providers/vercel-ai-gateway.models.ts @@ -265,6 +265,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as { id: "google/gemini-3.5-flash"; 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"> & { id: "google/gemma-4-26b-a4b-it"; provider: "vercel-ai-gateway"; @@ -629,6 +637,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as { id: "openai/o4-mini"; 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"> & { id: "sakana/fugu-ultra"; provider: "vercel-ai-gateway"; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index aeaf18c6..02e973f3 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -524,7 +524,7 @@ export interface OpenAICompletionsCompat { zaiToolStream?: boolean; /** Whether the provider supports the `strict` field in tool definitions. Default: true. */ 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"; /** Whether to send session-affinity data from `options.sessionId`. Default: false. */ sendSessionAffinityHeaders?: boolean; diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index 6332ff55..463b9032 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -51,6 +51,9 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ "connection.?lost", "other side closed", "fetch failed", + "getaddrinfo", + "ENOTFOUND", + "EAI_AGAIN", "upstream.?connect", "reset before headers", "socket hang up", @@ -85,6 +88,128 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ "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; + /** Emitted after the backoff sleep, immediately before the retried call starts. */ + onRetryAttemptStart?: () => void | Promise; + /** Emitted once when the loop ends: success if a later call completed normally. */ + onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise; +} + +class RetrySleepAbortError extends Error { + constructor() { + super("Aborted"); + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + 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, + policy: RetryPolicy | undefined, + signal: AbortSignal | undefined, + callbacks?: RetryCallbacks, +): Promise { + 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 * or transport error, so callers can decide if the last assistant turn should be diff --git a/packages/ai/test/kimi-coding-oauth.test.ts b/packages/ai/test/kimi-coding-oauth.test.ts new file mode 100644 index 00000000..07c56360 --- /dev/null +++ b/packages/ai/test/kimi-coding-oauth.test.ts @@ -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): 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>): 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> = []; + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => jsonResponse({ error: "invalid_grant" }, 400)), + ); + await expect( + kimiCodingOAuth.refresh({ type: "oauth", access: "old", refresh: "old", expires: 0 }), + ).rejects.toThrow("unauthorized"); + }); +}); diff --git a/packages/ai/test/openai-completions-cache-control-format.test.ts b/packages/ai/test/openai-completions-cache-control-format.test.ts index d87a95eb..b9f5e69d 100644 --- a/packages/ai/test/openai-completions-cache-control-format.test.ts +++ b/packages/ai/test/openai-completions-cache-control-format.test.ts @@ -2,7 +2,7 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; import { getModel } from "../src/compat.ts"; -import type { Model } from "../src/types.ts"; +import type { Message, Model } from "../src/types.ts"; interface CacheControl { type: "ephemeral"; @@ -74,6 +74,7 @@ vi.mock("openai", () => { async function capturePayload( model: Model<"openai-completions">, options?: { cacheRetention?: "none" | "short" | "long" }, + messages?: Message[], ): Promise { const timestamp = Date.now(); @@ -81,7 +82,7 @@ async function capturePayload( model, { systemPrompt: "System prompt", - messages: [{ role: "user", content: "Hello", timestamp }], + messages: messages ?? [{ role: "user", content: "Hello", timestamp }], tools: [ { name: "read", @@ -158,6 +159,47 @@ describe("openai-completions cacheControlFormat", () => { 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 () => { const model: Model<"openai-completions"> = { id: "custom-qwen", diff --git a/packages/ai/test/openrouter-cache-control-models.test.ts b/packages/ai/test/openrouter-cache-control-models.test.ts new file mode 100644 index 00000000..2c210444 --- /dev/null +++ b/packages/ai/test/openrouter-cache-control-models.test.ts @@ -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"); + }); +}); diff --git a/packages/ai/test/reasoning-options.test.ts b/packages/ai/test/reasoning-options.test.ts new file mode 100644 index 00000000..fafa9fa5 --- /dev/null +++ b/packages/ai/test/reasoning-options.test.ts @@ -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(); + }); +}); diff --git a/packages/ai/test/retry.test.ts b/packages/ai/test/retry.test.ts index abdc8716..fc79f691 100644 --- a/packages/ai/test/retry.test.ts +++ b/packages/ai/test/retry.test.ts @@ -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 { isRetryableAssistantError } from "../src/utils/retry.ts"; +import { isRetryableAssistantError, type RetryPolicy, retryAssistantCall } from "../src/utils/retry.ts"; 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."; @@ -10,6 +10,8 @@ const nvidiaNIMResourceExhaustedMessage = "ResourceExhausted: Worker local total const bunFetchSocketClosedMessage = "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 wrappedDnsLookupError = + "The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)"; describe("provider retry classification", () => { it("matches explicit provider retry guidance", () => { @@ -38,6 +40,15 @@ describe("provider retry classification", () => { ).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", () => { expect( isRetryableAssistantError( @@ -66,3 +77,136 @@ describe("provider retry classification", () => { 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"); + }); +}); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index b6988ba0..67279032 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -59,15 +59,7 @@ describe("getSupportedThinkingLevels", () => { (modelId) => { const model = getModel("openai", modelId); expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual([ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", - ]); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "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", () => { const model = getModel("kimi-coding", "k3"); expect(model).toBeDefined(); diff --git a/packages/ai/test/together-models.test.ts b/packages/ai/test/together-models.test.ts index 0d766d65..89c85525 100644 --- a/packages/ai/test/together-models.test.ts +++ b/packages/ai/test/together-models.test.ts @@ -44,7 +44,15 @@ describe("Together models", () => { it("models Together reasoning controls from the Together API surface", () => { 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({ supportsReasoningEffort: true, thinkingFormat: "openai", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f3670a31..cc0941be 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [0.81.1] - 2026-07-21 + +### New Features + +- **Verifiable release source archives** — GitHub releases now include deterministic, checksummed source archives with instructions for rebuilding standalone binaries. See [Building standalone binaries from release source](../../README.md#building-standalone-binaries-from-release-source). +- **Resilient compaction and branch summaries** — Transient provider failures now follow the configured retry policy, with retry lifecycle events available to interactive, JSON, RPC, and SDK consumers. See [Compaction & Branch Summarization](docs/compaction.md) and [RPC retry events](docs/rpc.md#summarization_retry_scheduled--summarization_retry_attempt_start--summarization_retry_finished). + +### Added + +- Added deterministic, checksummed source archives to GitHub releases with documented standalone binary rebuild instructions ([#6913](https://github.com/earendil-works/pi/pull/6913) by [@christianklotz](https://github.com/christianklotz)). + +### Fixed + +- Fixed compaction and branch summarization to retry transient provider failures using the configured retry policy, with retry lifecycle events exposed to interactive, JSON, RPC, and SDK consumers ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)). +- Fixed interactive startup waiting for background model catalog refresh while computing the footer provider count. +- Restored the default stream fallback for extensions using the pre-0.81 agent-core API ([#6915](https://github.com/earendil-works/pi/issues/6915)). +- Fixed inherited Kimi K3 models from Moonshot AI and Moonshot AI China to use the OpenAI thinking format and expose reasoning effort support. + ## [0.81.0] - 2026-07-21 ### New Features diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 5ddeab0e..82625904 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -259,7 +259,7 @@ models: [{ ``` Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. -Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content. +Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user, assistant, or tool-result text content. For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay. @@ -760,4 +760,4 @@ interface ProviderModelConfig { ``` `openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`. -`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. +`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index e9a48cbd..a9d28cbc 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -17,7 +17,11 @@ type AgentSessionEvent = | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" } | { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string } | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } - | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; + | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } + | { type: "summarization_retry_scheduled"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } + | { type: "summarization_retry_attempt_start"; source: "branchSummary" } + | { type: "summarization_retry_attempt_start"; source: "compaction"; reason: "manual" | "threshold" | "overflow" } + | { type: "summarization_retry_finished" }; ``` `queue_update` emits the full pending steering and follow-up queues whenever they change. `compaction_start` and `compaction_end` cover both manual and automatic compaction. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 50efc369..04066c4b 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -445,7 +445,7 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | | `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | | `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | -| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. | +| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. | | `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. | | `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. | | `supportsStrictMode` | Include the `strict` field in tool definitions | diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 2151b4b3..7e86feca 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -851,6 +851,9 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO | `compaction_end` | Compaction completes | | `auto_retry_start` | Auto-retry begins (after transient error) | | `auto_retry_end` | Auto-retry completes (success or final failure) | +| `summarization_retry_scheduled` | Retry scheduled for a transient compaction or branch-summary summarization error | +| `summarization_retry_attempt_start` | Retried summarization request starts | +| `summarization_retry_finished` | Summarization retry loop completes | | `extension_error` | Extension threw an error | ### agent_start @@ -1077,6 +1080,36 @@ On final failure (max retries exceeded): } ``` +### summarization_retry_scheduled / summarization_retry_attempt_start / summarization_retry_finished + +Emitted when compaction or branch-summary summarization retries after a transient provider error. These events use the same retry settings as automatic assistant-turn retries. + +```json +{ + "type": "summarization_retry_scheduled", + "attempt": 1, + "maxAttempts": 3, + "delayMs": 2000, + "errorMessage": "terminated" +} +``` + +```json +{ + "type": "summarization_retry_attempt_start", + "source": "compaction", + "reason": "threshold" +} +``` + +For branch summaries, `source` is `"branchSummary"` and no `reason` is present. + +```json +{ + "type": "summarization_retry_finished" +} +``` + ### extension_error Emitted when an extension throws an error. diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 9fcfdeec..1dc32a14 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -319,6 +319,9 @@ session.subscribe((event) => { case "compaction_end": case "auto_retry_start": case "auto_retry_end": + case "summarization_retry_scheduled": + case "summarization_retry_attempt_start": + case "summarization_retry_finished": break; } }); diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 48b9b039..d9fb23b9 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.81.0", + "version": "0.81.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.81.0", + "version": "0.81.1", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 2aaa62c9..ab6a9148 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.81.0", + "version": "0.81.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 2eec0eaf..eaca23dc 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.81.0", + "version": "0.81.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index a4b1dc37..97317c7a 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.81.0", + "version": "0.81.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.81.0", + "version": "0.81.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 7c1bd234..7259f4d2 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.81.0", + "version": "0.81.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 9c0e254a..8f1c2a26 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.11.0", + "version": "1.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.11.0", + "version": "1.11.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index b341b732..3e364467 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.11.0", + "version": "1.11.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 7ad0e0ce..2f57bcf8 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.81.0", + "version": "0.81.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.81.0", + "version": "0.81.1", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index b414081a..9a47b58c 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.81.0", + "version": "0.81.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 8c039a84..e76790ca 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.81.0", + "version": "0.81.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.81.0", + "version": "0.81.1", "dependencies": { - "@earendil-works/pi-coding-agent": "0.81.0" + "@earendil-works/pi-coding-agent": "0.81.1" }, "engines": { "node": ">=22.19.0" @@ -450,11 +450,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.81.0", + "@earendil-works/pi-ai": "^0.81.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.1.38", @@ -465,8 +465,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -489,13 +489,13 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.81.0", - "@earendil-works/pi-ai": "^0.81.0", - "@earendil-works/pi-tui": "^0.81.0", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-tui": "^0.81.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -523,8 +523,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index c06b3105..d3580806 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.81.0", + "version": "0.81.1", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.81.0" + "@earendil-works/pi-coding-agent": "0.81.1" }, "overrides": { "rimraf": "6.1.2", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 39dfb5e1..ffc4e5fe 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.81.0", + "version": "0.81.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.81.0", + "version": "0.81.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.81.0", - "@earendil-works/pi-ai": "^0.81.0", - "@earendil-works/pi-tui": "^0.81.0", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-tui": "^0.81.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.81.0", + "@earendil-works/pi-ai": "^0.81.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.1.38", @@ -489,8 +489,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -513,8 +513,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz", + "version": "0.81.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 6c454520..e2e5427d 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.81.0", + "version": "0.81.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.81.0", - "@earendil-works/pi-ai": "^0.81.0", - "@earendil-works/pi-tui": "^0.81.0", + "@earendil-works/pi-agent-core": "^0.81.1", + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-tui": "^0.81.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b0fdbb07..4fdaaaa6 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -41,6 +41,7 @@ import { isContextOverflow, isRetryableAssistantError, modelsAreEqual, + type RetryCallbacks, resetApiProviders, streamSimple, } from "@earendil-works/pi-ai/compat"; @@ -161,7 +162,21 @@ export type AgentSessionEvent = errorMessage?: string; } | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } - | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; + | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } + | { + type: "summarization_retry_scheduled"; + attempt: number; + maxAttempts: number; + delayMs: number; + errorMessage: string; + } + | { type: "summarization_retry_attempt_start"; source: "branchSummary" } + | { + type: "summarization_retry_attempt_start"; + source: "compaction"; + reason: "manual" | "threshold" | "overflow"; + } + | { type: "summarization_retry_finished" }; /** Listener function for agent session events */ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; @@ -1838,6 +1853,8 @@ export class AgentSession { this.thinkingLevel, this.agent.streamFunction, env, + this.settingsManager.getRetrySettings(), + this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }), ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -2114,6 +2131,8 @@ export class AgentSession { this.thinkingLevel, this.agent.streamFunction, env, + this.settingsManager.getRetrySettings(), + this._summarizationRetryCallbacks({ source: "compaction", reason }), ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; @@ -2620,6 +2639,37 @@ export class AgentSession { return isRetryableAssistantError(message); } + /** + * Retry policy + callbacks shared by compaction and branch-summary summarization calls. + * Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient + * stream drop no longer fails the whole operation. `source` carries the context + * the TUI needs to render the retry and recreate the underlying indicator. + */ + private _summarizationRetryCallbacks( + source: { source: "branchSummary" } | { source: "compaction"; reason: "manual" | "threshold" | "overflow" }, + ): RetryCallbacks { + return { + onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => { + this._emit({ + type: "summarization_retry_scheduled", + attempt, + maxAttempts, + delayMs, + errorMessage, + }); + }, + onRetryAttemptStart: () => { + this._emit({ + type: "summarization_retry_attempt_start", + ...source, + }); + }, + onRetryFinished: () => { + this._emit({ type: "summarization_retry_finished" }); + }, + }; + } + /** * Prepare a retryable error for continuation with exponential backoff. * @returns true if the caller should continue the agent, false otherwise @@ -2934,6 +2984,8 @@ export class AgentSession { replaceInstructions, reserveTokens: branchSummarySettings.reserveTokens, streamFn: this.agent.streamFunction, + retry: this.settingsManager.getRetrySettings(), + callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }), }); if (result.aborted) { return { cancelled: true, aborted: true }; diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 3366f06a..dbb1217e 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -6,9 +6,9 @@ */ import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; +import type { RetryCallbacks, RetryPolicy } from "@earendil-works/pi-ai"; import { contentText } from "@earendil-works/pi-ai"; import type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; -import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm, createBranchSummaryMessage, @@ -16,7 +16,7 @@ import { createCustomMessage, } from "../messages.ts"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; -import { estimateTokens } from "./compaction.ts"; +import { completeSummarization, estimateTokens } from "./compaction.ts"; import { computeFileLists, createFileOps, @@ -83,6 +83,10 @@ export interface GenerateBranchSummaryOptions { reserveTokens?: number; /** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */ streamFn?: StreamFn; + /** Retry policy for transient summarization errors. Reuses coding-agent's `settings.retry`. */ + retry?: RetryPolicy; + /** Optional callbacks for retry reporting (e.g. TUI retry indicators). */ + callbacks?: RetryCallbacks; } // ============================================================================ @@ -300,6 +304,8 @@ export async function generateBranchSummary( replaceInstructions, reserveTokens = 16384, streamFn, + retry, + callbacks, } = options; // Token budget = context window minus reserved space for prompt + response @@ -338,12 +344,11 @@ export async function generateBranchSummary( // Call LLM for summarization. Prefer the session stream function so SDK // request behavior (timeouts, retries, attribution headers) stays consistent - // without running through agent state/events. + // without running through agent state/events. Retried via completeSummarization + // so transient stream drops reuse the configured retry policy. const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 }; - const response = streamFn - ? await (await streamFn(model, context, requestOptions)).result() - : await completeSimple(model, context, requestOptions); + const response = await completeSummarization(model, context, requestOptions, streamFn, retry, callbacks); // Check if aborted or errored if (response.stopReason === "aborted") { diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index ffad75e8..0ae6deeb 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { contentText } from "@earendil-works/pi-ai"; +import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall } from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm } from "../messages.ts"; @@ -552,17 +552,24 @@ function createSummarizationOptions( return options; } -async function completeSummarization( +/** + * Shared choke point for every compaction/branch-summary summarization call. Wraps the + * single LLM call in {@link retryAssistantCall} so transient stream drops (e.g. + * `terminated`, socket close) honor the configured retry policy instead of failing + * the whole compaction on the first attempt. Deterministic errors and aborts return + * immediately (see {@link retryAssistantCall}). + */ +export async function completeSummarization( model: Model, context: Context, options: SimpleStreamOptions, streamFn?: StreamFn, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise { - if (!streamFn) { - return completeSimple(model, context, options); - } - const stream = await streamFn(model, context, options); - return stream.result(); + const produce = async (): Promise => + streamFn ? (await streamFn(model, context, options)).result() : completeSimple(model, context, options); + return retryAssistantCall(produce, retry, options.signal, callbacks); } /** @@ -581,6 +588,8 @@ export async function generateSummary( thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, env?: Record, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise { return ( await generateSummaryWithUsage( @@ -595,6 +604,8 @@ export async function generateSummary( thinkingLevel, streamFn, env, + retry, + callbacks, ) ).text; } @@ -612,6 +623,8 @@ export async function generateSummaryWithUsage( thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, env?: Record, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), @@ -651,6 +664,8 @@ export async function generateSummaryWithUsage( { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, completionOptions, streamFn, + retry, + callbacks, ); if (response.stopReason === "error") { @@ -801,6 +816,8 @@ export async function compact( thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, env?: Record, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise { const { firstKeptEntryId, @@ -833,6 +850,8 @@ export async function compact( thinkingLevel, streamFn, env, + retry, + callbacks, ); historyText = historyResult.text; historyUsage = historyResult.usage; @@ -847,6 +866,8 @@ export async function compact( signal, thinkingLevel, streamFn, + retry, + callbacks, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; @@ -865,6 +886,8 @@ export async function compact( thinkingLevel, streamFn, env, + retry, + callbacks, ); summary = result.text; summaryUsage = result.usage; @@ -900,6 +923,8 @@ async function generateTurnPrefixSummary( signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, + retry?: RetryPolicy, + callbacks?: RetryCallbacks, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), @@ -921,6 +946,8 @@ async function generateTurnPrefixSummary( { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), streamFn, + retry, + callbacks, ); if (response.stopReason === "error") { diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 09138f9f..7f662d33 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; -import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { clampThinkingLevel, type Message, type Model } from "@earendil-works/pi-ai/compat"; +import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat"; import { getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; import { AgentSession } from "./agent-session.ts"; @@ -30,6 +30,11 @@ import { withFileMutationQueue, } from "./tools/index.ts"; +// Preserve the pre-0.81 fallback for extensions that construct Agent instances +// or invoke low-level agent loops without supplying streamFn. Agent core remains +// provider-agnostic and does not import pi-ai/compat itself. +setDefaultStreamFn(streamSimple); + export interface CreateAgentSessionOptions { /** Working directory for project-local discovery. Default: process.cwd() */ cwd?: string; @@ -294,7 +299,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: [], }, convertToLlm: convertToLlmWithBlockImages, - streamFunction: async (model, context, options) => { + streamFn: async (model, context, options) => { const providerRetrySettings = settingsManager.getProviderRetrySettings(); const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); // SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e004436f..ce7465f1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -808,7 +808,8 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } - if (!offlineMode && (appMode === "interactive" || appMode === "rpc")) { + // RPC refreshes catalogs here in the background; interactive mode starts its refresh after TUI initialization. + if (!offlineMode && appMode === "rpc") { void modelRuntime.refresh().catch(() => {}); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 54415407..7a31aa84 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -826,6 +826,13 @@ export class InteractiveMode { async run(): Promise { await this.init(); + if (!process.env.PI_OFFLINE) { + void this.session.modelRuntime + .refresh() + .then(() => this.updateAvailableProviderCount()) + .catch(() => {}); + } + // Start version check asynchronously checkForNewPiVersion(this.version).then((newRelease) => { if (newRelease) { @@ -3110,6 +3117,32 @@ export class InteractiveMode { this.ui.requestRender(); break; } + + case "summarization_retry_scheduled": { + this.showError(event.errorMessage); + this.showStatusIndicator( + new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs), + ); + this.ui.requestRender(); + break; + } + + case "summarization_retry_attempt_start": { + this.clearStatusIndicator("retry"); + if (event.source === "branchSummary") { + this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui)); + } else { + this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason)); + } + this.ui.requestRender(); + break; + } + + case "summarization_retry_finished": { + this.clearStatusIndicator("retry"); + this.ui.requestRender(); + break; + } } } @@ -4326,10 +4359,13 @@ export class InteractiveMode { } } - /** Update the footer's available provider count from current model candidates */ - private async updateAvailableProviderCount(): Promise { - const models = await this.getModelCandidates(); - const uniqueProviders = new Set(models.map((m) => m.provider)); + /** Update the footer's available provider count from the current snapshot without refreshing catalogs. */ + private updateAvailableProviderCount(): void { + const models = + this.session.scopedModels.length > 0 + ? this.session.scopedModels.map((scoped) => scoped.model) + : this.session.modelRuntime.getAvailableSnapshot(); + const uniqueProviders = new Set(models.map((model) => model.provider)); this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size); } diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 3b12b1ef..8c1769dc 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -24,7 +24,7 @@ describe("AgentSession auto-compaction queue resume", () => { const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { model, systemPrompt: "Test", diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index e35158f8..6484aea0 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -49,7 +49,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => API_KEY, - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { model, systemPrompt: "You are a helpful assistant. Be concise.", diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index bd3ef463..bd825ec7 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -90,7 +90,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFunction: (_model, _context, options) => { + streamFn: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFunction: (_model, context, options) => { + streamFn: (_model, context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFunction: () => { + streamFn: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); @@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [tool], }, - streamFunction: async (_model, context) => { + streamFn: async (_model, context) => { const stream = new MockAssistantStream(); queueMicrotask(() => { const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length; @@ -508,7 +508,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [tool], }, - streamFunction: async (_model, context) => { + streamFn: async (_model, context) => { const stream = new MockAssistantStream(); queueMicrotask(() => { const hasToolResult = context.messages.some((message) => message.role === "toolResult"); diff --git a/packages/coding-agent/test/agent-session-retry.test.ts b/packages/coding-agent/test/agent-session-retry.test.ts index f61e71cb..25a996ca 100644 --- a/packages/coding-agent/test/agent-session-retry.test.ts +++ b/packages/coding-agent/test/agent-session-retry.test.ts @@ -82,7 +82,7 @@ describe("AgentSession retry", () => { const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: "Test", tools: [] }, - streamFunction: () => { + streamFn: () => { callCount++; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -203,7 +203,7 @@ describe("AgentSession retry", () => { const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: "Test", tools: [] }, - streamFunction: streamFn, + streamFn: streamFn, }); const sessionManager = SessionManager.inMemory(); const settingsManager = SettingsManager.create(tempDir, tempDir); @@ -255,7 +255,7 @@ describe("AgentSession retry", () => { const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: "Test", tools: [] }, - streamFunction: () => { + streamFn: () => { callCount++; const stream = new MockAssistantStream(); queueMicrotask(() => { diff --git a/packages/coding-agent/test/agent-session-stats.test.ts b/packages/coding-agent/test/agent-session-stats.test.ts index 37706c56..434524d4 100644 --- a/packages/coding-agent/test/agent-session-stats.test.ts +++ b/packages/coding-agent/test/agent-session-stats.test.ts @@ -75,7 +75,7 @@ async function createSession() { const session = new AgentSession({ agent: new Agent({ getApiKey: () => "test-key", - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { model, systemPrompt: "You are a helpful assistant.", diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index 053be534..7b8f8e62 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -89,7 +89,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => API_KEY, - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { model, systemPrompt: "You are a helpful assistant. Be concise.", diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index ef4b133b..70a8fe9e 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -652,7 +652,7 @@ describe("ModelRegistry", () => { expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false); expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true); expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true); - }); + }, 60_000); test("removing custom models from models.json keeps built-in provider models", async () => { writeModelsJson({ diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index 8279d57c..155d2d38 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -114,7 +114,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: systemPrompt: "Test", tools: [], }, - streamFunction: (_model, _context, _options) => { + streamFn: (_model, _context, _options) => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 95c5dbba..5f003241 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -137,7 +137,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise (withConfiguredAuth ? "faux-key" : undefined), - streamFunction: streamSimple, + streamFn: streamSimple, initialState: { model, systemPrompt: options.systemPrompt ?? "You are a test assistant.", diff --git a/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts index b6efaa64..3e78c034 100644 --- a/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts +++ b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts @@ -61,7 +61,7 @@ describe("regression #5596: missing configured theme export", () => { tools: [], }, convertToLlm, - streamFunction: streamSimple, + streamFn: streamSimple, }); const session = new AgentSession({ agent, diff --git a/packages/coding-agent/test/suite/regressions/6647-compaction-retries-transient-stream-drop.test.ts b/packages/coding-agent/test/suite/regressions/6647-compaction-retries-transient-stream-drop.test.ts new file mode 100644 index 00000000..88383687 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6647-compaction-retries-transient-stream-drop.test.ts @@ -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 }); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/6904-dns-transport-retry.test.ts b/packages/coding-agent/test/suite/regressions/6904-dns-transport-retry.test.ts new file mode 100644 index 00000000..9f25cacb --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6904-dns-transport-retry.test.ts @@ -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(); + } + }); +}); diff --git a/packages/coding-agent/test/test-harness.ts b/packages/coding-agent/test/test-harness.ts index 70fbf30f..89a94411 100644 --- a/packages/coding-agent/test/test-harness.ts +++ b/packages/coding-agent/test/test-harness.ts @@ -378,7 +378,7 @@ async function createHarnessWithResourceLoader( systemPrompt: options.systemPrompt ?? "You are a test assistant.", tools: options.tools ?? [], }, - streamFunction: streamFn, + streamFn: streamFn, }); const sessionManager = SessionManager.inMemory(); diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index e2484505..b14fed1e 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -246,7 +246,7 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.", tools: createCodingTools(process.cwd()), }, - streamFunction: streamSimple, + streamFn: streamSimple, }); const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir); diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 7f82e7c3..f24be5fd 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.81.1] - 2026-07-21 + ## [0.81.0] - 2026-07-21 ### Changed diff --git a/packages/server/package.json b/packages/server/package.json index c5b6d8a0..7ea5adca 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-server", - "version": "0.81.0", + "version": "0.81.1", "description": "experimental server package for pi", "type": "module", "main": "./dist/index.js", @@ -40,7 +40,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-coding-agent": "^0.81.0" + "@earendil-works/pi-coding-agent": "^0.81.1" }, "devDependencies": { "shx": "0.4.0" diff --git a/packages/storage/sqlite-node/CHANGELOG.md b/packages/storage/sqlite-node/CHANGELOG.md index aa1bd5a3..6f308d55 100644 --- a/packages/storage/sqlite-node/CHANGELOG.md +++ b/packages/storage/sqlite-node/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.81.1] - 2026-07-21 + ## [0.81.0] - 2026-07-21 ### Added diff --git a/packages/storage/sqlite-node/package.json b/packages/storage/sqlite-node/package.json index d2fbff33..849b73e5 100644 --- a/packages/storage/sqlite-node/package.json +++ b/packages/storage/sqlite-node/package.json @@ -1,6 +1,6 @@ { "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", "type": "module", "main": "./dist/index.js", @@ -32,7 +32,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.81.0", - "@earendil-works/pi-agent-core": "^0.81.0" + "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-agent-core": "^0.81.1" } } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5b9a2c78..84568225 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.81.1] - 2026-07-21 + ## [0.81.0] - 2026-07-21 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 90caf29e..4b8c6d07 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "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", "type": "module", "main": "dist/index.js", diff --git a/scripts/agent-treeshake-smoke-entry.ts b/scripts/agent-treeshake-smoke-entry.ts index 8a8206ec..654a0336 100644 --- a/scripts/agent-treeshake-smoke-entry.ts +++ b/scripts/agent-treeshake-smoke-entry.ts @@ -9,5 +9,5 @@ if (!model) throw new Error("Anthropic smoke-test model not found"); export const agent = new Agent({ initialState: { model }, - streamFunction: models.streamSimple.bind(models), + streamFn: models.streamSimple.bind(models), }); diff --git a/scripts/browser-smoke-entry.ts b/scripts/browser-smoke-entry.ts index 2a7ccfbe..31dada99 100644 --- a/scripts/browser-smoke-entry.ts +++ b/scripts/browser-smoke-entry.ts @@ -24,7 +24,7 @@ const model = getModel("google", "gemini-2.5-flash"); const schema = Type.Object({ prompt: Type.String() }); 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 }); const repo = new InMemorySessionRepo(); const result = getOrThrow(ok({ value: 1 })); diff --git a/scripts/create-source-archive.sh b/scripts/create-source-archive.sh new file mode 100755 index 00000000..629df7ae --- /dev/null +++ b/scripts/create-source-archive.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Create the deterministic source archive uploaded with GitHub releases. +# +# Usage: +# ./scripts/create-source-archive.sh --version --ref --out + +set -euo pipefail + +version="" +source_ref="HEAD" +output="" +invocation_dir="$PWD" + +usage() { + echo "Usage: $0 --version [--ref ] --out " +} + +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" diff --git a/scripts/diff-model-catalog.mjs b/scripts/diff-model-catalog.mjs index 8421daf5..e00563f7 100644 --- a/scripts/diff-model-catalog.mjs +++ b/scripts/diff-model-catalog.mjs @@ -1,19 +1,22 @@ #!/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 { join } from "node:path"; import { spawnSync } from "node:child_process"; 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 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: 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(); 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(); process.exit(1); } @@ -53,6 +58,8 @@ const temporaryRoot = mkdtempSync(join(tmpdir(), "pi-model-catalog-diff-")); const baselineWorktree = join(temporaryRoot, "baseline-worktree"); const baselineOutput = join(temporaryRoot, "before"); const currentOutput = join(temporaryRoot, "after"); +const baselineThinkingOutput = join(temporaryRoot, "before-thinking"); +const currentThinkingOutput = join(temporaryRoot, "after-thinking"); let worktreeAdded = false; function generateCatalog(cwd, outputDir, pretty = false) { @@ -76,8 +83,54 @@ function readProviderCatalog(outputDir, provider) { 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) { - writeFileSync(path, model === undefined ? "" : `${JSON.stringify(model, null, 2)}\n`); + writeFileSync(path, model === undefined ? "" : `${formatJsonForDiff(canonicalizeJson(model))}\n`); } function writeChangedLines(output) { @@ -94,6 +147,10 @@ function writeChangedLines(output) { try { run("git", ["worktree", "add", "--detach", baselineWorktree, "HEAD"], { cwd: repoRoot }); worktreeAdded = true; + copyFileSync( + join(repoRoot, "scripts", "generate-thinking-capabilities.mjs"), + join(baselineWorktree, "scripts", "generate-thinking-capabilities.mjs"), + ); const nodeModules = join(repoRoot, "node_modules"); if (existsSync(nodeModules)) { @@ -107,17 +164,26 @@ try { generateCatalog(repoRoot, currentOutput, true); 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 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 afterModelPath = "after-model.json"; const changedModels = []; let differences = 0; for (const provider of providers) { - const beforeModels = readProviderCatalog(baselineOutput, provider); - const afterModels = readProviderCatalog(currentOutput, provider); + const beforeModels = readProviderCatalog(beforeCatalogOutput, provider); + const afterModels = readProviderCatalog(currentCatalogOutput, provider); if (beforeModels === undefined && afterModels === undefined) { throw new Error(`Unknown provider: ${provider}`); } @@ -126,7 +192,7 @@ try { for (const modelId of modelIds) { const beforeModel = beforeModels?.[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, afterModelPath), afterModel); @@ -156,7 +222,7 @@ try { } 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 { console.log(`\n${differences} model catalog entr${differences === 1 ? "y" : "ies"} changed.`); for (const changedModel of changedModels) { diff --git a/scripts/generate-thinking-capabilities.mjs b/scripts/generate-thinking-capabilities.mjs new file mode 100644 index 00000000..fe7cd641 --- /dev/null +++ b/scripts/generate-thinking-capabilities.mjs @@ -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 "); +} + +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)); +}