Update READMEs: remove agent section from pi-ai, rewrite pi-agent-core

- Removed Agent API section from pi-ai README (moved to agent package)
- Rewrote agent package README for new architecture:
  - No more transports (ProviderTransport, AppTransport removed)
  - Uses streamFn directly with streamProxy for proxy usage
  - Documents convertToLlm and transformContext
  - Documents low-level agentLoop/agentLoopContinue API
  - Updated custom message types documentation
This commit is contained in:
Mario Zechner 2025-12-28 09:27:51 +01:00
parent a055fd4481
commit fa22595f25
3 changed files with 111 additions and 435 deletions

View file

@ -1,6 +1,6 @@
# @mariozechner/pi-agent-core
Stateful agent abstraction with transport layer for LLM interactions. Provides a reactive `Agent` class that manages conversation state, emits granular events, and supports pluggable transports for different deployment scenarios.
Stateful agent with tool execution, event streaming, and extensible message types. Built on `@mariozechner/pi-ai`.
## Installation
@ -11,12 +11,10 @@ npm install @mariozechner/pi-agent-core
## Quick Start
```typescript
import { Agent, ProviderTransport } from '@mariozechner/pi-agent-core';
import { Agent } from '@mariozechner/pi-agent-core';
import { getModel } from '@mariozechner/pi-ai';
// Create agent with direct provider transport
const agent = new Agent({
transport: new ProviderTransport(),
initialState: {
systemPrompt: 'You are a helpful assistant.',
model: getModel('anthropic', 'claude-sonnet-4-20250514'),
@ -29,37 +27,48 @@ const agent = new Agent({
agent.subscribe((event) => {
switch (event.type) {
case 'message_update':
// Stream text to UI
const content = event.message.content;
for (const block of content) {
if (block.type === 'text') console.log(block.text);
for (const block of event.message.content) {
if (block.type === 'text') process.stdout.write(block.text);
}
break;
case 'tool_execution_start':
console.log(`Calling ${event.toolName}...`);
break;
case 'tool_execution_update':
// Stream tool output (e.g., bash stdout)
console.log('Progress:', event.partialResult.content);
break;
case 'tool_execution_end':
console.log(`Result:`, event.result.content);
break;
}
});
// Send a prompt
await agent.prompt('Hello, world!');
// Access conversation state
console.log(agent.state.messages);
```
## Core Concepts
## Agent Options
### Agent State
```typescript
interface AgentOptions {
initialState?: Partial<AgentState>;
The `Agent` maintains reactive state:
// Converts AgentMessage[] to LLM-compatible Message[] before each call.
// Default: filters to user/assistant/toolResult and converts attachments.
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
// Transform context before convertToLlm (for pruning, injecting context, etc.)
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
// Queue mode: 'all' sends all queued messages, 'one-at-a-time' sends one per turn
queueMode?: 'all' | 'one-at-a-time';
// Custom stream function (for proxy backends). Default: streamSimple from pi-ai
streamFn?: StreamFn;
// Dynamic API key resolution (useful for expiring OAuth tokens)
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
}
```
## Agent State
```typescript
interface AgentState {
@ -67,17 +76,17 @@ interface AgentState {
model: Model<any>;
thinkingLevel: ThinkingLevel; // 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
tools: AgentTool<any>[];
messages: AppMessage[];
messages: AgentMessage[];
isStreaming: boolean;
streamMessage: Message | null;
streamMessage: AgentMessage | null;
pendingToolCalls: Set<string>;
error?: string;
}
```
### Events
## Events
Events provide fine-grained lifecycle information:
Events provide fine-grained lifecycle information for building reactive UIs:
| Event | Description |
|-------|-------------|
@ -89,30 +98,40 @@ Events provide fine-grained lifecycle information:
| `message_update` | Assistant message streaming update |
| `message_end` | Message completes |
| `tool_execution_start` | Tool begins execution |
| `tool_execution_update` | Tool streams progress (e.g., bash output) |
| `tool_execution_update` | Tool streams progress |
| `tool_execution_end` | Tool completes with result |
### Transports
## Custom Message Types
Transports abstract LLM communication:
- **`ProviderTransport`**: Direct API calls using `@mariozechner/pi-ai`
- **`AppTransport`**: Proxy through a backend server (for browser apps)
Extend `AgentMessage` for app-specific messages via declaration merging:
```typescript
// Direct provider access (Node.js)
const agent = new Agent({
transport: new ProviderTransport({
apiKey: process.env.ANTHROPIC_API_KEY
})
});
declare module '@mariozechner/pi-agent-core' {
interface CustomMessages {
artifact: { role: 'artifact'; code: string; language: string; timestamp: number };
notification: { role: 'notification'; text: string; timestamp: number };
}
}
// Via proxy (browser)
// AgentMessage now includes your custom types
const msg: AgentMessage = { role: 'artifact', code: '...', language: 'typescript', timestamp: Date.now() };
```
Custom messages are stored in state but filtered out by the default `convertToLlm`. Provide your own converter to handle them:
```typescript
const agent = new Agent({
transport: new AppTransport({
endpoint: '/api/agent',
headers: { 'Authorization': 'Bearer ...' }
})
convertToLlm: (messages) => {
return messages
.filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult')
.map(m => {
// Convert custom types or pass through
if (m.role === 'artifact') {
return { role: 'user', content: `[Artifact: ${m.language}]\n${m.code}`, timestamp: m.timestamp };
}
return m;
});
}
});
```
@ -121,20 +140,21 @@ const agent = new Agent({
Queue messages to inject at the next turn:
```typescript
// Queue mode: 'all' or 'one-at-a-time'
agent.setQueueMode('one-at-a-time');
// Queue a message while agent is streaming
await agent.queueMessage({
// Queue while agent is streaming
agent.queueMessage({
role: 'user',
content: 'Additional context...',
content: 'Stop what you are doing and focus on this instead.',
timestamp: Date.now()
});
```
When queued messages are detected after a tool call, remaining tool calls are skipped with error results.
## Attachments
User messages can include attachments:
User messages can include attachments (images, documents):
```typescript
await agent.prompt('What is in this image?', [{
@ -143,23 +163,57 @@ await agent.prompt('What is in this image?', [{
fileName: 'photo.jpg',
mimeType: 'image/jpeg',
size: 102400,
content: base64ImageData
content: base64ImageData // base64 without data URL prefix
}]);
```
## Custom Message Types
## Proxy Usage
Extend `AppMessage` for app-specific messages via declaration merging:
For browser apps that need to proxy through a backend, use `streamProxy`:
```typescript
declare module '@mariozechner/pi-agent-core' {
interface CustomMessages {
artifact: { role: 'artifact'; code: string; language: string };
}
import { Agent, streamProxy } from '@mariozechner/pi-agent-core';
const agent = new Agent({
streamFn: (model, context, options) => streamProxy(
'/api/agent',
model,
context,
options,
{ 'Authorization': 'Bearer ...' }
)
});
```
## Low-Level API
For more control, use `agentLoop` and `agentLoopContinue` directly:
```typescript
import { agentLoop, agentLoopContinue, AgentLoopContext, AgentLoopConfig } from '@mariozechner/pi-agent-core';
import { getModel, streamSimple } from '@mariozechner/pi-ai';
const context: AgentLoopContext = {
systemPrompt: 'You are helpful.',
messages: [],
tools: [myTool]
};
const config: AgentLoopConfig = {
model: getModel('openai', 'gpt-4o-mini'),
convertToLlm: (msgs) => msgs.filter(m => ['user', 'assistant', 'toolResult'].includes(m.role))
};
const userMessage = { role: 'user', content: 'Hello', timestamp: Date.now() };
for await (const event of agentLoop(userMessage, context, config, undefined, streamSimple)) {
console.log(event.type);
}
// Now AppMessage includes your custom type
const msg: AppMessage = { role: 'artifact', code: '...', language: 'typescript' };
// Continue from existing context (e.g., after overflow recovery)
for await (const event of agentLoopContinue(context, config, undefined, streamSimple)) {
console.log(event.type);
}
```
## API Reference
@ -169,9 +223,10 @@ const msg: AppMessage = { role: 'artifact', code: '...', language: 'typescript'
| Method | Description |
|--------|-------------|
| `prompt(text, attachments?)` | Send a user prompt |
| `continue()` | Continue from current context (for retry after overflow) |
| `prompt(message)` | Send an AgentMessage directly |
| `continue()` | Continue from current context |
| `abort()` | Abort current operation |
| `waitForIdle()` | Returns promise that resolves when agent is idle |
| `waitForIdle()` | Promise that resolves when agent is idle |
| `reset()` | Clear all messages and state |
| `subscribe(fn)` | Subscribe to events, returns unsubscribe function |
| `queueMessage(msg)` | Queue message for next turn |
@ -184,7 +239,7 @@ const msg: AppMessage = { role: 'artifact', code: '...', language: 'typescript'
| `setSystemPrompt(v)` | Update system prompt |
| `setModel(m)` | Switch model |
| `setThinkingLevel(l)` | Set reasoning level |
| `setQueueMode(m)` | Set queue mode ('all' or 'one-at-a-time') |
| `setQueueMode(m)` | Set queue mode |
| `setTools(t)` | Update available tools |
| `replaceMessages(ms)` | Replace all messages |
| `appendMessage(m)` | Append a message |

View file

@ -782,276 +782,6 @@ const continuation = await complete(newModel, restored);
> **Note**: If the context contains images (encoded as base64 as shown in the Image Input section), those will also be serialized.
## Agent API
The Agent API provides a higher-level interface for building agents with tools. It handles tool execution, validation, and provides detailed event streaming for interactive applications.
### Event System
The Agent API streams events during execution, allowing you to build reactive UIs and track agent progress. The agent processes prompts in **turns**, where each turn consists of:
1. An assistant message (the LLM's response)
2. Optional tool executions if the assistant calls tools
3. Tool result messages that are fed back to the LLM
This continues until the assistant produces a response without tool calls.
**Queued messages**: If you provide `getQueuedMessages` in the loop config, the agent checks for queued user messages after each tool call. When queued messages are found, any remaining tool calls from the current assistant message are skipped and returned as error tool results (`isError: true`) with the message "Skipped due to queued user message." The queued user messages are injected before the next assistant response.
### Event Flow Example
Given a prompt asking to calculate two expressions and sum them:
```typescript
import { agentLoop, AgentContext, calculateTool } from '@mariozechner/pi-ai';
const context: AgentContext = {
systemPrompt: 'You are a helpful math assistant.',
messages: [],
tools: [calculateTool]
};
const stream = agentLoop(
{ role: 'user', content: 'Calculate 15 * 20 and 30 * 40, then sum the results', timestamp: Date.now() },
context,
{ model: getModel('openai', 'gpt-4o-mini') }
);
// Expected event sequence:
// 1. agent_start - Agent begins processing
// 2. turn_start - First turn begins
// 3. message_start - User message starts
// 4. message_end - User message ends
// 5. message_start - Assistant message starts
// 6. message_update - Assistant streams response with tool calls
// 7. message_end - Assistant message ends
// 8. tool_execution_start - First calculation (15 * 20)
// 9. tool_execution_update - Streaming progress (for long-running tools)
// 10. tool_execution_end - Result: 300
// 11. tool_execution_start - Second calculation (30 * 40)
// 12. tool_execution_update - Streaming progress
// 13. tool_execution_end - Result: 1200
// 12. message_start - Tool result message for first calculation
// 13. message_end - Tool result message ends
// 14. message_start - Tool result message for second calculation
// 15. message_end - Tool result message ends
// 16. turn_end - First turn ends with 2 tool results
// 17. turn_start - Second turn begins
// 18. message_start - Assistant message starts
// 19. message_update - Assistant streams response with sum calculation
// 20. message_end - Assistant message ends
// 21. tool_execution_start - Sum calculation (300 + 1200)
// 22. tool_execution_end - Result: 1500
// 23. message_start - Tool result message for sum
// 24. message_end - Tool result message ends
// 25. turn_end - Second turn ends with 1 tool result
// 26. turn_start - Third turn begins
// 27. message_start - Final assistant message starts
// 28. message_update - Assistant streams final answer
// 29. message_end - Final assistant message ends
// 30. turn_end - Third turn ends with 0 tool results
// 31. agent_end - Agent completes with all messages
```
### Handling Events
```typescript
for await (const event of stream) {
switch (event.type) {
case 'agent_start':
console.log('Agent started');
break;
case 'turn_start':
console.log('New turn started');
break;
case 'message_start':
console.log(`${event.message.role} message started`);
break;
case 'message_update':
// Only for assistant messages during streaming
if (event.message.content.some(c => c.type === 'text')) {
console.log('Assistant:', event.message.content);
}
break;
case 'tool_execution_start':
console.log(`Calling ${event.toolName} with:`, event.args);
break;
case 'tool_execution_update':
// Streaming progress for long-running tools (e.g., bash output)
console.log(`Progress:`, event.partialResult.content);
break;
case 'tool_execution_end':
if (event.isError) {
console.error(`Tool failed:`, event.result);
} else {
console.log(`Tool result:`, event.result.content);
}
break;
case 'turn_end':
console.log(`Turn ended with ${event.toolResults.length} tool calls`);
break;
case 'agent_end':
console.log(`Agent completed with ${event.messages.length} new messages`);
break;
}
}
// Get all messages generated during this agent execution
// These include the user message and can be directly appended to context.messages
const messages = await stream.result();
context.messages.push(...messages);
```
### Continuing from Existing Context
Use `agentLoopContinue` to resume an agent loop without adding a new user message. This is useful for:
- Retrying after context overflow (after compaction reduces context size)
- Resuming from tool results that were added manually to the context
```typescript
import { agentLoopContinue, AgentContext } from '@mariozechner/pi-ai';
// Context already has messages - last must be 'user' or 'toolResult'
const context: AgentContext = {
systemPrompt: 'You are helpful.',
messages: [userMessage, assistantMessage, toolResult],
tools: [myTool]
};
// Continue processing from the tool result
const stream = agentLoopContinue(context, { model });
for await (const event of stream) {
// Same events as agentLoop, but no user message events emitted
}
const newMessages = await stream.result();
```
**Validation**: Throws if context has no messages or if the last message is an assistant message.
### Defining Tools with TypeBox
Tools use TypeBox schemas for runtime validation and type inference:
```typescript
import { Type, Static, AgentTool, AgentToolResult, StringEnum } from '@mariozechner/pi-ai';
const weatherSchema = Type.Object({
city: Type.String({ minLength: 1 }),
units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
});
type WeatherParams = Static<typeof weatherSchema>;
const weatherTool: AgentTool<typeof weatherSchema, { temp: number }> = {
label: 'Get Weather',
name: 'get_weather',
description: 'Get current weather for a city',
parameters: weatherSchema,
execute: async (toolCallId, args, signal, onUpdate) => {
// args is fully typed: { city: string, units: 'celsius' | 'fahrenheit' }
// signal: AbortSignal for cancellation
// onUpdate: Optional callback for streaming progress (emits tool_execution_update events)
const temp = Math.round(Math.random() * 30);
return {
content: [{ type: 'text', text: `Temperature in ${args.city}: ${temp}°${args.units[0].toUpperCase()}` }],
details: { temp }
};
}
};
// Tools can also return images alongside text
const chartTool: AgentTool<typeof Type.Object({ data: Type.Array(Type.Number()) })> = {
label: 'Generate Chart',
name: 'generate_chart',
description: 'Generate a chart from data',
parameters: Type.Object({ data: Type.Array(Type.Number()) }),
execute: async (toolCallId, args) => {
const chartImage = await generateChartImage(args.data);
return {
content: [
{ type: 'text', text: `Generated chart with ${args.data.length} data points` },
{ type: 'image', data: chartImage.toString('base64'), mimeType: 'image/png' }
]
};
}
};
// Tools can stream progress via the onUpdate callback (emits tool_execution_update events)
const bashTool: AgentTool<typeof Type.Object({ command: Type.String() }), { exitCode: number }> = {
label: 'Run Bash',
name: 'bash',
description: 'Execute a bash command',
parameters: Type.Object({ command: Type.String() }),
execute: async (toolCallId, args, signal, onUpdate) => {
let output = '';
const child = spawn('bash', ['-c', args.command]);
child.stdout.on('data', (data) => {
output += data.toString();
// Stream partial output to UI via tool_execution_update events
onUpdate?.({
content: [{ type: 'text', text: output }],
details: { exitCode: -1 } // Not finished yet
});
});
const exitCode = await new Promise<number>((resolve) => {
child.on('close', resolve);
});
return {
content: [{ type: 'text', text: output }],
details: { exitCode }
};
}
};
```
### Validation and Error Handling
Tool arguments are automatically validated using AJV with the TypeBox schema. Invalid arguments result in detailed error messages:
```typescript
// If the LLM calls with invalid arguments:
// get_weather({ city: '', units: 'kelvin' })
// The tool execution will fail with:
/*
Validation failed for tool "get_weather":
- city: must NOT have fewer than 1 characters
- units: must be equal to one of the allowed values
Received arguments:
{
"city": "",
"units": "kelvin"
}
*/
```
### Built-in Example Tools
The library includes example tools for common operations:
```typescript
import { calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai';
const context: AgentContext = {
systemPrompt: 'You are a helpful assistant.',
messages: [],
tools: [calculateTool, getCurrentTimeTool]
};
```
## Browser Usage
The library supports browser environments. You must pass the API key explicitly since environment variables are not available in browsers:

View file

@ -1,109 +0,0 @@
import { Type } from "@sinclair/typebox";
import AjvModule from "ajv";
import addFormatsModule from "ajv-formats";
// Handle both default and named exports
const Ajv = (AjvModule as any).default || AjvModule;
const addFormats = (addFormatsModule as any).default || addFormatsModule;
import { describe, expect, it } from "vitest";
import type { Tool } from "../src/types.js";
describe("Tool Validation with TypeBox and AJV", () => {
// Define a test tool with TypeBox schema
const testSchema = Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Integer({ minimum: 0, maximum: 150 }),
email: Type.String({ format: "email" }),
tags: Type.Optional(Type.Array(Type.String())),
});
const testTool = {
name: "test_tool",
description: "A test tool for validation",
parameters: testSchema,
} satisfies Tool<typeof testSchema>;
// Create AJV instance for validation
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
it("should validate correct input", () => {
const validInput = {
name: "John Doe",
age: 30,
email: "john@example.com",
tags: ["developer", "typescript"],
};
// Validate with AJV
const validate = ajv.compile(testTool.parameters);
const isValid = validate(validInput);
expect(isValid).toBe(true);
});
it("should reject invalid email", () => {
const invalidInput = {
name: "John Doe",
age: 30,
email: "not-an-email",
};
const validate = ajv.compile(testTool.parameters);
const isValid = validate(invalidInput);
expect(isValid).toBe(false);
expect(validate.errors).toBeDefined();
});
it("should reject missing required fields", () => {
const invalidInput = {
age: 30,
email: "john@example.com",
};
const validate = ajv.compile(testTool.parameters);
const isValid = validate(invalidInput);
expect(isValid).toBe(false);
expect(validate.errors).toBeDefined();
});
it("should reject invalid age", () => {
const invalidInput = {
name: "John Doe",
age: -5,
email: "john@example.com",
};
const validate = ajv.compile(testTool.parameters);
const isValid = validate(invalidInput);
expect(isValid).toBe(false);
expect(validate.errors).toBeDefined();
});
it("should format validation errors nicely", () => {
const invalidInput = {
name: "",
age: 200,
email: "invalid",
};
const validate = ajv.compile(testTool.parameters);
const isValid = validate(invalidInput);
expect(isValid).toBe(false);
expect(validate.errors).toBeDefined();
if (validate.errors) {
const errors = validate.errors
.map((err: any) => {
const path = err.instancePath ? err.instancePath.substring(1) : err.params.missingProperty || "root";
return ` - ${path}: ${err.message}`;
})
.join("\n");
// AJV error messages are different from Zod
expect(errors).toContain("name: must NOT have fewer than 1 characters");
expect(errors).toContain("age: must be <= 150");
expect(errors).toContain('email: must match format "email"');
}
});
});