diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 0e578ff6..fdd47c8e 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### 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/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/index.ts b/packages/agent/src/index.ts index b98023b0..1cd718cc 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -43,5 +43,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/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c559aed0..1867078b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### 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 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fe7d5cf0..1572d6e4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,9 +2,21 @@ ## [Unreleased] +### 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 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/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/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/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/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 }));