feat(agent): merge main into agent-harness-tools

This commit is contained in:
Mario Zechner
2026-07-22 11:56:35 +02:00
93 changed files with 2398 additions and 241 deletions
+10
View File
@@ -10,6 +10,16 @@
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`.
## [0.81.1] - 2026-07-21
### Added
- Added retry policy support and lifecycle events for compaction and branch-summary operations in `AgentHarness` ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)).
### Fixed
- Restored the `Agent` `streamFn` option and host-configurable fallback for omitted agent-loop stream functions without reintroducing a `pi-ai/compat` dependency ([#6915](https://github.com/earendil-works/pi/issues/6915)).
## [0.81.0] - 2026-07-21
### Breaking Changes
+7 -7
View File
@@ -29,7 +29,7 @@ const agent = new Agent({
systemPrompt: "You are a helpful assistant.",
model,
},
streamFunction: models.streamSimple.bind(models),
streamFn: models.streamSimple.bind(models),
});
agent.subscribe((event) => {
@@ -199,7 +199,7 @@ const agent = new Agent({
followUpMode: "one-at-a-time",
// Required stream function
streamFunction: models.streamSimple.bind(models),
streamFn: models.streamSimple.bind(models),
// Session ID for provider caching
sessionId: "session-123",
@@ -386,7 +386,7 @@ Handle custom types in `convertToLlm`:
```typescript
const agent = new Agent({
streamFunction: models.streamSimple.bind(models),
streamFn: models.streamSimple.bind(models),
convertToLlm: (messages) => messages.flatMap(m => {
if (m.role === "notification") return []; // Filter out
return [m];
@@ -457,7 +457,7 @@ For browser apps that proxy through a backend:
import { Agent, streamProxy } from "@earendil-works/pi-agent-core";
const agent = new Agent({
streamFunction: (model, context, options) =>
streamFn: (model, context, options) =>
streamProxy(model, context, {
...options,
authToken: "...",
@@ -489,13 +489,13 @@ const config: AgentLoopConfig = {
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
const streamFunction = models.streamSimple.bind(models);
for await (const event of agentLoop([userMessage], context, config, undefined, streamFunction)) {
const streamFn = models.streamSimple.bind(models);
for await (const event of agentLoop([userMessage], context, config, undefined, streamFn)) {
console.log(event.type);
}
// Continue from existing context
for await (const event of agentLoopContinue(context, config, undefined, streamFunction)) {
for await (const event of agentLoopContinue(context, config, undefined, streamFn)) {
console.log(event.type);
}
```
+10
View File
@@ -183,6 +183,16 @@ Summary:
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
### Summarization retry events
When the harness is configured with a retry policy, generated compaction and branch-summary requests emit retry lifecycle events for transient provider errors:
- `retry_scheduled`: a retry was scheduled. Includes `operation: "compaction" | "branch_summary"`, `attempt`, `maxAttempts`, `delayMs`, and `errorMessage`.
- `retry_attempt_start`: the backoff delay completed and the retried summarization request is starting. Includes `operation`.
- `retry_finished`: the retry loop finished after success, exhaustion, or abort. Includes `operation`.
These events are observational and do not accept hook results.
## Planned session facade
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.81.0",
"version": "0.81.1",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@@ -29,7 +29,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.81.0",
"@earendil-works/pi-ai": "^0.81.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
+9 -8
View File
@@ -10,6 +10,7 @@ import {
type ToolResultMessage,
validateToolArguments,
} from "@earendil-works/pi-ai";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type {
AgentContext,
AgentEvent,
@@ -32,7 +33,7 @@ export function agentLoop(
context: AgentContext,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
streamFunction: StreamFn,
streamFn: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> {
const stream = createAgentStream();
@@ -44,7 +45,7 @@ export function agentLoop(
stream.push(event);
},
signal,
streamFunction,
streamFn,
).then((messages) => {
stream.end(messages);
});
@@ -64,7 +65,7 @@ export function agentLoopContinue(
context: AgentContext,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
streamFunction: StreamFn,
streamFn: StreamFn,
): EventStream<AgentEvent, AgentMessage[]> {
if (context.messages.length === 0) {
throw new Error("Cannot continue: no messages in context");
@@ -83,7 +84,7 @@ export function agentLoopContinue(
stream.push(event);
},
signal,
streamFunction,
streamFn,
).then((messages) => {
stream.end(messages);
});
@@ -97,7 +98,7 @@ export async function runAgentLoop(
config: AgentLoopConfig,
emit: AgentEventSink,
signal: AbortSignal | undefined,
streamFunction: StreamFn,
streamFn: StreamFn,
): Promise<AgentMessage[]> {
const newMessages: AgentMessage[] = [...prompts];
const currentContext: AgentContext = {
@@ -112,7 +113,7 @@ export async function runAgentLoop(
await emit({ type: "message_end", message: prompt });
}
await runLoop(currentContext, newMessages, config, signal, emit, streamFunction);
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
return newMessages;
}
@@ -121,7 +122,7 @@ export async function runAgentLoopContinue(
config: AgentLoopConfig,
emit: AgentEventSink,
signal: AbortSignal | undefined,
streamFunction: StreamFn,
streamFn: StreamFn,
): Promise<AgentMessage[]> {
if (context.messages.length === 0) {
throw new Error("Cannot continue: no messages in context");
@@ -137,7 +138,7 @@ export async function runAgentLoopContinue(
await emit({ type: "agent_start" });
await emit({ type: "turn_start" });
await runLoop(currentContext, newMessages, config, signal, emit, streamFunction);
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
return newMessages;
}
+22 -19
View File
@@ -8,6 +8,7 @@ import type {
Transport,
} from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type {
AfterToolCallContext,
AfterToolCallResult,
@@ -97,7 +98,7 @@ export interface AgentOptions {
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
streamFunction: StreamFn;
streamFn: StreamFn;
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
onPayload?: SimpleStreamOptions["onPayload"];
onResponse?: SimpleStreamOptions["onResponse"];
@@ -207,24 +208,26 @@ export class Agent {
public toolExecution: ToolExecutionMode;
constructor(options: AgentOptions) {
this._state = createMutableAgentState(options.initialState);
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
this.transformContext = options.transformContext;
this.streamFunction = options.streamFunction;
this.getApiKey = options.getApiKey;
this.onPayload = options.onPayload;
this.onResponse = options.onResponse;
this.beforeToolCall = options.beforeToolCall;
this.afterToolCall = options.afterToolCall;
this.prepareNextTurn = options.prepareNextTurn;
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext;
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
this.sessionId = options.sessionId;
this.thinkingBudgets = options.thinkingBudgets;
this.transport = options.transport ?? "auto";
this.maxRetryDelayMs = options.maxRetryDelayMs;
this.toolExecution = options.toolExecution ?? "parallel";
// Older compiled consumers may omit options or streamFn even though the current API requires them.
const runtimeOptions: Partial<AgentOptions> = options ?? {};
this._state = createMutableAgentState(runtimeOptions.initialState);
this.convertToLlm = runtimeOptions.convertToLlm ?? defaultConvertToLlm;
this.transformContext = runtimeOptions.transformContext;
this.streamFunction = runtimeOptions.streamFn ?? getDefaultStreamFn();
this.getApiKey = runtimeOptions.getApiKey;
this.onPayload = runtimeOptions.onPayload;
this.onResponse = runtimeOptions.onResponse;
this.beforeToolCall = runtimeOptions.beforeToolCall;
this.afterToolCall = runtimeOptions.afterToolCall;
this.prepareNextTurn = runtimeOptions.prepareNextTurn;
this.prepareNextTurnWithContext = runtimeOptions.prepareNextTurnWithContext;
this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time");
this.sessionId = runtimeOptions.sessionId;
this.thinkingBudgets = runtimeOptions.thinkingBudgets;
this.transport = runtimeOptions.transport ?? "auto";
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
}
/**
+25 -1
View File
@@ -4,6 +4,8 @@ import {
type ImageContent,
type Model,
type Models,
type RetryCallbacks,
type RetryPolicy,
type UserMessage,
} from "@earendil-works/pi-ai";
import { runAgentLoop } from "../agent-loop.ts";
@@ -182,6 +184,7 @@ export class AgentHarness<
private systemPrompt: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>["systemPrompt"];
private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
private streamOptions: AgentHarnessStreamOptions;
private retry: RetryPolicy | undefined;
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
private tools = new Map<string, TTool>();
private activeToolNames: string[];
@@ -197,6 +200,7 @@ export class AgentHarness<
this.models = options.models;
this.resources = options.resources ?? {};
this.streamOptions = cloneStreamOptions(options.streamOptions);
this.retry = options.retry;
this.systemPrompt = options.systemPrompt;
this.toolContext = options.toolContext;
this.validateUniqueNames(
@@ -260,6 +264,15 @@ export class AgentHarness<
return lastResult;
}
private retryCallbacks(operation: "compaction" | "branch_summary"): RetryCallbacks {
return {
onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) =>
this.emitOwn({ type: "retry_scheduled", operation, attempt, maxAttempts, delayMs, errorMessage }),
onRetryAttemptStart: () => this.emitOwn({ type: "retry_attempt_start", operation }),
onRetryFinished: () => this.emitOwn({ type: "retry_finished", operation }),
};
}
private async emitBeforeProviderRequest(
model: Model<any>,
sessionId: string,
@@ -741,7 +754,16 @@ export class AgentHarness<
const provided = hookResult?.compaction;
const compactResult = provided
? { ok: true as const, value: provided }
: await compact(preparation, this.models, model, customInstructions, undefined, this.thinkingLevel);
: await compact(
preparation,
this.models,
model,
customInstructions,
undefined,
this.thinkingLevel,
this.retry,
this.retryCallbacks("compaction"),
);
if (!compactResult.ok) throw compactResult.error;
const result = compactResult.value;
const entryId = await this.session.appendCompaction(
@@ -803,6 +825,8 @@ export class AgentHarness<
signal: new AbortController().signal,
customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
retry: this.retry,
callbacks: this.retryCallbacks("branch_summary"),
});
if (!branchSummary.ok) {
if (branchSummary.error.code === "aborted") return { cancelled: true };
@@ -1,4 +1,4 @@
import { contentText, type Model, type Models } from "@earendil-works/pi-ai";
import { contentText, type Model, type Models, type RetryCallbacks, type RetryPolicy } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.ts";
import {
@@ -9,7 +9,7 @@ import {
} from "../messages.ts";
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
import { completeSimpleWithRetries, estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
@@ -61,6 +61,10 @@ export interface GenerateBranchSummaryOptions {
replaceInstructions?: boolean;
/** Tokens reserved for prompt and model output. Defaults to 16384. */
reserveTokens?: number;
/** Optional retry policy for transient summarization errors. */
retry?: RetryPolicy;
/** Optional callbacks for retry reporting. */
callbacks?: RetryCallbacks;
}
/** Collect entries that should be summarized before navigating to a different session tree entry. */
@@ -200,7 +204,16 @@ export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { models, model, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
const {
models,
model,
signal,
customInstructions,
replaceInstructions,
reserveTokens = 16384,
retry,
callbacks,
} = options;
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
@@ -228,10 +241,13 @@ export async function generateBranchSummary(
timestamp: Date.now(),
},
];
const response = await models.completeSimple(
const response = await completeSimpleWithRetries(
models,
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ signal, maxTokens: 2048 },
retry,
callbacks,
);
if (response.stopReason === "aborted") {
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
@@ -1,9 +1,14 @@
import {
type AssistantMessage,
type Context,
contentText,
type ImageContent,
type Model,
type Models,
type RetryCallbacks,
type RetryPolicy,
retryAssistantCall,
type SimpleStreamOptions,
type TextContent,
type Usage,
} from "@earendil-works/pi-ai";
@@ -109,6 +114,17 @@ export interface CompactionResult<T = unknown> {
details?: T;
}
export async function completeSimpleWithRetries(
models: Models,
model: Model<any>,
context: Context,
options: SimpleStreamOptions,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
return retryAssistantCall(() => models.completeSimple(model, context, options), retry, options.signal, callbacks);
}
function combineUsage(first: Usage, second: Usage): Usage {
return {
input: first.input + second.input,
@@ -501,6 +517,8 @@ export async function generateSummary(
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<string, CompactionError>> {
const result = await generateSummaryWithUsage(
currentMessages,
@@ -511,6 +529,8 @@ export async function generateSummary(
customInstructions,
previousSummary,
thinkingLevel,
retry,
callbacks,
);
return result.ok ? ok(result.value.text) : err(result.error);
}
@@ -525,6 +545,8 @@ export async function generateSummaryWithUsage(
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
@@ -555,10 +577,13 @@ export async function generateSummaryWithUsage(
? { maxTokens, signal, reasoning: thinkingLevel }
: { maxTokens, signal };
const response = await models.completeSimple(
const response = await completeSimpleWithRetries(
models,
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
retry,
callbacks,
);
if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
@@ -700,6 +725,8 @@ export async function compact(
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<CompactionResult, CompactionError>> {
const {
firstKeptEntryId,
@@ -733,6 +760,8 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
retry,
callbacks,
);
if (!historyResult.ok) return err(historyResult.error);
historyText = historyResult.value.text;
@@ -745,6 +774,8 @@ export async function compact(
settings.reserveTokens,
signal,
thinkingLevel,
retry,
callbacks,
);
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
@@ -761,6 +792,8 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
retry,
callbacks,
);
if (!summaryResult.ok) return err(summaryResult.error);
summary = summaryResult.value.text;
@@ -786,6 +819,8 @@ async function generateTurnPrefixSummary(
reserveTokens: number,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
@@ -802,12 +837,17 @@ async function generateTurnPrefixSummary(
},
];
const response = await models.completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
const completionOptions =
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, reasoning: thinkingLevel }
: { maxTokens, signal },
: { maxTokens, signal };
const response = await completeSimpleWithRetries(
models,
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
retry,
callbacks,
);
if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
+28
View File
@@ -2,6 +2,7 @@ import type {
ImageContent,
Model,
Models,
RetryPolicy,
SimpleStreamOptions,
TextContent,
Transport,
@@ -659,6 +660,25 @@ export interface SessionTreeEvent {
fromHook?: boolean;
}
export interface RetryScheduledEvent {
type: "retry_scheduled";
operation: "compaction" | "branch_summary";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
export interface RetryAttemptStartEvent {
type: "retry_attempt_start";
operation: "compaction" | "branch_summary";
}
export interface RetryFinishedEvent {
type: "retry_finished";
operation: "compaction" | "branch_summary";
}
export interface ModelUpdateEvent {
type: "model_update";
model: Model<any>;
@@ -709,6 +729,9 @@ export type AgentHarnessOwnEvent<
| SessionCompactEvent
| SessionBeforeTreeEvent
| SessionTreeEvent
| RetryScheduledEvent
| RetryAttemptStartEvent
| RetryFinishedEvent
| ModelUpdateEvent
| ThinkingLevelUpdateEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
@@ -778,6 +801,9 @@ export type AgentHarnessEventResultMap = {
session_compact: undefined;
session_before_tree: SessionBeforeTreeResult | undefined;
session_tree: undefined;
retry_scheduled: undefined;
retry_attempt_start: undefined;
retry_finished: undefined;
model_update: undefined;
thinking_level_update: undefined;
resources_update: undefined;
@@ -897,6 +923,8 @@ export interface AgentHarnessOptions<
}) => string | Promise<string>);
/** Curated stream/provider request options. Snapshotted at turn start. */
streamOptions?: AgentHarnessStreamOptions;
/** Optional retry policy for generated compaction and branch-summary requests. */
retry?: RetryPolicy;
model: Model<any>;
thinkingLevel?: ThinkingLevel;
activeToolNames?: string[];
+2
View File
@@ -44,5 +44,7 @@ export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
// Proxy utilities
export * from "./proxy.ts";
// Stream defaults
export { setDefaultStreamFn } from "./stream-fn.ts";
// Types
export * from "./types.ts";
+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.
* We reconstruct the partial message client-side.
*
* Use this as the `streamFunction` option when creating an Agent that needs to go through a proxy.
* Use this as the `streamFn` option when creating an Agent that needs to go through a proxy.
*
* @example
* ```typescript
* const agent = new Agent({
* streamFunction: (model, context, options) =>
* streamFn: (model, context, options) =>
* streamProxy(model, context, {
* ...options,
* authToken: await getAuthToken(),
+20
View File
@@ -0,0 +1,20 @@
import type { StreamFn } from "./types.ts";
let defaultStreamFn: StreamFn | undefined;
/**
* Configure the fallback used by Agent and low-level loops when callers omit streamFn.
*
* Hosts that provide a default model runtime can install its stream function here
* without making pi-agent-core depend on a provider catalog or compatibility layer.
*/
export function setDefaultStreamFn(streamFn: StreamFn | undefined): void {
defaultStreamFn = streamFn;
}
export function getDefaultStreamFn(): StreamFn {
if (!defaultStreamFn) {
throw new Error("No default stream function configured. Pass streamFn explicitly or call setDefaultStreamFn().");
}
return defaultStreamFn;
}
+35
View File
@@ -9,6 +9,7 @@ import {
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
import { setDefaultStreamFn } from "../src/index.ts";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
// Mock stream for testing - mimics MockAssistantStream
@@ -80,6 +81,40 @@ function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
}
describe("default stream function compatibility", () => {
it("uses the configured default when a legacy caller omits streamFn", async () => {
let calls = 0;
setDefaultStreamFn(() => {
calls++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "fallback" }]),
});
});
return stream;
});
try {
const context: AgentContext = { systemPrompt: "", messages: [], tools: [] };
const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };
const stream = Reflect.apply(agentLoop, undefined, [
[createUserMessage("Hello")],
context,
config,
undefined,
]) as ReturnType<typeof agentLoop>;
await stream.result();
expect(calls).toBe(1);
} finally {
setDefaultStreamFn(undefined);
}
});
});
describe("agentLoop with AgentMessage", () => {
it("should emit events with AgentMessage types", async () => {
const context: AgentContext = {
+48 -20
View File
@@ -1,7 +1,14 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback, type StreamFn } from "../src/index.ts";
import {
Agent,
type AgentEvent,
type AgentTool,
type AgentToolUpdateCallback,
type StreamFn,
setDefaultStreamFn,
} from "../src/index.ts";
// Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -75,8 +82,29 @@ function createDeferred(): {
}
describe("Agent", () => {
it("uses the configured default when a legacy caller omits streamFn", async () => {
let calls = 0;
setDefaultStreamFn(() => {
calls++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
const message = createAssistantMessage("fallback");
stream.push({ type: "done", reason: "stop", message });
});
return stream;
});
try {
const agent = Reflect.construct(Agent, [{}]) as Agent;
await agent.prompt("Hello");
expect(calls).toBe(1);
} finally {
setDefaultStreamFn(undefined);
}
});
it("should create an agent instance with default state", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction });
const agent = new Agent({ streamFn: unusedStreamFunction });
expect(agent.state).toBeDefined();
expect(agent.state.systemPrompt).toBe("");
@@ -93,7 +121,7 @@ describe("Agent", () => {
it("should create an agent instance with custom initial state", () => {
const customModel = getModel("openai", "gpt-4o-mini");
const agent = new Agent({
streamFunction: unusedStreamFunction,
streamFn: unusedStreamFunction,
initialState: {
systemPrompt: "You are a helpful assistant.",
model: customModel,
@@ -107,7 +135,7 @@ describe("Agent", () => {
});
it("should subscribe to events", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction });
const agent = new Agent({ streamFn: unusedStreamFunction });
let eventCount = 0;
const unsubscribe = agent.subscribe((_event) => {
@@ -130,7 +158,7 @@ describe("Agent", () => {
it("emits full lifecycle events for thrown run failures", async () => {
const agent = new Agent({
streamFunction: () => {
streamFn: () => {
throw new Error("provider exploded");
},
});
@@ -162,7 +190,7 @@ describe("Agent", () => {
it("should await async subscribers before prompt resolves", async () => {
const barrier = createDeferred();
const agent = new Agent({
streamFunction: () => {
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
@@ -200,7 +228,7 @@ describe("Agent", () => {
it("waitForIdle should wait for async subscribers", async () => {
const barrier = createDeferred();
const agent = new Agent({
streamFunction: () => {
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
@@ -235,7 +263,7 @@ describe("Agent", () => {
it("should pass the active abort signal to subscribers", async () => {
let receivedSignal: AbortSignal | undefined;
const agent = new Agent({
streamFunction: (_model, _context, options) => {
streamFn: (_model, _context, options) => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") });
@@ -298,7 +326,7 @@ describe("Agent", () => {
};
const agent = new Agent({
initialState: { tools: [tool] },
streamFunction: () => {
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
@@ -373,7 +401,7 @@ describe("Agent", () => {
};
const agent = new Agent({
initialState: { tools: [settledTool, slowTool] },
streamFunction: () => {
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
@@ -412,7 +440,7 @@ describe("Agent", () => {
});
it("should update state with mutators", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction });
const agent = new Agent({ streamFn: unusedStreamFunction });
// Test setSystemPrompt
agent.state.systemPrompt = "Custom prompt";
@@ -451,7 +479,7 @@ describe("Agent", () => {
});
it("should support steering message queue", async () => {
const agent = new Agent({ streamFunction: unusedStreamFunction });
const agent = new Agent({ streamFn: unusedStreamFunction });
const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() };
agent.steer(message);
@@ -461,7 +489,7 @@ describe("Agent", () => {
});
it("should support follow-up message queue", async () => {
const agent = new Agent({ streamFunction: unusedStreamFunction });
const agent = new Agent({ streamFn: unusedStreamFunction });
const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() };
agent.followUp(message);
@@ -471,7 +499,7 @@ describe("Agent", () => {
});
it("should handle abort controller", () => {
const agent = new Agent({ streamFunction: unusedStreamFunction });
const agent = new Agent({ streamFn: unusedStreamFunction });
// Should not throw even if nothing is running
expect(() => agent.abort()).not.toThrow();
@@ -481,7 +509,7 @@ describe("Agent", () => {
let abortSignal: AbortSignal | undefined;
const agent = new Agent({
// Use a stream function that responds to abort
streamFunction: (_model, _context, options) => {
streamFn: (_model, _context, options) => {
abortSignal = options?.signal;
const stream = new MockAssistantStream();
queueMicrotask(() => {
@@ -520,7 +548,7 @@ describe("Agent", () => {
it("should throw when continue() called while streaming", async () => {
let abortSignal: AbortSignal | undefined;
const agent = new Agent({
streamFunction: (_model, _context, options) => {
streamFn: (_model, _context, options) => {
abortSignal = options?.signal;
const stream = new MockAssistantStream();
queueMicrotask(() => {
@@ -555,7 +583,7 @@ describe("Agent", () => {
it("continue() should process queued follow-up messages after an assistant turn", async () => {
const agent = new Agent({
streamFunction: () => {
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
@@ -594,7 +622,7 @@ describe("Agent", () => {
it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => {
let responseCount = 0;
const agent = new Agent({
streamFunction: () => {
streamFn: () => {
const stream = new MockAssistantStream();
responseCount++;
queueMicrotask(() => {
@@ -652,7 +680,7 @@ describe("Agent", () => {
sawAbortSignal = signal instanceof AbortSignal;
return undefined;
},
streamFunction: () => {
streamFn: () => {
requestCount++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
@@ -680,7 +708,7 @@ describe("Agent", () => {
let receivedSessionId: string | undefined;
const agent = new Agent({
sessionId: "session-abc",
streamFunction: (_model, _context, options) => {
streamFn: (_model, _context, options) => {
receivedSessionId = options?.sessionId;
const stream = new MockAssistantStream();
queueMicrotask(() => {
+10 -10
View File
@@ -38,7 +38,7 @@ afterEach(() => {
async function basicPrompt(model: Model<string>) {
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "You are a helpful assistant. Keep your responses concise.",
model,
@@ -61,7 +61,7 @@ async function basicPrompt(model: Model<string>) {
async function toolExecution(model: Model<string>) {
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.",
model,
@@ -101,7 +101,7 @@ async function toolExecution(model: Model<string>) {
async function abortExecution(model: Model<string>) {
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "You are a helpful assistant.",
model,
@@ -129,7 +129,7 @@ async function abortExecution(model: Model<string>) {
async function stateUpdates(model: Model<string>) {
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "You are a helpful assistant.",
model,
@@ -162,7 +162,7 @@ async function stateUpdates(model: Model<string>) {
async function multiTurnConversation(model: Model<string>) {
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "You are a helpful assistant.",
model,
@@ -244,7 +244,7 @@ describe("Agent integration with faux provider", () => {
faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]);
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "You are a helpful assistant.",
model: faux.getModel(),
@@ -269,7 +269,7 @@ describe("Agent.continue() with faux provider", () => {
it("throws when no messages in context", async () => {
const faux = createFauxRegistration();
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "Test",
model: faux.getModel(),
@@ -283,7 +283,7 @@ describe("Agent.continue() with faux provider", () => {
const faux = createFauxRegistration();
const model = faux.getModel();
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "Test",
model,
@@ -318,7 +318,7 @@ describe("Agent.continue() with faux provider", () => {
const faux = createFauxRegistration();
faux.setResponses([fauxAssistantMessage("HELLO WORLD")]);
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
model: faux.getModel(),
@@ -353,7 +353,7 @@ describe("Agent.continue() with faux provider", () => {
const model = faux.getModel();
faux.setResponses([fauxAssistantMessage("The answer is 8.")]);
const agent = new Agent({
streamFunction: streamSimple,
streamFn: streamSimple,
initialState: {
systemPrompt:
"You are a helpful assistant. After getting a calculation result, state the answer clearly.",
@@ -605,6 +605,176 @@ describe("AgentHarness", () => {
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage);
});
describe("summarization retries", () => {
it("retries transient compaction errors and emits retry events", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
},
() => {
calls++;
return fauxAssistantMessage("## Goal\nRecovered summary");
},
]);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
const result = await harness.compact();
expect(result.summary).toContain("Recovered summary");
expect(calls).toBe(2);
expect(retryEvents).toEqual([
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_finished:compaction",
]);
});
it("does not retry non-retryable compaction errors", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" });
},
]);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(event.type);
}
});
await expect(harness.compact()).rejects.toThrow("insufficient_quota");
expect(calls).toBe(1);
expect(retryEvents).toEqual([]);
});
it("exhausts transient compaction retries after maxRetries failures", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses(
Array.from({ length: 4 }, () => () => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
}),
);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
await expect(harness.compact()).rejects.toThrow("terminated");
expect(calls).toBe(4);
expect(retryEvents).toEqual([
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_finished:compaction",
]);
});
it("retries transient branch summary errors and emits retry events", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
},
() => {
calls++;
return fauxAssistantMessage("## Goal\nRecovered branch summary");
},
]);
const session = new Session(new InMemorySessionStorage());
const targetId = await session.appendMessage(createUserMessage("first branch"));
await session.appendMessage(createAssistantMessage("first reply"));
await session.appendMessage(createUserMessage("abandoned work"));
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
const result = await harness.navigateTree(targetId, { summarize: true });
expect(result.summaryEntry?.summary).toContain("Recovered branch summary");
expect(calls).toBe(2);
expect(retryEvents).toEqual([
"retry_scheduled:branch_summary",
"retry_attempt_start:branch_summary",
"retry_finished:branch_summary",
]);
});
});
it("persists generated branch summary usage", async () => {
const registration = newFaux();
registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]);