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

@ -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"');
}
});
});