From 1235c0ec649b5fbd2c7cd2328aa8d7786f565d8c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 20 Jul 2026 17:54:17 +0200 Subject: [PATCH] fix(agent): decouple agent streams from compat closes #6851 --- packages/agent/CHANGELOG.md | 1 + packages/agent/README.md | 41 ++++++++----- packages/agent/src/agent-loop.ts | 35 +++++------ packages/agent/src/agent.ts | 31 +++++----- packages/agent/src/proxy.ts | 4 +- packages/agent/test/agent-loop.test.ts | 6 +- packages/agent/test/agent.test.ts | 45 +++++++------- packages/agent/test/e2e.test.ts | 11 ++++ .../coding-agent/src/core/agent-session.ts | 10 ++-- packages/coding-agent/src/core/sdk.ts | 2 +- ...gent-session-auto-compaction-queue.test.ts | 9 ++- .../test/agent-session-compaction.test.ts | 3 +- .../test/agent-session-concurrent.test.ts | 10 ++-- .../agent-session-dynamic-provider.test.ts | 2 +- .../test/agent-session-retry.test.ts | 6 +- .../test/agent-session-stats.test.ts | 9 ++- .../test/compaction-extensions.test.ts | 3 +- .../rpc-prompt-response-semantics.test.ts | 2 +- .../test/sdk-openrouter-attribution.test.ts | 2 +- .../test/sdk-stream-options.test.ts | 2 +- .../suite/agent-session-compaction.test.ts | 2 +- packages/coding-agent/test/suite/harness.ts | 3 +- .../5596-missing-theme-export.test.ts | 3 +- .../6324-branch-summary-ambient-auth.test.ts | 2 +- packages/coding-agent/test/test-harness.ts | 2 +- packages/coding-agent/test/utilities.ts | 3 +- scripts/agent-treeshake-smoke-entry.ts | 13 +++++ scripts/browser-smoke-entry.ts | 4 +- scripts/check-browser-smoke.mjs | 58 +++++++++++++++++++ 29 files changed, 219 insertions(+), 105 deletions(-) create mode 100644 scripts/agent-treeshake-smoke-entry.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3e8ec724..57bd9ad8 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking Changes - Moved the `uuidv7` export to `@earendil-works/pi-ai`. +- Replaced the optional `Agent` `streamFn` fallback with a required `streamFunction` and made low-level loop stream functions required, preventing `@earendil-works/pi-ai/compat` and all built-in providers from entering selective-provider bundles ([#6851](https://github.com/earendil-works/pi/issues/6851)). ### Added diff --git a/packages/agent/README.md b/packages/agent/README.md index 17eecea5..3c935f44 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -12,13 +12,20 @@ npm install @earendil-works/pi-agent-core ```typescript import { Agent } from "@earendil-works/pi-agent-core"; -import { getModel } from "@earendil-works/pi-ai"; +import { createModels } from "@earendil-works/pi-ai"; +import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; + +const models = createModels(); +models.setProvider(anthropicProvider()); +const model = models.getModel("anthropic", "claude-sonnet-4-6"); +if (!model) throw new Error("Model not found"); const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant.", - model: getModel("anthropic", "claude-sonnet-4-20250514"), + model, }, + streamFunction: models.streamSimple.bind(models), }); agent.subscribe((event) => { @@ -115,13 +122,19 @@ Tools can also return `terminate: true` to hint that the automatic follow-up LLM Low-level loop callers can set `shouldStopAfterTurn` to stop gracefully after the current turn completes: ```typescript -const stream = agentLoop(prompts, context, { - model, - convertToLlm, - shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => { - return shouldCompactBeforeNextTurn(context.messages); +const stream = agentLoop( + prompts, + context, + { + model, + convertToLlm, + shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => { + return shouldCompactBeforeNextTurn(context.messages); + }, }, -}); + undefined, + models.streamSimple.bind(models), +); ``` `shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason. @@ -181,8 +194,8 @@ const agent = new Agent({ // Follow-up mode: "one-at-a-time" (default) or "all" followUpMode: "one-at-a-time", - // Custom stream function (for proxy backends) - streamFn: streamProxy, + // Required stream function + streamFunction: models.streamSimple.bind(models), // Session ID for provider caching sessionId: "session-123", @@ -369,6 +382,7 @@ Handle custom types in `convertToLlm`: ```typescript const agent = new Agent({ + streamFunction: models.streamSimple.bind(models), convertToLlm: (messages) => messages.flatMap(m => { if (m.role === "notification") return []; // Filter out return [m]; @@ -439,7 +453,7 @@ For browser apps that proxy through a backend: import { Agent, streamProxy } from "@earendil-works/pi-agent-core"; const agent = new Agent({ - streamFn: (model, context, options) => + streamFunction: (model, context, options) => streamProxy(model, context, { ...options, authToken: "...", @@ -471,12 +485,13 @@ const config: AgentLoopConfig = { const userMessage = { role: "user", content: "Hello", timestamp: Date.now() }; -for await (const event of agentLoop([userMessage], context, config)) { +const streamFunction = models.streamSimple.bind(models); +for await (const event of agentLoop([userMessage], context, config, undefined, streamFunction)) { console.log(event.type); } // Continue from existing context -for await (const event of agentLoopContinue(context, config)) { +for await (const event of agentLoopContinue(context, config, undefined, streamFunction)) { console.log(event.type); } ``` diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index c9e731cd..fff06996 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -7,10 +7,9 @@ import { type AssistantMessage, type Context, EventStream, - streamSimple, type ToolResultMessage, validateToolArguments, -} from "@earendil-works/pi-ai/compat"; +} from "@earendil-works/pi-ai"; import type { AgentContext, AgentEvent, @@ -32,8 +31,8 @@ export function agentLoop( prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): EventStream { const stream = createAgentStream(); @@ -45,7 +44,7 @@ export function agentLoop( stream.push(event); }, signal, - streamFn, + streamFunction, ).then((messages) => { stream.end(messages); }); @@ -64,8 +63,8 @@ export function agentLoop( export function agentLoopContinue( context: AgentContext, config: AgentLoopConfig, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): EventStream { if (context.messages.length === 0) { throw new Error("Cannot continue: no messages in context"); @@ -84,7 +83,7 @@ export function agentLoopContinue( stream.push(event); }, signal, - streamFn, + streamFunction, ).then((messages) => { stream.end(messages); }); @@ -97,8 +96,8 @@ export async function runAgentLoop( context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): Promise { const newMessages: AgentMessage[] = [...prompts]; const currentContext: AgentContext = { @@ -113,7 +112,7 @@ export async function runAgentLoop( await emit({ type: "message_end", message: prompt }); } - await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + await runLoop(currentContext, newMessages, config, signal, emit, streamFunction); return newMessages; } @@ -121,8 +120,8 @@ export async function runAgentLoopContinue( context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): Promise { if (context.messages.length === 0) { throw new Error("Cannot continue: no messages in context"); @@ -138,7 +137,7 @@ export async function runAgentLoopContinue( await emit({ type: "agent_start" }); await emit({ type: "turn_start" }); - await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + await runLoop(currentContext, newMessages, config, signal, emit, streamFunction); return newMessages; } @@ -158,7 +157,7 @@ async function runLoop( initialConfig: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, - streamFn?: StreamFn, + streamFunction: StreamFn, ): Promise { let currentContext = initialContext; let config = initialConfig; @@ -190,7 +189,7 @@ async function runLoop( } // Stream assistant response - const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); + const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction); newMessages.push(message); if (message.stopReason === "error" || message.stopReason === "aborted") { @@ -283,7 +282,7 @@ async function streamAssistantResponse( config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, - streamFn?: StreamFn, + streamFunction: StreamFn, ): Promise { // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; @@ -301,8 +300,6 @@ async function streamAssistantResponse( tools: context.tools, }; - const streamFunction = streamFn || streamSimple; - // Resolve API key (important for expiring tokens) const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ab10d5ac..653adf8e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,13 +1,12 @@ -import { - type ImageContent, - type Message, - type Model, - type SimpleStreamOptions, - streamSimple, - type TextContent, - type ThinkingBudgets, - type Transport, -} from "@earendil-works/pi-ai/compat"; +import type { + ImageContent, + Message, + Model, + SimpleStreamOptions, + TextContent, + ThinkingBudgets, + Transport, +} from "@earendil-works/pi-ai"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import type { AfterToolCallContext, @@ -98,7 +97,7 @@ export interface AgentOptions { initialState?: Partial>; convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - streamFn?: StreamFn; + streamFunction: StreamFn; getApiKey?: (provider: string) => Promise | string | undefined; onPayload?: SimpleStreamOptions["onPayload"]; onResponse?: SimpleStreamOptions["onResponse"]; @@ -176,7 +175,7 @@ export class Agent { public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - public streamFn: StreamFn; + public streamFunction: StreamFn; public getApiKey?: (provider: string) => Promise | string | undefined; public onPayload?: SimpleStreamOptions["onPayload"]; public onResponse?: SimpleStreamOptions["onResponse"]; @@ -207,11 +206,11 @@ export class Agent { /** Tool execution strategy for assistant messages that contain multiple tool calls. */ public toolExecution: ToolExecutionMode; - constructor(options: AgentOptions = {}) { + constructor(options: AgentOptions) { this._state = createMutableAgentState(options.initialState); this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; this.transformContext = options.transformContext; - this.streamFn = options.streamFn ?? streamSimple; + this.streamFunction = options.streamFunction; this.getApiKey = options.getApiKey; this.onPayload = options.onPayload; this.onResponse = options.onResponse; @@ -404,7 +403,7 @@ export class Agent { this.createLoopConfig(options), (event) => this.processEvents(event), signal, - this.streamFn, + this.streamFunction, ); }); } @@ -416,7 +415,7 @@ export class Agent { this.createLoopConfig(), (event) => this.processEvents(event), signal, - this.streamFn, + this.streamFunction, ); }); } diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5f0925c9..5d4b7a55 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 `streamFn` option when creating an Agent that needs to go through a proxy. + * Use this as the `streamFunction` option when creating an Agent that needs to go through a proxy. * * @example * ```typescript * const agent = new Agent({ - * streamFn: (model, context, options) => + * streamFunction: (model, context, options) => * streamProxy(model, context, { * ...options, * authToken: await getAuthToken(), diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index f9641ce7..47380c42 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1342,7 +1342,11 @@ describe("agentLoopContinue with AgentMessage", () => { convertToLlm: identityConverter, }; - expect(() => agentLoopContinue(context, config)).toThrow("Cannot continue: no messages in context"); + expect(() => + agentLoopContinue(context, config, undefined, () => { + throw new Error("Unexpected stream call"); + }), + ).toThrow("Cannot continue: no messages in context"); }); it("should continue from existing context without emitting user message events", async () => { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index f677ee78..6cd77f11 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,7 +1,7 @@ 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 } from "../src/index.ts"; +import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback, type StreamFn } from "../src/index.ts"; // Mock stream that mimics AssistantMessageEventStream class MockAssistantStream extends EventStream { @@ -59,6 +59,10 @@ function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMes }; } +const unusedStreamFunction: StreamFn = () => { + throw new Error("Unexpected stream call"); +}; + function createDeferred(): { promise: Promise; resolve: () => void; @@ -72,7 +76,7 @@ function createDeferred(): { describe("Agent", () => { it("should create an agent instance with default state", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); expect(agent.state).toBeDefined(); expect(agent.state.systemPrompt).toBe(""); @@ -89,6 +93,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, initialState: { systemPrompt: "You are a helpful assistant.", model: customModel, @@ -102,7 +107,7 @@ describe("Agent", () => { }); it("should subscribe to events", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); let eventCount = 0; const unsubscribe = agent.subscribe((_event) => { @@ -125,7 +130,7 @@ describe("Agent", () => { it("emits full lifecycle events for thrown run failures", async () => { const agent = new Agent({ - streamFn: () => { + streamFunction: () => { throw new Error("provider exploded"); }, }); @@ -157,7 +162,7 @@ describe("Agent", () => { it("should await async subscribers before prompt resolves", async () => { const barrier = createDeferred(); const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); @@ -195,7 +200,7 @@ describe("Agent", () => { it("waitForIdle should wait for async subscribers", async () => { const barrier = createDeferred(); const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); @@ -230,7 +235,7 @@ describe("Agent", () => { it("should pass the active abort signal to subscribers", async () => { let receivedSignal: AbortSignal | undefined; const agent = new Agent({ - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); @@ -293,7 +298,7 @@ describe("Agent", () => { }; const agent = new Agent({ initialState: { tools: [tool] }, - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ @@ -368,7 +373,7 @@ describe("Agent", () => { }; const agent = new Agent({ initialState: { tools: [settledTool, slowTool] }, - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ @@ -407,7 +412,7 @@ describe("Agent", () => { }); it("should update state with mutators", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); // Test setSystemPrompt agent.state.systemPrompt = "Custom prompt"; @@ -446,7 +451,7 @@ describe("Agent", () => { }); it("should support steering message queue", async () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() }; agent.steer(message); @@ -456,7 +461,7 @@ describe("Agent", () => { }); it("should support follow-up message queue", async () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() }; agent.followUp(message); @@ -466,7 +471,7 @@ describe("Agent", () => { }); it("should handle abort controller", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); // Should not throw even if nothing is running expect(() => agent.abort()).not.toThrow(); @@ -476,7 +481,7 @@ describe("Agent", () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ // Use a stream function that responds to abort - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -515,7 +520,7 @@ describe("Agent", () => { it("should throw when continue() called while streaming", async () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -550,7 +555,7 @@ describe("Agent", () => { it("continue() should process queued follow-up messages after an assistant turn", async () => { const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") }); @@ -589,7 +594,7 @@ describe("Agent", () => { it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => { let responseCount = 0; const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); responseCount++; queueMicrotask(() => { @@ -647,7 +652,7 @@ describe("Agent", () => { sawAbortSignal = signal instanceof AbortSignal; return undefined; }, - streamFn: () => { + streamFunction: () => { requestCount++; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -671,11 +676,11 @@ describe("Agent", () => { expect(sawAbortSignal).toBe(true); }); - it("forwards sessionId to streamFn options", async () => { + it("forwards sessionId to streamFunction options", async () => { let receivedSessionId: string | undefined; const agent = new Agent({ sessionId: "session-abc", - streamFn: (_model, _context, options) => { + streamFunction: (_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 57d70585..484ef079 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -7,6 +7,7 @@ import { fauxToolCall, type Model, registerFauxProvider, + streamSimple, type ToolResultMessage, type UserMessage, } from "@earendil-works/pi-ai/compat"; @@ -37,6 +38,7 @@ afterEach(() => { async function basicPrompt(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Keep your responses concise.", model, @@ -59,6 +61,7 @@ async function basicPrompt(model: Model) { async function toolExecution(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.", model, @@ -98,6 +101,7 @@ async function toolExecution(model: Model) { async function abortExecution(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -125,6 +129,7 @@ async function abortExecution(model: Model) { async function stateUpdates(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -157,6 +162,7 @@ async function stateUpdates(model: Model) { async function multiTurnConversation(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -238,6 +244,7 @@ describe("Agent integration with faux provider", () => { faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]); const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model: faux.getModel(), @@ -262,6 +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, initialState: { systemPrompt: "Test", model: faux.getModel(), @@ -275,6 +283,7 @@ describe("Agent.continue() with faux provider", () => { const faux = createFauxRegistration(); const model = faux.getModel(); const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "Test", model, @@ -309,6 +318,7 @@ describe("Agent.continue() with faux provider", () => { const faux = createFauxRegistration(); faux.setResponses([fauxAssistantMessage("HELLO WORLD")]); const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Follow instructions exactly.", model: faux.getModel(), @@ -343,6 +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, initialState: { systemPrompt: "You are a helpful assistant. After getting a calculation result, state the answer clearly.", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index eb4f78e9..b0fdbb07 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -426,7 +426,7 @@ export class AgentSession { headers?: Record; env?: Record; }> { - if (this.agent.streamFn === streamSimple) { + if (this.agent.streamFunction === streamSimple) { return this._getRequiredRequestAuth(model); } @@ -1836,7 +1836,7 @@ export class AgentSession { customInstructions, this._compactionAbortController.signal, this.thinkingLevel, - this.agent.streamFn, + this.agent.streamFunction, env, ); summary = result.summary; @@ -2037,7 +2037,7 @@ export class AgentSession { let apiKey: string | undefined; let headers: Record | undefined; let env: Record | undefined; - if (this.agent.streamFn === streamSimple) { + if (this.agent.streamFunction === streamSimple) { const authResult = await this._modelRuntime.getAuth(this.model); if (!authResult?.auth.apiKey) return false; apiKey = authResult.auth.apiKey; @@ -2112,7 +2112,7 @@ export class AgentSession { undefined, this._autoCompactionAbortController.signal, this.thinkingLevel, - this.agent.streamFn, + this.agent.streamFunction, env, ); summary = compactResult.summary; @@ -2933,7 +2933,7 @@ export class AgentSession { customInstructions, replaceInstructions, reserveTokens: branchSummarySettings.reserveTokens, - streamFn: this.agent.streamFn, + streamFn: this.agent.streamFunction, }); if (result.aborted) { return { cancelled: true, aborted: true }; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9ac4bc76..09138f9f 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -294,7 +294,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: [], }, convertToLlm: convertToLlmWithBlockImages, - streamFn: async (model, context, options) => { + streamFunction: 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 d34d36f3..3b12b1ef 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 @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai"; -import { getModel } from "@earendil-works/pi-ai/compat"; +import { getModel, streamSimple } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -21,10 +21,10 @@ describe("AgentSession auto-compaction queue resume", () => { beforeEach(async () => { tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); - vi.useFakeTimers(); const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ + streamFunction: streamSimple, initialState: { model, systemPrompt: "Test", @@ -50,7 +50,6 @@ describe("AgentSession auto-compaction queue resume", () => { afterEach(() => { session.dispose(); - vi.useRealTimers(); vi.restoreAllMocks(); if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true }); @@ -84,9 +83,9 @@ describe("AgentSession auto-compaction queue resume", () => { timestamp: now - 500, }); session.agent.state.messages = sessionManager.buildSessionContext().messages; - session.agent.streamFn = (summaryModel) => { + session.agent.streamFunction = (summaryModel) => { const stream = createAssistantMessageEventStream(); - queueMicrotask(() => { + void Promise.resolve().then(() => { stream.push({ type: "done", reason: "stop", diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 61ad0a2b..e35158f8 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -12,7 +12,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { getModel } from "@earendil-works/pi-ai/compat"; +import { getModel, streamSimple } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -49,6 +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, 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 bd825ec7..bd3ef463 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: [], }, - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFn: (_model, context, options) => { + streamFunction: (_model, context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); @@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [tool], }, - streamFn: async (_model, context) => { + streamFunction: 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], }, - streamFn: async (_model, context) => { + streamFunction: 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-dynamic-provider.test.ts b/packages/coding-agent/test/agent-session-dynamic-provider.test.ts index ce137d2e..c806efbc 100644 --- a/packages/coding-agent/test/agent-session-dynamic-provider.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-provider.test.ts @@ -84,7 +84,7 @@ describe("AgentSession dynamic provider registration", () => { session: Awaited>, ): Promise { let baseUrl: string | undefined; - session.agent.streamFn = async (model) => { + session.agent.streamFunction = async (model) => { baseUrl = model.baseUrl; throw new Error("stop"); }; diff --git a/packages/coding-agent/test/agent-session-retry.test.ts b/packages/coding-agent/test/agent-session-retry.test.ts index f7d5eced..f61e71cb 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: [] }, - streamFn: () => { + streamFunction: () => { 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: [] }, - streamFn, + streamFunction: 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: [] }, - streamFn: () => { + streamFunction: () => { 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 2e9d6bf4..37706c56 100644 --- a/packages/coding-agent/test/agent-session-stats.test.ts +++ b/packages/coding-agent/test/agent-session-stats.test.ts @@ -1,5 +1,11 @@ import { Agent } from "@earendil-works/pi-agent-core"; -import { type AssistantMessage, getModel, type ToolResultMessage, type Usage } from "@earendil-works/pi-ai/compat"; +import { + type AssistantMessage, + getModel, + streamSimple, + type ToolResultMessage, + type Usage, +} from "@earendil-works/pi-ai/compat"; import { describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -69,6 +75,7 @@ async function createSession() { const session = new AgentSession({ agent: new Agent({ getApiKey: () => "test-key", + streamFunction: 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 fabcb38d..053be534 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { getModel } from "@earendil-works/pi-ai/compat"; +import { getModel, streamSimple } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -89,6 +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, 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 155d2d38..8279d57c 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: [], }, - streamFn: (_model, _context, _options) => { + streamFunction: (_model, _context, _options) => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index 53f7e942..8cafaccd 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -126,7 +126,7 @@ describe("createAgentSession provider attribution headers", () => { }); try { - const stream = await session.agent.streamFn( + const stream = await session.agent.streamFunction( model, { messages: [] }, { diff --git a/packages/coding-agent/test/sdk-stream-options.test.ts b/packages/coding-agent/test/sdk-stream-options.test.ts index f61cade0..f4c2fd94 100644 --- a/packages/coding-agent/test/sdk-stream-options.test.ts +++ b/packages/coding-agent/test/sdk-stream-options.test.ts @@ -114,7 +114,7 @@ describe("createAgentSession stream options", () => { }); try { - const stream = await session.agent.streamFn(model, { messages: [] }, requestOptions); + const stream = await session.agent.streamFunction(model, { messages: [] }, requestOptions); await stream.result(); return capturedOptions; } finally { diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index ee7bea77..33649135 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -49,7 +49,7 @@ function createAssistant( function useSummaryStreamFn(harness: Harness, summary: string): () => number { let callCount = 0; - harness.session.agent.streamFn = (model) => { + harness.session.agent.streamFunction = (model) => { callCount++; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index b7b27971..95c5dbba 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -14,7 +14,7 @@ import type { FauxResponseStep, Model, } from "@earendil-works/pi-ai/compat"; -import { registerFauxProvider } from "@earendil-works/pi-ai/compat"; +import { registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat"; import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.ts"; import { AuthStorage } from "../../src/core/auth-storage.ts"; import type { ExtensionRunner } from "../../src/core/extensions/index.ts"; @@ -137,6 +137,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise (withConfiguredAuth ? "faux-key" : undefined), + streamFunction: 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 e8ef3b35..b6efaa64 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 @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat"; +import { fauxAssistantMessage, registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat"; import { afterEach, describe, expect, it } from "vitest"; import { AgentSession } from "../../../src/core/agent-session.ts"; import { AuthStorage } from "../../../src/core/auth-storage.ts"; @@ -61,6 +61,7 @@ describe("regression #5596: missing configured theme export", () => { tools: [], }, convertToLlm, + streamFunction: streamSimple, }); const session = new AgentSession({ agent, diff --git a/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts b/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts index bfedbaaf..d116325d 100644 --- a/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts +++ b/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts @@ -17,7 +17,7 @@ describe("issue #6324 branch summary ambient auth", () => { harnesses.push(harness); let streamCallCount = 0; - harness.session.agent.streamFn = (model, _context, options) => { + harness.session.agent.streamFunction = (model, _context, options) => { streamCallCount++; expect(options?.apiKey).toBeUndefined(); diff --git a/packages/coding-agent/test/test-harness.ts b/packages/coding-agent/test/test-harness.ts index 3dc9f628..70fbf30f 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 ?? [], }, - streamFn, + streamFunction: streamFn, }); const sessionManager = SessionManager.inMemory(); diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index f7568e9f..e2484505 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -8,7 +8,7 @@ import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import type { OAuthCredentials } from "@earendil-works/pi-ai"; -import { getModel } from "@earendil-works/pi-ai/compat"; +import { getModel, streamSimple } from "@earendil-works/pi-ai/compat"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -246,6 +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, }); 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 new file mode 100644 index 00000000..8a8206ec --- /dev/null +++ b/scripts/agent-treeshake-smoke-entry.ts @@ -0,0 +1,13 @@ +import { Agent } from "@earendil-works/pi-agent-core"; +import { createModels } from "@earendil-works/pi-ai"; +import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; + +const models = createModels(); +models.setProvider(anthropicProvider()); +const model = models.getModel("anthropic", "claude-sonnet-4-5"); +if (!model) throw new Error("Anthropic smoke-test model not found"); + +export const agent = new Agent({ + initialState: { model }, + streamFunction: models.streamSimple.bind(models), +}); diff --git a/scripts/browser-smoke-entry.ts b/scripts/browser-smoke-entry.ts index 3ac7bcf0..2a7ccfbe 100644 --- a/scripts/browser-smoke-entry.ts +++ b/scripts/browser-smoke-entry.ts @@ -1,5 +1,5 @@ import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai"; -import { complete, getModel, getProviders } from "@earendil-works/pi-ai/compat"; +import { complete, getModel, getProviders, streamSimple } from "@earendil-works/pi-ai/compat"; import { Agent, bashExecutionToText, @@ -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 } }); +const agent = new Agent({ initialState: { model }, streamFunction: 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/check-browser-smoke.mjs b/scripts/check-browser-smoke.mjs index a45bd3a9..eeab27b1 100644 --- a/scripts/check-browser-smoke.mjs +++ b/scripts/check-browser-smoke.mjs @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { build } from "esbuild"; const outputPath = join(tmpdir(), "pi-browser-smoke.js"); +const agentTreeshakeOutputPath = join(tmpdir(), "pi-agent-treeshake-smoke.js"); const errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log"); const generatedCatalogDataDir = join(process.cwd(), "packages/ai/src/providers/data"); @@ -23,6 +24,22 @@ const generatedCatalogDataPlugin = { }, }; +function normalizePath(path) { + return path.replaceAll("\\", "/"); +} + +function findInput(inputs, suffix) { + return Object.keys(inputs).find((input) => { + const normalized = normalizePath(input); + return normalized === suffix || normalized.endsWith(`/${suffix}`); + }); +} + +function includesNodePackage(inputs, packageName) { + const marker = `node_modules/${packageName}/`; + return Object.keys(inputs).some((input) => normalizePath(input).includes(marker)); +} + try { await build({ entryPoints: ["scripts/browser-smoke-entry.ts"], @@ -33,6 +50,47 @@ try { outfile: outputPath, plugins: [generatedCatalogDataPlugin], }); + + const agentTreeshakeBuild = await build({ + entryPoints: ["scripts/agent-treeshake-smoke-entry.ts"], + bundle: true, + platform: "browser", + format: "esm", + logLevel: "silent", + metafile: true, + outfile: agentTreeshakeOutputPath, + plugins: [generatedCatalogDataPlugin], + write: false, + }); + const inputs = agentTreeshakeBuild.metafile.inputs; + for (const forbiddenInput of [ + "packages/ai/src/compat.ts", + "packages/ai/src/models.generated.ts", + "packages/ai/src/providers/all.ts", + ]) { + const includedInput = findInput(inputs, forbiddenInput); + if (includedInput) { + throw new Error(`Agent selective-provider bundle unexpectedly includes ${includedInput}`); + } + } + + const aiSdkPackages = [ + "@anthropic-ai/sdk", + "@aws-sdk/client-bedrock-runtime", + "@google/genai", + "@mistralai/mistralai", + "openai", + ]; + const includedAiSdkPackages = aiSdkPackages.filter((packageName) => includesNodePackage(inputs, packageName)); + if ( + includedAiSdkPackages.length !== 1 || + includedAiSdkPackages[0] !== "@anthropic-ai/sdk" + ) { + throw new Error( + `Agent selective-provider bundle SDKs: expected only @anthropic-ai/sdk, found ${includedAiSdkPackages.join(", ") || "none"}`, + ); + } + process.exit(0); } catch (error) { let detailedErrors = "";