fix(agent): decouple agent streams from compat

closes #6851
This commit is contained in:
Mario Zechner
2026-07-20 17:54:17 +02:00
parent 3a40794ea1
commit 1235c0ec64
29 changed files with 219 additions and 105 deletions
+1
View File
@@ -5,6 +5,7 @@
### Breaking Changes ### Breaking Changes
- Moved the `uuidv7` export to `@earendil-works/pi-ai`. - 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 ### Added
+28 -13
View File
@@ -12,13 +12,20 @@ npm install @earendil-works/pi-agent-core
```typescript ```typescript
import { Agent } from "@earendil-works/pi-agent-core"; 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({ const agent = new Agent({
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model: getModel("anthropic", "claude-sonnet-4-20250514"), model,
}, },
streamFunction: models.streamSimple.bind(models),
}); });
agent.subscribe((event) => { 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: Low-level loop callers can set `shouldStopAfterTurn` to stop gracefully after the current turn completes:
```typescript ```typescript
const stream = agentLoop(prompts, context, { const stream = agentLoop(
model, prompts,
convertToLlm, context,
shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => { {
return shouldCompactBeforeNextTurn(context.messages); 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. `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" // Follow-up mode: "one-at-a-time" (default) or "all"
followUpMode: "one-at-a-time", followUpMode: "one-at-a-time",
// Custom stream function (for proxy backends) // Required stream function
streamFn: streamProxy, streamFunction: models.streamSimple.bind(models),
// Session ID for provider caching // Session ID for provider caching
sessionId: "session-123", sessionId: "session-123",
@@ -369,6 +382,7 @@ Handle custom types in `convertToLlm`:
```typescript ```typescript
const agent = new Agent({ const agent = new Agent({
streamFunction: models.streamSimple.bind(models),
convertToLlm: (messages) => messages.flatMap(m => { convertToLlm: (messages) => messages.flatMap(m => {
if (m.role === "notification") return []; // Filter out if (m.role === "notification") return []; // Filter out
return [m]; return [m];
@@ -439,7 +453,7 @@ For browser apps that proxy through a backend:
import { Agent, streamProxy } from "@earendil-works/pi-agent-core"; import { Agent, streamProxy } from "@earendil-works/pi-agent-core";
const agent = new Agent({ const agent = new Agent({
streamFn: (model, context, options) => streamFunction: (model, context, options) =>
streamProxy(model, context, { streamProxy(model, context, {
...options, ...options,
authToken: "...", authToken: "...",
@@ -471,12 +485,13 @@ const config: AgentLoopConfig = {
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() }; 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); console.log(event.type);
} }
// Continue from existing context // 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); console.log(event.type);
} }
``` ```
+16 -19
View File
@@ -7,10 +7,9 @@ import {
type AssistantMessage, type AssistantMessage,
type Context, type Context,
EventStream, EventStream,
streamSimple,
type ToolResultMessage, type ToolResultMessage,
validateToolArguments, validateToolArguments,
} from "@earendil-works/pi-ai/compat"; } from "@earendil-works/pi-ai";
import type { import type {
AgentContext, AgentContext,
AgentEvent, AgentEvent,
@@ -32,8 +31,8 @@ export function agentLoop(
prompts: AgentMessage[], prompts: AgentMessage[],
context: AgentContext, context: AgentContext,
config: AgentLoopConfig, config: AgentLoopConfig,
signal?: AbortSignal, signal: AbortSignal | undefined,
streamFn?: StreamFn, streamFunction: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> { ): EventStream<AgentEvent, AgentMessage[]> {
const stream = createAgentStream(); const stream = createAgentStream();
@@ -45,7 +44,7 @@ export function agentLoop(
stream.push(event); stream.push(event);
}, },
signal, signal,
streamFn, streamFunction,
).then((messages) => { ).then((messages) => {
stream.end(messages); stream.end(messages);
}); });
@@ -64,8 +63,8 @@ export function agentLoop(
export function agentLoopContinue( export function agentLoopContinue(
context: AgentContext, context: AgentContext,
config: AgentLoopConfig, config: AgentLoopConfig,
signal?: AbortSignal, signal: AbortSignal | undefined,
streamFn?: StreamFn, streamFunction: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> { ): EventStream<AgentEvent, AgentMessage[]> {
if (context.messages.length === 0) { if (context.messages.length === 0) {
throw new Error("Cannot continue: no messages in context"); throw new Error("Cannot continue: no messages in context");
@@ -84,7 +83,7 @@ export function agentLoopContinue(
stream.push(event); stream.push(event);
}, },
signal, signal,
streamFn, streamFunction,
).then((messages) => { ).then((messages) => {
stream.end(messages); stream.end(messages);
}); });
@@ -97,8 +96,8 @@ export async function runAgentLoop(
context: AgentContext, context: AgentContext,
config: AgentLoopConfig, config: AgentLoopConfig,
emit: AgentEventSink, emit: AgentEventSink,
signal?: AbortSignal, signal: AbortSignal | undefined,
streamFn?: StreamFn, streamFunction: StreamFn,
): Promise<AgentMessage[]> { ): Promise<AgentMessage[]> {
const newMessages: AgentMessage[] = [...prompts]; const newMessages: AgentMessage[] = [...prompts];
const currentContext: AgentContext = { const currentContext: AgentContext = {
@@ -113,7 +112,7 @@ export async function runAgentLoop(
await emit({ type: "message_end", message: prompt }); await emit({ type: "message_end", message: prompt });
} }
await runLoop(currentContext, newMessages, config, signal, emit, streamFn); await runLoop(currentContext, newMessages, config, signal, emit, streamFunction);
return newMessages; return newMessages;
} }
@@ -121,8 +120,8 @@ export async function runAgentLoopContinue(
context: AgentContext, context: AgentContext,
config: AgentLoopConfig, config: AgentLoopConfig,
emit: AgentEventSink, emit: AgentEventSink,
signal?: AbortSignal, signal: AbortSignal | undefined,
streamFn?: StreamFn, streamFunction: StreamFn,
): Promise<AgentMessage[]> { ): Promise<AgentMessage[]> {
if (context.messages.length === 0) { if (context.messages.length === 0) {
throw new Error("Cannot continue: no messages in context"); throw new Error("Cannot continue: no messages in context");
@@ -138,7 +137,7 @@ export async function runAgentLoopContinue(
await emit({ type: "agent_start" }); await emit({ type: "agent_start" });
await emit({ type: "turn_start" }); await emit({ type: "turn_start" });
await runLoop(currentContext, newMessages, config, signal, emit, streamFn); await runLoop(currentContext, newMessages, config, signal, emit, streamFunction);
return newMessages; return newMessages;
} }
@@ -158,7 +157,7 @@ async function runLoop(
initialConfig: AgentLoopConfig, initialConfig: AgentLoopConfig,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
emit: AgentEventSink, emit: AgentEventSink,
streamFn?: StreamFn, streamFunction: StreamFn,
): Promise<void> { ): Promise<void> {
let currentContext = initialContext; let currentContext = initialContext;
let config = initialConfig; let config = initialConfig;
@@ -190,7 +189,7 @@ async function runLoop(
} }
// Stream assistant response // Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message); newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") { if (message.stopReason === "error" || message.stopReason === "aborted") {
@@ -283,7 +282,7 @@ async function streamAssistantResponse(
config: AgentLoopConfig, config: AgentLoopConfig,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
emit: AgentEventSink, emit: AgentEventSink,
streamFn?: StreamFn, streamFunction: StreamFn,
): Promise<AssistantMessage> { ): Promise<AssistantMessage> {
// Apply context transform if configured (AgentMessage[] → AgentMessage[]) // Apply context transform if configured (AgentMessage[] → AgentMessage[])
let messages = context.messages; let messages = context.messages;
@@ -301,8 +300,6 @@ async function streamAssistantResponse(
tools: context.tools, tools: context.tools,
}; };
const streamFunction = streamFn || streamSimple;
// Resolve API key (important for expiring tokens) // Resolve API key (important for expiring tokens)
const resolvedApiKey = const resolvedApiKey =
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
+15 -16
View File
@@ -1,13 +1,12 @@
import { import type {
type ImageContent, ImageContent,
type Message, Message,
type Model, Model,
type SimpleStreamOptions, SimpleStreamOptions,
streamSimple, TextContent,
type TextContent, ThinkingBudgets,
type ThinkingBudgets, Transport,
type Transport, } from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/compat";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import type { import type {
AfterToolCallContext, AfterToolCallContext,
@@ -98,7 +97,7 @@ export interface AgentOptions {
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>; initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>; convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>; transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
streamFn?: StreamFn; streamFunction: StreamFn;
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined; getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
onPayload?: SimpleStreamOptions["onPayload"]; onPayload?: SimpleStreamOptions["onPayload"];
onResponse?: SimpleStreamOptions["onResponse"]; onResponse?: SimpleStreamOptions["onResponse"];
@@ -176,7 +175,7 @@ export class Agent {
public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>; public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>; public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
public streamFn: StreamFn; public streamFunction: StreamFn;
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined; public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
public onPayload?: SimpleStreamOptions["onPayload"]; public onPayload?: SimpleStreamOptions["onPayload"];
public onResponse?: SimpleStreamOptions["onResponse"]; public onResponse?: SimpleStreamOptions["onResponse"];
@@ -207,11 +206,11 @@ export class Agent {
/** Tool execution strategy for assistant messages that contain multiple tool calls. */ /** Tool execution strategy for assistant messages that contain multiple tool calls. */
public toolExecution: ToolExecutionMode; public toolExecution: ToolExecutionMode;
constructor(options: AgentOptions = {}) { constructor(options: AgentOptions) {
this._state = createMutableAgentState(options.initialState); this._state = createMutableAgentState(options.initialState);
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
this.transformContext = options.transformContext; this.transformContext = options.transformContext;
this.streamFn = options.streamFn ?? streamSimple; this.streamFunction = options.streamFunction;
this.getApiKey = options.getApiKey; this.getApiKey = options.getApiKey;
this.onPayload = options.onPayload; this.onPayload = options.onPayload;
this.onResponse = options.onResponse; this.onResponse = options.onResponse;
@@ -404,7 +403,7 @@ export class Agent {
this.createLoopConfig(options), this.createLoopConfig(options),
(event) => this.processEvents(event), (event) => this.processEvents(event),
signal, signal,
this.streamFn, this.streamFunction,
); );
}); });
} }
@@ -416,7 +415,7 @@ export class Agent {
this.createLoopConfig(), this.createLoopConfig(),
(event) => this.processEvents(event), (event) => this.processEvents(event),
signal, signal,
this.streamFn, this.streamFunction,
); );
}); });
} }
+2 -2
View File
@@ -84,12 +84,12 @@ export interface ProxyStreamOptions extends ProxySerializableStreamOptions {
* The server strips the partial field from delta events to reduce bandwidth. * The server strips the partial field from delta events to reduce bandwidth.
* We reconstruct the partial message client-side. * We reconstruct the partial message client-side.
* *
* Use this as the `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 * @example
* ```typescript * ```typescript
* const agent = new Agent({ * const agent = new Agent({
* streamFn: (model, context, options) => * streamFunction: (model, context, options) =>
* streamProxy(model, context, { * streamProxy(model, context, {
* ...options, * ...options,
* authToken: await getAuthToken(), * authToken: await getAuthToken(),
+5 -1
View File
@@ -1342,7 +1342,11 @@ describe("agentLoopContinue with AgentMessage", () => {
convertToLlm: identityConverter, 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 () => { it("should continue from existing context without emitting user message events", async () => {
+25 -20
View File
@@ -1,7 +1,7 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat"; import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
import { Type } from "typebox"; import { Type } from "typebox";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts"; import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback, type StreamFn } from "../src/index.ts";
// Mock stream that mimics AssistantMessageEventStream // Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> { class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -59,6 +59,10 @@ function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMes
}; };
} }
const unusedStreamFunction: StreamFn = () => {
throw new Error("Unexpected stream call");
};
function createDeferred(): { function createDeferred(): {
promise: Promise<void>; promise: Promise<void>;
resolve: () => void; resolve: () => void;
@@ -72,7 +76,7 @@ function createDeferred(): {
describe("Agent", () => { describe("Agent", () => {
it("should create an agent instance with default state", () => { 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).toBeDefined();
expect(agent.state.systemPrompt).toBe(""); expect(agent.state.systemPrompt).toBe("");
@@ -89,6 +93,7 @@ describe("Agent", () => {
it("should create an agent instance with custom initial state", () => { it("should create an agent instance with custom initial state", () => {
const customModel = getModel("openai", "gpt-4o-mini"); const customModel = getModel("openai", "gpt-4o-mini");
const agent = new Agent({ const agent = new Agent({
streamFunction: unusedStreamFunction,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model: customModel, model: customModel,
@@ -102,7 +107,7 @@ describe("Agent", () => {
}); });
it("should subscribe to events", () => { it("should subscribe to events", () => {
const agent = new Agent(); const agent = new Agent({ streamFunction: unusedStreamFunction });
let eventCount = 0; let eventCount = 0;
const unsubscribe = agent.subscribe((_event) => { const unsubscribe = agent.subscribe((_event) => {
@@ -125,7 +130,7 @@ describe("Agent", () => {
it("emits full lifecycle events for thrown run failures", async () => { it("emits full lifecycle events for thrown run failures", async () => {
const agent = new Agent({ const agent = new Agent({
streamFn: () => { streamFunction: () => {
throw new Error("provider exploded"); throw new Error("provider exploded");
}, },
}); });
@@ -157,7 +162,7 @@ describe("Agent", () => {
it("should await async subscribers before prompt resolves", async () => { it("should await async subscribers before prompt resolves", async () => {
const barrier = createDeferred(); const barrier = createDeferred();
const agent = new Agent({ const agent = new Agent({
streamFn: () => { streamFunction: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
@@ -195,7 +200,7 @@ describe("Agent", () => {
it("waitForIdle should wait for async subscribers", async () => { it("waitForIdle should wait for async subscribers", async () => {
const barrier = createDeferred(); const barrier = createDeferred();
const agent = new Agent({ const agent = new Agent({
streamFn: () => { streamFunction: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
@@ -230,7 +235,7 @@ describe("Agent", () => {
it("should pass the active abort signal to subscribers", async () => { it("should pass the active abort signal to subscribers", async () => {
let receivedSignal: AbortSignal | undefined; let receivedSignal: AbortSignal | undefined;
const agent = new Agent({ const agent = new Agent({
streamFn: (_model, _context, options) => { streamFunction: (_model, _context, options) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") }); stream.push({ type: "start", partial: createAssistantMessage("") });
@@ -293,7 +298,7 @@ describe("Agent", () => {
}; };
const agent = new Agent({ const agent = new Agent({
initialState: { tools: [tool] }, initialState: { tools: [tool] },
streamFn: () => { streamFunction: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ stream.push({
@@ -368,7 +373,7 @@ describe("Agent", () => {
}; };
const agent = new Agent({ const agent = new Agent({
initialState: { tools: [settledTool, slowTool] }, initialState: { tools: [settledTool, slowTool] },
streamFn: () => { streamFunction: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ stream.push({
@@ -407,7 +412,7 @@ describe("Agent", () => {
}); });
it("should update state with mutators", () => { it("should update state with mutators", () => {
const agent = new Agent(); const agent = new Agent({ streamFunction: unusedStreamFunction });
// Test setSystemPrompt // Test setSystemPrompt
agent.state.systemPrompt = "Custom prompt"; agent.state.systemPrompt = "Custom prompt";
@@ -446,7 +451,7 @@ describe("Agent", () => {
}); });
it("should support steering message queue", async () => { 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() }; const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() };
agent.steer(message); agent.steer(message);
@@ -456,7 +461,7 @@ describe("Agent", () => {
}); });
it("should support follow-up message queue", async () => { 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() }; const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() };
agent.followUp(message); agent.followUp(message);
@@ -466,7 +471,7 @@ describe("Agent", () => {
}); });
it("should handle abort controller", () => { it("should handle abort controller", () => {
const agent = new Agent(); const agent = new Agent({ streamFunction: unusedStreamFunction });
// Should not throw even if nothing is running // Should not throw even if nothing is running
expect(() => agent.abort()).not.toThrow(); expect(() => agent.abort()).not.toThrow();
@@ -476,7 +481,7 @@ describe("Agent", () => {
let abortSignal: AbortSignal | undefined; let abortSignal: AbortSignal | undefined;
const agent = new Agent({ const agent = new Agent({
// Use a stream function that responds to abort // Use a stream function that responds to abort
streamFn: (_model, _context, options) => { streamFunction: (_model, _context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -515,7 +520,7 @@ describe("Agent", () => {
it("should throw when continue() called while streaming", async () => { it("should throw when continue() called while streaming", async () => {
let abortSignal: AbortSignal | undefined; let abortSignal: AbortSignal | undefined;
const agent = new Agent({ const agent = new Agent({
streamFn: (_model, _context, options) => { streamFunction: (_model, _context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -550,7 +555,7 @@ describe("Agent", () => {
it("continue() should process queued follow-up messages after an assistant turn", async () => { it("continue() should process queued follow-up messages after an assistant turn", async () => {
const agent = new Agent({ const agent = new Agent({
streamFn: () => { streamFunction: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") }); stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
@@ -589,7 +594,7 @@ describe("Agent", () => {
it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => { it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => {
let responseCount = 0; let responseCount = 0;
const agent = new Agent({ const agent = new Agent({
streamFn: () => { streamFunction: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
responseCount++; responseCount++;
queueMicrotask(() => { queueMicrotask(() => {
@@ -647,7 +652,7 @@ describe("Agent", () => {
sawAbortSignal = signal instanceof AbortSignal; sawAbortSignal = signal instanceof AbortSignal;
return undefined; return undefined;
}, },
streamFn: () => { streamFunction: () => {
requestCount++; requestCount++;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -671,11 +676,11 @@ describe("Agent", () => {
expect(sawAbortSignal).toBe(true); expect(sawAbortSignal).toBe(true);
}); });
it("forwards sessionId to streamFn options", async () => { it("forwards sessionId to streamFunction options", async () => {
let receivedSessionId: string | undefined; let receivedSessionId: string | undefined;
const agent = new Agent({ const agent = new Agent({
sessionId: "session-abc", sessionId: "session-abc",
streamFn: (_model, _context, options) => { streamFunction: (_model, _context, options) => {
receivedSessionId = options?.sessionId; receivedSessionId = options?.sessionId;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
+11
View File
@@ -7,6 +7,7 @@ import {
fauxToolCall, fauxToolCall,
type Model, type Model,
registerFauxProvider, registerFauxProvider,
streamSimple,
type ToolResultMessage, type ToolResultMessage,
type UserMessage, type UserMessage,
} from "@earendil-works/pi-ai/compat"; } from "@earendil-works/pi-ai/compat";
@@ -37,6 +38,7 @@ afterEach(() => {
async function basicPrompt(model: Model<string>) { async function basicPrompt(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant. Keep your responses concise.", systemPrompt: "You are a helpful assistant. Keep your responses concise.",
model, model,
@@ -59,6 +61,7 @@ async function basicPrompt(model: Model<string>) {
async function toolExecution(model: Model<string>) { async function toolExecution(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.", systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.",
model, model,
@@ -98,6 +101,7 @@ async function toolExecution(model: Model<string>) {
async function abortExecution(model: Model<string>) { async function abortExecution(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model, model,
@@ -125,6 +129,7 @@ async function abortExecution(model: Model<string>) {
async function stateUpdates(model: Model<string>) { async function stateUpdates(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model, model,
@@ -157,6 +162,7 @@ async function stateUpdates(model: Model<string>) {
async function multiTurnConversation(model: Model<string>) { async function multiTurnConversation(model: Model<string>) {
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model, model,
@@ -238,6 +244,7 @@ describe("Agent integration with faux provider", () => {
faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]); faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]);
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
model: faux.getModel(), model: faux.getModel(),
@@ -262,6 +269,7 @@ describe("Agent.continue() with faux provider", () => {
it("throws when no messages in context", async () => { it("throws when no messages in context", async () => {
const faux = createFauxRegistration(); const faux = createFauxRegistration();
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "Test", systemPrompt: "Test",
model: faux.getModel(), model: faux.getModel(),
@@ -275,6 +283,7 @@ describe("Agent.continue() with faux provider", () => {
const faux = createFauxRegistration(); const faux = createFauxRegistration();
const model = faux.getModel(); const model = faux.getModel();
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "Test", systemPrompt: "Test",
model, model,
@@ -309,6 +318,7 @@ describe("Agent.continue() with faux provider", () => {
const faux = createFauxRegistration(); const faux = createFauxRegistration();
faux.setResponses([fauxAssistantMessage("HELLO WORLD")]); faux.setResponses([fauxAssistantMessage("HELLO WORLD")]);
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: "You are a helpful assistant. Follow instructions exactly.", systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
model: faux.getModel(), model: faux.getModel(),
@@ -343,6 +353,7 @@ describe("Agent.continue() with faux provider", () => {
const model = faux.getModel(); const model = faux.getModel();
faux.setResponses([fauxAssistantMessage("The answer is 8.")]); faux.setResponses([fauxAssistantMessage("The answer is 8.")]);
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
systemPrompt: systemPrompt:
"You are a helpful assistant. After getting a calculation result, state the answer clearly.", "You are a helpful assistant. After getting a calculation result, state the answer clearly.",
@@ -426,7 +426,7 @@ export class AgentSession {
headers?: Record<string, string>; headers?: Record<string, string>;
env?: Record<string, string>; env?: Record<string, string>;
}> { }> {
if (this.agent.streamFn === streamSimple) { if (this.agent.streamFunction === streamSimple) {
return this._getRequiredRequestAuth(model); return this._getRequiredRequestAuth(model);
} }
@@ -1836,7 +1836,7 @@ export class AgentSession {
customInstructions, customInstructions,
this._compactionAbortController.signal, this._compactionAbortController.signal,
this.thinkingLevel, this.thinkingLevel,
this.agent.streamFn, this.agent.streamFunction,
env, env,
); );
summary = result.summary; summary = result.summary;
@@ -2037,7 +2037,7 @@ export class AgentSession {
let apiKey: string | undefined; let apiKey: string | undefined;
let headers: Record<string, string> | undefined; let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined; let env: Record<string, string> | undefined;
if (this.agent.streamFn === streamSimple) { if (this.agent.streamFunction === streamSimple) {
const authResult = await this._modelRuntime.getAuth(this.model); const authResult = await this._modelRuntime.getAuth(this.model);
if (!authResult?.auth.apiKey) return false; if (!authResult?.auth.apiKey) return false;
apiKey = authResult.auth.apiKey; apiKey = authResult.auth.apiKey;
@@ -2112,7 +2112,7 @@ export class AgentSession {
undefined, undefined,
this._autoCompactionAbortController.signal, this._autoCompactionAbortController.signal,
this.thinkingLevel, this.thinkingLevel,
this.agent.streamFn, this.agent.streamFunction,
env, env,
); );
summary = compactResult.summary; summary = compactResult.summary;
@@ -2933,7 +2933,7 @@ export class AgentSession {
customInstructions, customInstructions,
replaceInstructions, replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens, reserveTokens: branchSummarySettings.reserveTokens,
streamFn: this.agent.streamFn, streamFn: this.agent.streamFunction,
}); });
if (result.aborted) { if (result.aborted) {
return { cancelled: true, aborted: true }; return { cancelled: true, aborted: true };
+1 -1
View File
@@ -294,7 +294,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
tools: [], tools: [],
}, },
convertToLlm: convertToLlmWithBlockImages, convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => { streamFunction: async (model, context, options) => {
const providerRetrySettings = settingsManager.getProviderRetrySettings(); const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". // SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core"; import { Agent } from "@earendil-works/pi-agent-core";
import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts"; import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts"; import { AuthStorage } from "../src/core/auth-storage.ts";
@@ -21,10 +21,10 @@ describe("AgentSession auto-compaction queue resume", () => {
beforeEach(async () => { beforeEach(async () => {
tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`); tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`);
mkdirSync(tempDir, { recursive: true }); mkdirSync(tempDir, { recursive: true });
vi.useFakeTimers();
const model = getModel("anthropic", "claude-sonnet-4-5")!; const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({ const agent = new Agent({
streamFunction: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "Test", systemPrompt: "Test",
@@ -50,7 +50,6 @@ describe("AgentSession auto-compaction queue resume", () => {
afterEach(() => { afterEach(() => {
session.dispose(); session.dispose();
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
if (tempDir && existsSync(tempDir)) { if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true }); rmSync(tempDir, { recursive: true });
@@ -84,9 +83,9 @@ describe("AgentSession auto-compaction queue resume", () => {
timestamp: now - 500, timestamp: now - 500,
}); });
session.agent.state.messages = sessionManager.buildSessionContext().messages; session.agent.state.messages = sessionManager.buildSessionContext().messages;
session.agent.streamFn = (summaryModel) => { session.agent.streamFunction = (summaryModel) => {
const stream = createAssistantMessageEventStream(); const stream = createAssistantMessageEventStream();
queueMicrotask(() => { void Promise.resolve().then(() => {
stream.push({ stream.push({
type: "done", type: "done",
reason: "stop", reason: "stop",
@@ -12,7 +12,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core"; 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 { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts"; import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.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 model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({ const agent = new Agent({
getApiKey: () => API_KEY, getApiKey: () => API_KEY,
streamFunction: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant. Be concise.", systemPrompt: "You are a helpful assistant. Be concise.",
@@ -90,7 +90,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFn: (_model, _context, options) => { streamFunction: (_model, _context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFn: (_model, context, options) => { streamFunction: (_model, context, options) => {
abortSignal = options?.signal; abortSignal = options?.signal;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFn: () => { streamFunction: () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") }); stream.push({ type: "start", partial: createAssistantMessage("") });
@@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [tool], tools: [tool],
}, },
streamFn: async (_model, context) => { streamFunction: async (_model, context) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length; const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
@@ -508,7 +508,7 @@ describe("AgentSession concurrent prompt guard", () => {
systemPrompt: "Test", systemPrompt: "Test",
tools: [tool], tools: [tool],
}, },
streamFn: async (_model, context) => { streamFunction: async (_model, context) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
const hasToolResult = context.messages.some((message) => message.role === "toolResult"); const hasToolResult = context.messages.some((message) => message.role === "toolResult");
@@ -84,7 +84,7 @@ describe("AgentSession dynamic provider registration", () => {
session: Awaited<ReturnType<typeof createSession>>, session: Awaited<ReturnType<typeof createSession>>,
): Promise<string | undefined> { ): Promise<string | undefined> {
let baseUrl: string | undefined; let baseUrl: string | undefined;
session.agent.streamFn = async (model) => { session.agent.streamFunction = async (model) => {
baseUrl = model.baseUrl; baseUrl = model.baseUrl;
throw new Error("stop"); throw new Error("stop");
}; };
@@ -82,7 +82,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({ const agent = new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] }, initialState: { model, systemPrompt: "Test", tools: [] },
streamFn: () => { streamFunction: () => {
callCount++; callCount++;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -203,7 +203,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({ const agent = new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] }, initialState: { model, systemPrompt: "Test", tools: [] },
streamFn, streamFunction: streamFn,
}); });
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
@@ -255,7 +255,7 @@ describe("AgentSession retry", () => {
const agent = new Agent({ const agent = new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
initialState: { model, systemPrompt: "Test", tools: [] }, initialState: { model, systemPrompt: "Test", tools: [] },
streamFn: () => { streamFunction: () => {
callCount++; callCount++;
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
@@ -1,5 +1,11 @@
import { Agent } from "@earendil-works/pi-agent-core"; 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 { describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts"; import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts"; import { AuthStorage } from "../src/core/auth-storage.ts";
@@ -69,6 +75,7 @@ async function createSession() {
const session = new AgentSession({ const session = new AgentSession({
agent: new Agent({ agent: new Agent({
getApiKey: () => "test-key", getApiKey: () => "test-key",
streamFunction: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core"; 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 { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts"; import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.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 model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({ const agent = new Agent({
getApiKey: () => API_KEY, getApiKey: () => API_KEY,
streamFunction: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: "You are a helpful assistant. Be concise.", systemPrompt: "You are a helpful assistant. Be concise.",
@@ -114,7 +114,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs:
systemPrompt: "Test", systemPrompt: "Test",
tools: [], tools: [],
}, },
streamFn: (_model, _context, _options) => { streamFunction: (_model, _context, _options) => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") }); stream.push({ type: "start", partial: createAssistantMessage("") });
@@ -126,7 +126,7 @@ describe("createAgentSession provider attribution headers", () => {
}); });
try { try {
const stream = await session.agent.streamFn( const stream = await session.agent.streamFunction(
model, model,
{ messages: [] }, { messages: [] },
{ {
@@ -114,7 +114,7 @@ describe("createAgentSession stream options", () => {
}); });
try { try {
const stream = await session.agent.streamFn(model, { messages: [] }, requestOptions); const stream = await session.agent.streamFunction(model, { messages: [] }, requestOptions);
await stream.result(); await stream.result();
return capturedOptions; return capturedOptions;
} finally { } finally {
@@ -49,7 +49,7 @@ function createAssistant(
function useSummaryStreamFn(harness: Harness, summary: string): () => number { function useSummaryStreamFn(harness: Harness, summary: string): () => number {
let callCount = 0; let callCount = 0;
harness.session.agent.streamFn = (model) => { harness.session.agent.streamFunction = (model) => {
callCount++; callCount++;
const stream = createAssistantMessageEventStream(); const stream = createAssistantMessageEventStream();
queueMicrotask(() => { queueMicrotask(() => {
+2 -1
View File
@@ -14,7 +14,7 @@ import type {
FauxResponseStep, FauxResponseStep,
Model, Model,
} from "@earendil-works/pi-ai/compat"; } 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 { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.ts";
import { AuthStorage } from "../../src/core/auth-storage.ts"; import { AuthStorage } from "../../src/core/auth-storage.ts";
import type { ExtensionRunner } from "../../src/core/extensions/index.ts"; import type { ExtensionRunner } from "../../src/core/extensions/index.ts";
@@ -137,6 +137,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
const agent = new Agent({ const agent = new Agent({
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined), getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
streamFunction: streamSimple,
initialState: { initialState: {
model, model,
systemPrompt: options.systemPrompt ?? "You are a test assistant.", systemPrompt: options.systemPrompt ?? "You are a test assistant.",
@@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core"; 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 { afterEach, describe, expect, it } from "vitest";
import { AgentSession } from "../../../src/core/agent-session.ts"; import { AgentSession } from "../../../src/core/agent-session.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts"; import { AuthStorage } from "../../../src/core/auth-storage.ts";
@@ -61,6 +61,7 @@ describe("regression #5596: missing configured theme export", () => {
tools: [], tools: [],
}, },
convertToLlm, convertToLlm,
streamFunction: streamSimple,
}); });
const session = new AgentSession({ const session = new AgentSession({
agent, agent,
@@ -17,7 +17,7 @@ describe("issue #6324 branch summary ambient auth", () => {
harnesses.push(harness); harnesses.push(harness);
let streamCallCount = 0; let streamCallCount = 0;
harness.session.agent.streamFn = (model, _context, options) => { harness.session.agent.streamFunction = (model, _context, options) => {
streamCallCount++; streamCallCount++;
expect(options?.apiKey).toBeUndefined(); expect(options?.apiKey).toBeUndefined();
+1 -1
View File
@@ -378,7 +378,7 @@ async function createHarnessWithResourceLoader(
systemPrompt: options.systemPrompt ?? "You are a test assistant.", systemPrompt: options.systemPrompt ?? "You are a test assistant.",
tools: options.tools ?? [], tools: options.tools ?? [],
}, },
streamFn, streamFunction: streamFn,
}); });
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
+2 -1
View File
@@ -8,7 +8,7 @@ import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core"; import { Agent } from "@earendil-works/pi-agent-core";
import type { OAuthCredentials } from "@earendil-works/pi-ai"; 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 { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import { AgentSession } from "../src/core/agent-session.ts"; import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.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.", systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.",
tools: createCodingTools(process.cwd()), tools: createCodingTools(process.cwd()),
}, },
streamFunction: streamSimple,
}); });
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir); const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
+13
View File
@@ -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),
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai"; 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 { import {
Agent, Agent,
bashExecutionToText, bashExecutionToText,
@@ -24,7 +24,7 @@ const model = getModel("google", "gemini-2.5-flash");
const schema = Type.Object({ prompt: Type.String() }); const schema = Type.Object({ prompt: Type.String() });
const stream = createAssistantMessageEventStream(); const stream = createAssistantMessageEventStream();
const agent = new Agent({ initialState: { model } }); const agent = new Agent({ initialState: { model }, streamFunction: streamSimple });
agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 }); agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 });
const repo = new InMemorySessionRepo(); const repo = new InMemorySessionRepo();
const result = getOrThrow(ok({ value: 1 })); const result = getOrThrow(ok({ value: 1 }));
+58
View File
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
import { build } from "esbuild"; import { build } from "esbuild";
const outputPath = join(tmpdir(), "pi-browser-smoke.js"); 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 errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log");
const generatedCatalogDataDir = join(process.cwd(), "packages/ai/src/providers/data"); 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 { try {
await build({ await build({
entryPoints: ["scripts/browser-smoke-entry.ts"], entryPoints: ["scripts/browser-smoke-entry.ts"],
@@ -33,6 +50,47 @@ try {
outfile: outputPath, outfile: outputPath,
plugins: [generatedCatalogDataPlugin], 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); process.exit(0);
} catch (error) { } catch (error) {
let detailedErrors = ""; let detailedErrors = "";