chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
135
third_party/pi/packages/agent/README.md
vendored
135
third_party/pi/packages/agent/README.md
vendored
@@ -8,10 +8,6 @@ Stateful agent with tool execution and event streaming. Built on `@earendil-work
|
||||
npm install @earendil-works/pi-agent-core
|
||||
```
|
||||
|
||||
### SQLite session backends
|
||||
|
||||
The SQLite session backend and the `node:sqlite` adapter live in a separate package, `@earendil-works/pi-session-backend-sqlite-node`, so the core package does not pull in runtime builtins or native SQLite dependencies by default. The backend accepts a runtime-specific SQLite factory, allowing other session backends to ship as their own packages in the future.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
@@ -42,10 +38,6 @@ agent.subscribe((event) => {
|
||||
await agent.prompt("Hello!");
|
||||
```
|
||||
|
||||
## Experimental facet services
|
||||
|
||||
Transport-neutral facet-service primitives live in `@earendil-works/chord`. The agent core does not export the service runtime.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### AgentMessage vs LLM Message
|
||||
@@ -127,38 +119,68 @@ The `beforeToolCall` hook runs after `tool_execution_start` and validated argume
|
||||
|
||||
Tools, blocked `beforeToolCall` results, and `afterToolCall` overrides can return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally.
|
||||
|
||||
The `Agent` class accepts `shouldStopAfterTurn` in `AgentOptions`. Low-level loop callers can set the same hook in `AgentLoopConfig`:
|
||||
|
||||
```typescript
|
||||
const stream = agentLoop(
|
||||
prompts,
|
||||
context,
|
||||
{
|
||||
model,
|
||||
convertToLlm,
|
||||
shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => {
|
||||
return shouldCompactBeforeNextTurn(context.messages);
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
models.streamSimple.bind(models),
|
||||
);
|
||||
```
|
||||
|
||||
`shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason. The `AgentOptions` callback also receives the active run's `AbortSignal` as its second argument.
|
||||
|
||||
When you use the `Agent` class, assistant `message_end` processing is treated as a barrier before tool preflight begins. That means `beforeToolCall` sees agent state that already includes the assistant message that requested the tool call.
|
||||
|
||||
### continue() Event Sequence
|
||||
### Request preparation and turn finalization
|
||||
|
||||
`continue()` resumes from existing context without adding a new message. Use it for retries after errors.
|
||||
`prepareRequest` runs immediately before every conversational provider request, including the first. Use it to install canonical persisted context after pending input has been emitted:
|
||||
|
||||
```typescript
|
||||
// After an error, retry from current state
|
||||
await agent.continue();
|
||||
agent.prepareRequest = async ({ context }) => ({
|
||||
context: { ...context, messages: await session.loadModelContext() },
|
||||
});
|
||||
```
|
||||
|
||||
The last message in context must be `user` or `toolResult` (not `assistant`).
|
||||
`prepareRequest` does not poll queues. Steering queued while it runs waits for the next normal steering poll.
|
||||
|
||||
`finishTurn` runs after the assistant and all tool results are finalized, but before `turn_end`. It runs for normal, error, and aborted responses:
|
||||
|
||||
```typescript
|
||||
agent.finishTurn = async ({ message }) => {
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") return;
|
||||
if (shouldEndRun(message)) return { action: "end" };
|
||||
return needsAnotherResponse(message) ? { action: "continue" } : undefined;
|
||||
};
|
||||
```
|
||||
|
||||
Returning `undefined` preserves normal scheduling. `{ action: "end" }` stops immediately after `turn_end`, before polling steering or follow-up queues or preparing another request. On a normal response, `{ action: "continue" }` ensures one next provider request. If tool results, steering, or a follow-up already cause that request, they satisfy the decision and no additional request is made; otherwise the loop makes one context-only request. Error and aborted responses remain hard exits, so their decisions are ignored. `finishTurn` runs again after the next request, so returning `{ action: "continue" }` unconditionally creates an endless loop.
|
||||
|
||||
To migrate from the removed `shouldStopAfterTurn`, return `{ action: "end" }`. Guard error and aborted responses to preserve the old hook's normal-response-only invocation, especially when the predicate has side effects or assumes a successful response:
|
||||
|
||||
```typescript
|
||||
finishTurn: async (turn, signal) => {
|
||||
if (turn.message.stopReason === "error" || turn.message.stopReason === "aborted") return;
|
||||
return (await shouldStop(turn, signal)) ? { action: "end" } : undefined;
|
||||
},
|
||||
```
|
||||
|
||||
Each provider turn follows this lifecycle:
|
||||
|
||||
```text
|
||||
selected input events
|
||||
→ prepareRequest
|
||||
→ provider response
|
||||
→ tool results
|
||||
→ finishTurn
|
||||
→ turn_end
|
||||
→ existing continuation scheduling or agent_end
|
||||
```
|
||||
|
||||
### continue() and queued input
|
||||
|
||||
`continue()` retains its existing queue behavior. Empty and system-only transcripts reject without consuming queues. A non-assistant tail continues from existing context: steering is polled at startup, while follow-up input waits until the response naturally stops.
|
||||
|
||||
```typescript
|
||||
agent.followUp({ role: "user", content: "After the retry", timestamp: Date.now() });
|
||||
await agent.continue(); // The first request retries the existing user/toolResult tail.
|
||||
```
|
||||
|
||||
An assistant tail cannot be sent directly, so `continue()` falls back to one queued steering batch, then one queued follow-up batch. Queue mode still controls whether that selected batch contains one message or all messages:
|
||||
|
||||
```typescript
|
||||
agent.steer({ role: "user", content: "Continue from here", timestamp: Date.now() });
|
||||
await agent.continue(); // Uses the queued message only because the tail is assistant.
|
||||
```
|
||||
|
||||
### Event Types
|
||||
|
||||
@@ -181,7 +203,8 @@ The last message in context must be `user` or `toolResult` (not `assistant`).
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
// Initial state
|
||||
// Initial state. systemPrompt and tools become the leading system message
|
||||
// unless messages already starts with one.
|
||||
initialState: {
|
||||
systemPrompt: string,
|
||||
model: Model<any>,
|
||||
@@ -202,7 +225,8 @@ const agent = new Agent({
|
||||
// Follow-up mode: "one-at-a-time" (default) or "all"
|
||||
followUpMode: "one-at-a-time",
|
||||
|
||||
// Required stream function
|
||||
// Required stream function. Receives a TranscriptContext: the prompt and tools
|
||||
// are in the transcript's system messages, not on the context.
|
||||
streamFn: models.streamSimple.bind(models),
|
||||
|
||||
// Session ID for provider caching
|
||||
@@ -231,9 +255,16 @@ const agent = new Agent({
|
||||
}
|
||||
},
|
||||
|
||||
// Stop gracefully after a completed turn, before queued messages are polled.
|
||||
shouldStopAfterTurn: async ({ context }, signal) => {
|
||||
return shouldCompactBeforeNextTurn(context.messages, signal);
|
||||
// Rebuild finalized context immediately before every provider request.
|
||||
prepareRequest: async ({ context }, signal) => {
|
||||
return { context: { ...context, messages: await loadCanonicalMessages(signal) } };
|
||||
},
|
||||
|
||||
// Finalize a completed turn before turn_end is emitted.
|
||||
// `continue` ensures one next request; existing tool/queue scheduling can satisfy it.
|
||||
// `end` ends this run after turn_end without polling queues.
|
||||
finishTurn: async ({ message, toolResults }, signal) => {
|
||||
return shouldContinue(message, toolResults) ? { action: "continue" } : undefined;
|
||||
},
|
||||
|
||||
// Custom thinking budgets for token-based providers
|
||||
@@ -250,7 +281,6 @@ const agent = new Agent({
|
||||
|
||||
```typescript
|
||||
interface AgentState {
|
||||
systemPrompt: string;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
@@ -266,6 +296,17 @@ Access state via `agent.state`.
|
||||
|
||||
Assigning `agent.state.tools = [...]` or `agent.state.messages = [...]` copies the top-level array before storing it. Mutating the returned array mutates the current agent state.
|
||||
|
||||
The transcript owns the system prompt and tool declarations: the leading system message is the prompt, later system messages patch it (see `SystemMessage` in pi-ai). `agent.state.systemPrompt` is read-only and replays the transcript. `agent.state.tools` is the executable loadout; before every request the loop diffs it against the tools the transcript declares and, if they differ, announces the change in a system message (merged into a pending system message when one exists). pi-ai's `getCurrentSystemMessage(messages)` returns the replayed head, including declared tools, for any message array, including agent transcripts with custom message roles.
|
||||
|
||||
To change the prompt mid-conversation, append a system message with `content` (added instructions) or `sections` (named replacements):
|
||||
|
||||
```typescript
|
||||
await agent.prompt([
|
||||
{ role: "system", content: "", sections: { skills: "<skills>...</skills>" }, timestamp: Date.now() },
|
||||
{ role: "user", content: "Continue", timestamp: Date.now() },
|
||||
]);
|
||||
```
|
||||
|
||||
During streaming, `agent.state.streamingMessage` contains the current partial assistant message.
|
||||
|
||||
`agent.state.isStreaming` remains `true` until the run fully settles, including awaited `agent_end` subscribers.
|
||||
@@ -286,23 +327,26 @@ await agent.prompt("What's in this image?", [
|
||||
// AgentMessage directly
|
||||
await agent.prompt({ role: "user", content: "Hello", timestamp: Date.now() });
|
||||
|
||||
// Continue from current context (last message must be user or toolResult)
|
||||
// Continue existing non-assistant input; an assistant tail may use queued input as fallback
|
||||
await agent.continue();
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
agent.state.systemPrompt = "New prompt";
|
||||
agent.state.model = getModel("openai", "gpt-4o");
|
||||
agent.state.thinkingLevel = "medium";
|
||||
agent.state.tools = [myTool];
|
||||
agent.toolExecution = "sequential";
|
||||
agent.beforeToolCall = async ({ toolCall }) => undefined;
|
||||
agent.afterToolCall = async ({ toolCall, result }) => undefined;
|
||||
agent.shouldStopAfterTurn = async ({ context }) => shouldCompactBeforeNextTurn(context.messages);
|
||||
agent.prepareRequest = async ({ context }) => ({
|
||||
context: { ...context, messages: await loadCanonicalMessages() },
|
||||
});
|
||||
agent.finishTurn = async () => undefined;
|
||||
agent.state.messages = newMessages; // top-level array is copied
|
||||
agent.state.messages.push(message);
|
||||
const nextQueuedMessages = agent.peekQueuedMessages(); // respects queue modes; does not consume
|
||||
agent.reset();
|
||||
```
|
||||
|
||||
@@ -459,6 +503,10 @@ Thrown errors are caught by the agent and reported to the LLM as tool errors wit
|
||||
|
||||
Return `terminate: true` from `execute()`, a blocked `beforeToolCall`, or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results.
|
||||
|
||||
### MCP and Codemode
|
||||
|
||||
`@earendil-works/pi-mcp` connects to MCP servers and `@earendil-works/pi-codemode` runs model-written JavaScript that calls tools. [examples/mcp-codemode](examples/mcp-codemode) wraps both as `AgentTool`s: one tool per MCP tool, and a `codemode` tool whose scripts call the agent's tools through `runToolCall()`, so `beforeToolCall` and `afterToolCall` apply to those calls too.
|
||||
|
||||
## Proxy Usage
|
||||
|
||||
For browser apps that proxy through a backend:
|
||||
@@ -484,8 +532,7 @@ For direct control without the Agent class:
|
||||
import { agentLoop, agentLoopContinue } from "@earendil-works/pi-agent-core";
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [],
|
||||
messages: [{ role: "system", content: "You are helpful.", timestamp: Date.now() }],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user