fix(agent): simplify state API and update consumers fixes #2633
This commit is contained in:
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- `AgentState` has been reshaped:
|
||||||
|
- `streamMessage` was renamed to `streamingMessage`
|
||||||
|
- `error` was renamed to `errorMessage`
|
||||||
|
- `isStreaming`, `streamingMessage`, `pendingToolCalls`, and `errorMessage` are now readonly in the public API
|
||||||
|
- `pendingToolCalls` is now typed as `ReadonlySet<string>`
|
||||||
|
- `tools` and `messages` are now accessor properties, and assigning either field copies the provided top-level array instead of preserving array identity
|
||||||
|
- `AgentOptions.initialState` no longer accepts runtime-owned fields. Remove `isStreaming`, `streamingMessage`, `pendingToolCalls`, and `errorMessage` from `initialState` values.
|
||||||
|
- Removed `Agent` mutator methods in favor of direct property access:
|
||||||
|
- `agent.setSystemPrompt(value)` -> `agent.state.systemPrompt = value`
|
||||||
|
- `agent.setModel(model)` -> `agent.state.model = model`
|
||||||
|
- `agent.setThinkingLevel(level)` -> `agent.state.thinkingLevel = level`
|
||||||
|
- `agent.setTools(tools)` -> `agent.state.tools = tools`
|
||||||
|
- `agent.replaceMessages(messages)` -> `agent.state.messages = messages`
|
||||||
|
- `agent.appendMessage(message)` -> `agent.state.messages.push(message)`
|
||||||
|
- `agent.clearMessages()` -> `agent.state.messages = []`
|
||||||
|
- `agent.setToolExecution(mode)` -> `agent.toolExecution = mode`
|
||||||
|
- `agent.setBeforeToolCall(fn)` -> `agent.beforeToolCall = fn`
|
||||||
|
- `agent.setAfterToolCall(fn)` -> `agent.afterToolCall = fn`
|
||||||
|
- `agent.setTransport(transport)` -> `agent.transport = transport`
|
||||||
|
- Removed queue mode getter/setter methods in favor of properties:
|
||||||
|
- `agent.setSteeringMode(mode)` -> `agent.steeringMode = mode`
|
||||||
|
- `agent.getSteeringMode()` -> `agent.steeringMode`
|
||||||
|
- `agent.setFollowUpMode(mode)` -> `agent.followUpMode = mode`
|
||||||
|
- `agent.getFollowUpMode()` -> `agent.followUpMode`
|
||||||
|
|
||||||
## [0.64.0] - 2026-03-29
|
## [0.64.0] - 2026-03-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -204,14 +204,18 @@ interface AgentState {
|
|||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
tools: AgentTool<any>[];
|
tools: AgentTool<any>[];
|
||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
isStreaming: boolean;
|
readonly isStreaming: boolean;
|
||||||
streamMessage: AgentMessage | null; // Current partial during streaming
|
readonly streamingMessage?: AgentMessage;
|
||||||
pendingToolCalls: Set<string>;
|
readonly pendingToolCalls: ReadonlySet<string>;
|
||||||
error?: string;
|
readonly errorMessage?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Access via `agent.state`. During streaming, `streamMessage` contains the partial assistant message.
|
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.
|
||||||
|
|
||||||
|
During streaming, `agent.state.streamingMessage` contains the current partial assistant message.
|
||||||
|
|
||||||
## Methods
|
## Methods
|
||||||
|
|
||||||
@@ -236,17 +240,16 @@ await agent.continue();
|
|||||||
### State Management
|
### State Management
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
agent.setSystemPrompt("New prompt");
|
agent.state.systemPrompt = "New prompt";
|
||||||
agent.setModel(getModel("openai", "gpt-4o"));
|
agent.state.model = getModel("openai", "gpt-4o");
|
||||||
agent.setThinkingLevel("medium");
|
agent.state.thinkingLevel = "medium";
|
||||||
agent.setTools([myTool]);
|
agent.state.tools = [myTool];
|
||||||
agent.setToolExecution("sequential");
|
agent.toolExecution = "sequential";
|
||||||
agent.setBeforeToolCall(async ({ toolCall }) => undefined);
|
agent.beforeToolCall = async ({ toolCall }) => undefined;
|
||||||
agent.setAfterToolCall(async ({ toolCall, result }) => undefined);
|
agent.afterToolCall = async ({ toolCall, result }) => undefined;
|
||||||
agent.replaceMessages(newMessages);
|
agent.state.messages = newMessages; // top-level array is copied
|
||||||
agent.appendMessage(message);
|
agent.state.messages.push(message);
|
||||||
agent.clearMessages();
|
agent.reset();
|
||||||
agent.reset(); // Clear everything
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Session and Thinking Budgets
|
### Session and Thinking Budgets
|
||||||
@@ -283,8 +286,8 @@ unsubscribe();
|
|||||||
Steering messages let you interrupt the agent while tools are running. Follow-up messages let you queue work after the agent would otherwise stop.
|
Steering messages let you interrupt the agent while tools are running. Follow-up messages let you queue work after the agent would otherwise stop.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
agent.setSteeringMode("one-at-a-time");
|
agent.steeringMode = "one-at-a-time";
|
||||||
agent.setFollowUpMode("one-at-a-time");
|
agent.followUpMode = "one-at-a-time";
|
||||||
|
|
||||||
// While agent is running tools
|
// While agent is running tools
|
||||||
agent.steer({
|
agent.steer({
|
||||||
@@ -300,8 +303,8 @@ agent.followUp({
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const steeringMode = agent.getSteeringMode();
|
const steeringMode = agent.steeringMode;
|
||||||
const followUpMode = agent.getFollowUpMode();
|
const followUpMode = agent.followUpMode;
|
||||||
|
|
||||||
agent.clearSteeringQueue();
|
agent.clearSteeringQueue();
|
||||||
agent.clearFollowUpQueue();
|
agent.clearFollowUpQueue();
|
||||||
@@ -370,7 +373,7 @@ const readFileTool: AgentTool = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
agent.setTools([readFileTool]);
|
agent.state.tools = [readFileTool];
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
|
||||||
* Agent class that uses the agent-loop directly.
|
|
||||||
* No transport abstraction - calls streamSimple via the loop.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getModel,
|
|
||||||
type ImageContent,
|
type ImageContent,
|
||||||
type Message,
|
type Message,
|
||||||
type Model,
|
type Model,
|
||||||
@@ -27,453 +21,466 @@ import type {
|
|||||||
BeforeToolCallContext,
|
BeforeToolCallContext,
|
||||||
BeforeToolCallResult,
|
BeforeToolCallResult,
|
||||||
StreamFn,
|
StreamFn,
|
||||||
ThinkingLevel,
|
|
||||||
ToolExecutionMode,
|
ToolExecutionMode,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
|
||||||
*/
|
|
||||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||||
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
|
return messages.filter(
|
||||||
|
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentOptions {
|
const EMPTY_USAGE = {
|
||||||
initialState?: Partial<AgentState>;
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
const DEFAULT_MODEL = {
|
||||||
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
|
id: "unknown",
|
||||||
* Default filters to user/assistant/toolResult and converts attachments.
|
name: "unknown",
|
||||||
*/
|
api: "unknown",
|
||||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
provider: "unknown",
|
||||||
|
baseUrl: "",
|
||||||
|
reasoning: false,
|
||||||
|
input: [],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 0,
|
||||||
|
maxTokens: 0,
|
||||||
|
} satisfies Model<any>;
|
||||||
|
|
||||||
/**
|
type QueueMode = "all" | "one-at-a-time";
|
||||||
* Optional transform applied to context before convertToLlm.
|
|
||||||
* Use for context pruning, injecting external context, etc.
|
|
||||||
*/
|
|
||||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
|
||||||
|
|
||||||
/**
|
type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & {
|
||||||
* Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
|
isStreaming: boolean;
|
||||||
*/
|
streamingMessage?: AgentMessage;
|
||||||
steeringMode?: "all" | "one-at-a-time";
|
pendingToolCalls: Set<string>;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
function createMutableAgentState(
|
||||||
* Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn
|
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>,
|
||||||
*/
|
): MutableAgentState {
|
||||||
followUpMode?: "all" | "one-at-a-time";
|
let tools = initialState?.tools?.slice() ?? [];
|
||||||
|
let messages = initialState?.messages?.slice() ?? [];
|
||||||
|
|
||||||
/**
|
return {
|
||||||
* Custom stream function (for proxy backends, etc.). Default uses streamSimple.
|
systemPrompt: initialState?.systemPrompt ?? "",
|
||||||
*/
|
model: initialState?.model ?? DEFAULT_MODEL,
|
||||||
streamFn?: StreamFn;
|
thinkingLevel: initialState?.thinkingLevel ?? "off",
|
||||||
|
get tools() {
|
||||||
/**
|
return tools;
|
||||||
* Optional session identifier forwarded to LLM providers.
|
},
|
||||||
* Used by providers that support session-based caching (e.g., OpenAI Codex).
|
set tools(nextTools: AgentTool<any>[]) {
|
||||||
*/
|
tools = nextTools.slice();
|
||||||
sessionId?: string;
|
},
|
||||||
|
get messages() {
|
||||||
/**
|
return messages;
|
||||||
* Resolves an API key dynamically for each LLM call.
|
},
|
||||||
* Useful for expiring tokens (e.g., GitHub Copilot OAuth).
|
set messages(nextMessages: AgentMessage[]) {
|
||||||
*/
|
messages = nextMessages.slice();
|
||||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Inspect or replace provider payloads before they are sent.
|
|
||||||
*/
|
|
||||||
onPayload?: SimpleStreamOptions["onPayload"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom token budgets for thinking levels (token-based providers only).
|
|
||||||
*/
|
|
||||||
thinkingBudgets?: ThinkingBudgets;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preferred transport for providers that support multiple transports.
|
|
||||||
*/
|
|
||||||
transport?: Transport;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
|
|
||||||
* If the server's requested delay exceeds this value, the request fails immediately,
|
|
||||||
* allowing higher-level retry logic to handle it with user visibility.
|
|
||||||
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
|
|
||||||
*/
|
|
||||||
maxRetryDelayMs?: number;
|
|
||||||
|
|
||||||
/** Tool execution mode. Default: "parallel" */
|
|
||||||
toolExecution?: ToolExecutionMode;
|
|
||||||
|
|
||||||
/** Called before a tool is executed, after arguments have been validated. */
|
|
||||||
beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
|
|
||||||
|
|
||||||
/** Called after a tool finishes executing, before final tool events are emitted. */
|
|
||||||
afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Agent {
|
|
||||||
private _state: AgentState = {
|
|
||||||
systemPrompt: "",
|
|
||||||
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
|
|
||||||
thinkingLevel: "off",
|
|
||||||
tools: [],
|
|
||||||
messages: [],
|
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
streamMessage: null,
|
streamingMessage: undefined,
|
||||||
pendingToolCalls: new Set<string>(),
|
pendingToolCalls: new Set<string>(),
|
||||||
error: undefined,
|
errorMessage: undefined,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private listeners = new Set<(e: AgentEvent) => void>();
|
/** Options for constructing an {@link Agent}. */
|
||||||
private abortController?: AbortController;
|
export interface AgentOptions {
|
||||||
private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
|
||||||
private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||||
private steeringQueue: AgentMessage[] = [];
|
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||||
private followUpQueue: AgentMessage[] = [];
|
streamFn?: StreamFn;
|
||||||
private steeringMode: "all" | "one-at-a-time";
|
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||||
private followUpMode: "all" | "one-at-a-time";
|
onPayload?: SimpleStreamOptions["onPayload"];
|
||||||
|
beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
|
||||||
|
afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
|
||||||
|
steeringMode?: QueueMode;
|
||||||
|
followUpMode?: QueueMode;
|
||||||
|
sessionId?: string;
|
||||||
|
thinkingBudgets?: ThinkingBudgets;
|
||||||
|
transport?: Transport;
|
||||||
|
maxRetryDelayMs?: number;
|
||||||
|
toolExecution?: ToolExecutionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PendingMessageQueue {
|
||||||
|
private messages: AgentMessage[] = [];
|
||||||
|
|
||||||
|
constructor(public mode: QueueMode) {}
|
||||||
|
|
||||||
|
enqueue(message: AgentMessage): void {
|
||||||
|
this.messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasItems(): boolean {
|
||||||
|
return this.messages.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
drain(): AgentMessage[] {
|
||||||
|
if (this.mode === "all") {
|
||||||
|
const drained = this.messages.slice();
|
||||||
|
this.messages = [];
|
||||||
|
return drained;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = this.messages[0];
|
||||||
|
if (!first) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
this.messages = this.messages.slice(1);
|
||||||
|
return [first];
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.messages = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveRun = {
|
||||||
|
promise: Promise<void>;
|
||||||
|
resolve: () => void;
|
||||||
|
abortController: AbortController;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful wrapper around the low-level agent loop.
|
||||||
|
*
|
||||||
|
* `Agent` owns the current transcript, emits lifecycle events, executes tools,
|
||||||
|
* and exposes queueing APIs for steering and follow-up messages.
|
||||||
|
*/
|
||||||
|
export class Agent {
|
||||||
|
private _state: MutableAgentState;
|
||||||
|
private readonly listeners = new Set<(event: AgentEvent) => void>();
|
||||||
|
private readonly steeringQueue: PendingMessageQueue;
|
||||||
|
private readonly followUpQueue: PendingMessageQueue;
|
||||||
|
|
||||||
|
public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||||
|
public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||||
public streamFn: StreamFn;
|
public streamFn: StreamFn;
|
||||||
private _sessionId?: string;
|
|
||||||
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||||
private _onPayload?: SimpleStreamOptions["onPayload"];
|
public onPayload?: SimpleStreamOptions["onPayload"];
|
||||||
private runningPrompt?: Promise<void>;
|
public beforeToolCall?: (
|
||||||
private resolveRunningPrompt?: () => void;
|
|
||||||
private _thinkingBudgets?: ThinkingBudgets;
|
|
||||||
private _transport: Transport;
|
|
||||||
private _maxRetryDelayMs?: number;
|
|
||||||
private _toolExecution: ToolExecutionMode;
|
|
||||||
private _beforeToolCall?: (
|
|
||||||
context: BeforeToolCallContext,
|
context: BeforeToolCallContext,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => Promise<BeforeToolCallResult | undefined>;
|
) => Promise<BeforeToolCallResult | undefined>;
|
||||||
private _afterToolCall?: (
|
public afterToolCall?: (
|
||||||
context: AfterToolCallContext,
|
context: AfterToolCallContext,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => Promise<AfterToolCallResult | undefined>;
|
) => Promise<AfterToolCallResult | undefined>;
|
||||||
|
private activeRun?: ActiveRun;
|
||||||
|
/** Session identifier forwarded to providers for cache-aware backends. */
|
||||||
|
public sessionId?: string;
|
||||||
|
/** Optional per-level thinking token budgets forwarded to the stream function. */
|
||||||
|
public thinkingBudgets?: ThinkingBudgets;
|
||||||
|
/** Preferred transport forwarded to the stream function. */
|
||||||
|
public transport: Transport;
|
||||||
|
/** Optional cap for provider-requested retry delays. */
|
||||||
|
public maxRetryDelayMs?: number;
|
||||||
|
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
|
||||||
|
public toolExecution: ToolExecutionMode;
|
||||||
|
|
||||||
constructor(opts: AgentOptions = {}) {
|
constructor(options: AgentOptions = {}) {
|
||||||
this._state = { ...this._state, ...opts.initialState };
|
this._state = createMutableAgentState(options.initialState);
|
||||||
this.convertToLlm = opts.convertToLlm || defaultConvertToLlm;
|
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
|
||||||
this.transformContext = opts.transformContext;
|
this.transformContext = options.transformContext;
|
||||||
this.steeringMode = opts.steeringMode || "one-at-a-time";
|
this.streamFn = options.streamFn ?? streamSimple;
|
||||||
this.followUpMode = opts.followUpMode || "one-at-a-time";
|
this.getApiKey = options.getApiKey;
|
||||||
this.streamFn = opts.streamFn || streamSimple;
|
this.onPayload = options.onPayload;
|
||||||
this._sessionId = opts.sessionId;
|
this.beforeToolCall = options.beforeToolCall;
|
||||||
this.getApiKey = opts.getApiKey;
|
this.afterToolCall = options.afterToolCall;
|
||||||
this._onPayload = opts.onPayload;
|
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
|
||||||
this._thinkingBudgets = opts.thinkingBudgets;
|
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
|
||||||
this._transport = opts.transport ?? "sse";
|
this.sessionId = options.sessionId;
|
||||||
this._maxRetryDelayMs = opts.maxRetryDelayMs;
|
this.thinkingBudgets = options.thinkingBudgets;
|
||||||
this._toolExecution = opts.toolExecution ?? "parallel";
|
this.transport = options.transport ?? "sse";
|
||||||
this._beforeToolCall = opts.beforeToolCall;
|
this.maxRetryDelayMs = options.maxRetryDelayMs;
|
||||||
this._afterToolCall = opts.afterToolCall;
|
this.toolExecution = options.toolExecution ?? "parallel";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to agent lifecycle events. Returns an unsubscribe function. */
|
||||||
|
subscribe(listener: (event: AgentEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current session ID used for provider caching.
|
* Current agent state.
|
||||||
|
*
|
||||||
|
* Assigning `state.tools` or `state.messages` copies the provided top-level array.
|
||||||
*/
|
*/
|
||||||
get sessionId(): string | undefined {
|
|
||||||
return this._sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the session ID for provider caching.
|
|
||||||
* Call this when switching sessions (new session, branch, resume).
|
|
||||||
*/
|
|
||||||
set sessionId(value: string | undefined) {
|
|
||||||
this._sessionId = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current thinking budgets.
|
|
||||||
*/
|
|
||||||
get thinkingBudgets(): ThinkingBudgets | undefined {
|
|
||||||
return this._thinkingBudgets;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set custom thinking budgets for token-based providers.
|
|
||||||
*/
|
|
||||||
set thinkingBudgets(value: ThinkingBudgets | undefined) {
|
|
||||||
this._thinkingBudgets = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current preferred transport.
|
|
||||||
*/
|
|
||||||
get transport(): Transport {
|
|
||||||
return this._transport;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the preferred transport.
|
|
||||||
*/
|
|
||||||
setTransport(value: Transport) {
|
|
||||||
this._transport = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current max retry delay in milliseconds.
|
|
||||||
*/
|
|
||||||
get maxRetryDelayMs(): number | undefined {
|
|
||||||
return this._maxRetryDelayMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the maximum delay to wait for server-requested retries.
|
|
||||||
* Set to 0 to disable the cap.
|
|
||||||
*/
|
|
||||||
set maxRetryDelayMs(value: number | undefined) {
|
|
||||||
this._maxRetryDelayMs = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get toolExecution(): ToolExecutionMode {
|
|
||||||
return this._toolExecution;
|
|
||||||
}
|
|
||||||
|
|
||||||
setToolExecution(value: ToolExecutionMode) {
|
|
||||||
this._toolExecution = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBeforeToolCall(
|
|
||||||
value:
|
|
||||||
| ((context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>)
|
|
||||||
| undefined,
|
|
||||||
) {
|
|
||||||
this._beforeToolCall = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAfterToolCall(
|
|
||||||
value:
|
|
||||||
| ((context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>)
|
|
||||||
| undefined,
|
|
||||||
) {
|
|
||||||
this._afterToolCall = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get state(): AgentState {
|
get state(): AgentState {
|
||||||
return this._state;
|
return this._state;
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(fn: (e: AgentEvent) => void): () => void {
|
/** Controls how queued steering messages are drained. */
|
||||||
this.listeners.add(fn);
|
set steeringMode(mode: QueueMode) {
|
||||||
return () => this.listeners.delete(fn);
|
this.steeringQueue.mode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// State mutators
|
get steeringMode(): QueueMode {
|
||||||
setSystemPrompt(v: string) {
|
return this.steeringQueue.mode;
|
||||||
this._state.systemPrompt = v;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setModel(m: Model<any>) {
|
/** Controls how queued follow-up messages are drained. */
|
||||||
this._state.model = m;
|
set followUpMode(mode: QueueMode) {
|
||||||
|
this.followUpQueue.mode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
setThinkingLevel(l: ThinkingLevel) {
|
get followUpMode(): QueueMode {
|
||||||
this._state.thinkingLevel = l;
|
return this.followUpQueue.mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSteeringMode(mode: "all" | "one-at-a-time") {
|
/** Queue a message to be injected after the current assistant turn finishes. */
|
||||||
this.steeringMode = mode;
|
steer(message: AgentMessage): void {
|
||||||
|
this.steeringQueue.enqueue(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSteeringMode(): "all" | "one-at-a-time" {
|
/** Queue a message to run only after the agent would otherwise stop. */
|
||||||
return this.steeringMode;
|
followUp(message: AgentMessage): void {
|
||||||
|
this.followUpQueue.enqueue(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFollowUpMode(mode: "all" | "one-at-a-time") {
|
/** Remove all queued steering messages. */
|
||||||
this.followUpMode = mode;
|
clearSteeringQueue(): void {
|
||||||
|
this.steeringQueue.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
getFollowUpMode(): "all" | "one-at-a-time" {
|
/** Remove all queued follow-up messages. */
|
||||||
return this.followUpMode;
|
clearFollowUpQueue(): void {
|
||||||
|
this.followUpQueue.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTools(t: AgentTool<any>[]) {
|
/** Remove all queued steering and follow-up messages. */
|
||||||
this._state.tools = t;
|
clearAllQueues(): void {
|
||||||
}
|
this.clearSteeringQueue();
|
||||||
|
this.clearFollowUpQueue();
|
||||||
replaceMessages(ms: AgentMessage[]) {
|
|
||||||
this._state.messages = ms.slice();
|
|
||||||
}
|
|
||||||
|
|
||||||
appendMessage(m: AgentMessage) {
|
|
||||||
this._state.messages = [...this._state.messages, m];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue a steering message while the agent is running.
|
|
||||||
* Delivered after the current assistant turn finishes executing its tool calls,
|
|
||||||
* before the next LLM call.
|
|
||||||
*/
|
|
||||||
steer(m: AgentMessage) {
|
|
||||||
this.steeringQueue.push(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue a follow-up message to be processed after the agent finishes.
|
|
||||||
* Delivered only when agent has no more tool calls or steering messages.
|
|
||||||
*/
|
|
||||||
followUp(m: AgentMessage) {
|
|
||||||
this.followUpQueue.push(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSteeringQueue() {
|
|
||||||
this.steeringQueue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFollowUpQueue() {
|
|
||||||
this.followUpQueue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAllQueues() {
|
|
||||||
this.steeringQueue = [];
|
|
||||||
this.followUpQueue = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true when either queue still contains pending messages. */
|
||||||
hasQueuedMessages(): boolean {
|
hasQueuedMessages(): boolean {
|
||||||
return this.steeringQueue.length > 0 || this.followUpQueue.length > 0;
|
return this.steeringQueue.hasItems() || this.followUpQueue.hasItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
private dequeueSteeringMessages(): AgentMessage[] {
|
/** Active abort signal for the current run, if any. */
|
||||||
if (this.steeringMode === "one-at-a-time") {
|
|
||||||
if (this.steeringQueue.length > 0) {
|
|
||||||
const first = this.steeringQueue[0];
|
|
||||||
this.steeringQueue = this.steeringQueue.slice(1);
|
|
||||||
return [first];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const steering = this.steeringQueue.slice();
|
|
||||||
this.steeringQueue = [];
|
|
||||||
return steering;
|
|
||||||
}
|
|
||||||
|
|
||||||
private dequeueFollowUpMessages(): AgentMessage[] {
|
|
||||||
if (this.followUpMode === "one-at-a-time") {
|
|
||||||
if (this.followUpQueue.length > 0) {
|
|
||||||
const first = this.followUpQueue[0];
|
|
||||||
this.followUpQueue = this.followUpQueue.slice(1);
|
|
||||||
return [first];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const followUp = this.followUpQueue.slice();
|
|
||||||
this.followUpQueue = [];
|
|
||||||
return followUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearMessages() {
|
|
||||||
this._state.messages = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The current abort signal, or undefined when the agent is not streaming. */
|
|
||||||
get signal(): AbortSignal | undefined {
|
get signal(): AbortSignal | undefined {
|
||||||
return this.abortController?.signal;
|
return this.activeRun?.abortController.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
abort() {
|
/** Abort the current run, if one is active. */
|
||||||
this.abortController?.abort();
|
abort(): void {
|
||||||
|
this.activeRun?.abortController.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve when the current run has finished. */
|
||||||
waitForIdle(): Promise<void> {
|
waitForIdle(): Promise<void> {
|
||||||
return this.runningPrompt ?? Promise.resolve();
|
return this.activeRun?.promise ?? Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
/** Clear transcript state, runtime state, and queued messages. */
|
||||||
|
reset(): void {
|
||||||
this._state.messages = [];
|
this._state.messages = [];
|
||||||
this._state.isStreaming = false;
|
this._state.isStreaming = false;
|
||||||
this._state.streamMessage = null;
|
this._state.streamingMessage = undefined;
|
||||||
this._state.pendingToolCalls = new Set<string>();
|
this._state.pendingToolCalls = new Set<string>();
|
||||||
this._state.error = undefined;
|
this._state.errorMessage = undefined;
|
||||||
this.steeringQueue = [];
|
this.clearFollowUpQueue();
|
||||||
this.followUpQueue = [];
|
this.clearSteeringQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a prompt with an AgentMessage */
|
/** Start a new prompt from text, a single message, or a batch of messages. */
|
||||||
async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
|
async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
|
||||||
async prompt(input: string, images?: ImageContent[]): Promise<void>;
|
async prompt(input: string, images?: ImageContent[]): Promise<void>;
|
||||||
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) {
|
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise<void> {
|
||||||
if (this._state.isStreaming) {
|
if (this.activeRun) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
|
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const messages = this.normalizePromptInput(input, images);
|
||||||
const model = this._state.model;
|
await this.runPromptMessages(messages);
|
||||||
if (!model) throw new Error("No model configured");
|
|
||||||
|
|
||||||
let msgs: AgentMessage[];
|
|
||||||
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
msgs = input;
|
|
||||||
} else if (typeof input === "string") {
|
|
||||||
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
|
||||||
if (images && images.length > 0) {
|
|
||||||
content.push(...images);
|
|
||||||
}
|
|
||||||
msgs = [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
msgs = [input];
|
|
||||||
}
|
|
||||||
|
|
||||||
await this._runLoop(msgs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Continue from the current transcript. The last message must be a user or tool-result message. */
|
||||||
* Continue from current context (used for retries and resuming queued messages).
|
async continue(): Promise<void> {
|
||||||
*/
|
if (this.activeRun) {
|
||||||
async continue() {
|
|
||||||
if (this._state.isStreaming) {
|
|
||||||
throw new Error("Agent is already processing. Wait for completion before continuing.");
|
throw new Error("Agent is already processing. Wait for completion before continuing.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = this._state.messages;
|
const lastMessage = this._state.messages[this._state.messages.length - 1];
|
||||||
if (messages.length === 0) {
|
if (!lastMessage) {
|
||||||
throw new Error("No messages to continue from");
|
throw new Error("No messages to continue from");
|
||||||
}
|
}
|
||||||
if (messages[messages.length - 1].role === "assistant") {
|
|
||||||
const queuedSteering = this.dequeueSteeringMessages();
|
if (lastMessage.role === "assistant") {
|
||||||
|
const queuedSteering = this.steeringQueue.drain();
|
||||||
if (queuedSteering.length > 0) {
|
if (queuedSteering.length > 0) {
|
||||||
await this._runLoop(queuedSteering, { skipInitialSteeringPoll: true });
|
await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queuedFollowUp = this.dequeueFollowUpMessages();
|
const queuedFollowUps = this.followUpQueue.drain();
|
||||||
if (queuedFollowUp.length > 0) {
|
if (queuedFollowUps.length > 0) {
|
||||||
await this._runLoop(queuedFollowUp);
|
await this.runPromptMessages(queuedFollowUps);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Cannot continue from message role: assistant");
|
throw new Error("Cannot continue from message role: assistant");
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._runLoop(undefined);
|
await this.runContinuation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processLoopEvent(event: AgentEvent): void {
|
private normalizePromptInput(
|
||||||
|
input: string | AgentMessage | AgentMessage[],
|
||||||
|
images?: ImageContent[],
|
||||||
|
): AgentMessage[] {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
return [input];
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
||||||
|
if (images && images.length > 0) {
|
||||||
|
content.push(...images);
|
||||||
|
}
|
||||||
|
return [{ role: "user", content, timestamp: Date.now() }];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPromptMessages(
|
||||||
|
messages: AgentMessage[],
|
||||||
|
options: { skipInitialSteeringPoll?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.runWithLifecycle(async (signal) => {
|
||||||
|
await runAgentLoop(
|
||||||
|
messages,
|
||||||
|
this.createContextSnapshot(),
|
||||||
|
this.createLoopConfig(options),
|
||||||
|
(event) => this.processEvents(event),
|
||||||
|
signal,
|
||||||
|
this.streamFn,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runContinuation(): Promise<void> {
|
||||||
|
await this.runWithLifecycle(async (signal) => {
|
||||||
|
await runAgentLoopContinue(
|
||||||
|
this.createContextSnapshot(),
|
||||||
|
this.createLoopConfig(),
|
||||||
|
(event) => this.processEvents(event),
|
||||||
|
signal,
|
||||||
|
this.streamFn,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createContextSnapshot(): AgentContext {
|
||||||
|
return {
|
||||||
|
systemPrompt: this._state.systemPrompt,
|
||||||
|
messages: this._state.messages.slice(),
|
||||||
|
tools: this._state.tools.slice(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig {
|
||||||
|
let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true;
|
||||||
|
return {
|
||||||
|
model: this._state.model,
|
||||||
|
reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel,
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
onPayload: this.onPayload,
|
||||||
|
transport: this.transport,
|
||||||
|
thinkingBudgets: this.thinkingBudgets,
|
||||||
|
maxRetryDelayMs: this.maxRetryDelayMs,
|
||||||
|
toolExecution: this.toolExecution,
|
||||||
|
beforeToolCall: this.beforeToolCall,
|
||||||
|
afterToolCall: this.afterToolCall,
|
||||||
|
convertToLlm: this.convertToLlm,
|
||||||
|
transformContext: this.transformContext,
|
||||||
|
getApiKey: this.getApiKey,
|
||||||
|
getSteeringMessages: async () => {
|
||||||
|
if (skipInitialSteeringPoll) {
|
||||||
|
skipInitialSteeringPoll = false;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.steeringQueue.drain();
|
||||||
|
},
|
||||||
|
getFollowUpMessages: async () => this.followUpQueue.drain(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> {
|
||||||
|
if (this.activeRun) {
|
||||||
|
throw new Error("Agent is already processing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let resolvePromise = () => {};
|
||||||
|
const promise = new Promise<void>((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
this.activeRun = { promise, resolve: resolvePromise, abortController };
|
||||||
|
|
||||||
|
this._state.isStreaming = true;
|
||||||
|
this._state.streamingMessage = undefined;
|
||||||
|
this._state.errorMessage = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executor(abortController.signal);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleRunFailure(error, abortController.signal.aborted);
|
||||||
|
} finally {
|
||||||
|
this.finishRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRunFailure(error: unknown, aborted: boolean): void {
|
||||||
|
const failureMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "" }],
|
||||||
|
api: this._state.model.api,
|
||||||
|
provider: this._state.model.provider,
|
||||||
|
model: this._state.model.id,
|
||||||
|
usage: EMPTY_USAGE,
|
||||||
|
stopReason: aborted ? "aborted" : "error",
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} satisfies AgentMessage;
|
||||||
|
this._state.messages.push(failureMessage);
|
||||||
|
this._state.errorMessage = failureMessage.errorMessage;
|
||||||
|
this.processEvents({ type: "agent_end", messages: [failureMessage] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishRun(): void {
|
||||||
|
this._state.isStreaming = false;
|
||||||
|
this._state.streamingMessage = undefined;
|
||||||
|
this._state.pendingToolCalls = new Set<string>();
|
||||||
|
this.activeRun?.resolve();
|
||||||
|
this.activeRun = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private processEvents(event: AgentEvent): void {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "message_start":
|
case "message_start":
|
||||||
this._state.streamMessage = event.message;
|
this._state.streamingMessage = event.message;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "message_update":
|
case "message_update":
|
||||||
this._state.streamMessage = event.message;
|
this._state.streamingMessage = event.message;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "message_end":
|
case "message_end":
|
||||||
this._state.streamMessage = null;
|
this._state.streamingMessage = undefined;
|
||||||
this.appendMessage(event.message);
|
this._state.messages.push(event.message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tool_execution_start": {
|
case "tool_execution_start": {
|
||||||
@@ -491,128 +498,19 @@ export class Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "turn_end":
|
case "turn_end":
|
||||||
if (event.message.role === "assistant" && (event.message as any).errorMessage) {
|
if (event.message.role === "assistant" && event.message.errorMessage) {
|
||||||
this._state.error = (event.message as any).errorMessage;
|
this._state.errorMessage = event.message.errorMessage;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "agent_end":
|
case "agent_end":
|
||||||
this._state.isStreaming = false;
|
this._state.isStreaming = false;
|
||||||
this._state.streamMessage = null;
|
this._state.streamingMessage = undefined;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the agent loop.
|
|
||||||
* If messages are provided, starts a new conversation turn with those messages.
|
|
||||||
* Otherwise, continues from existing context.
|
|
||||||
*/
|
|
||||||
private async _runLoop(messages?: AgentMessage[], options?: { skipInitialSteeringPoll?: boolean }) {
|
|
||||||
const model = this._state.model;
|
|
||||||
if (!model) throw new Error("No model configured");
|
|
||||||
|
|
||||||
this.runningPrompt = new Promise<void>((resolve) => {
|
|
||||||
this.resolveRunningPrompt = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.abortController = new AbortController();
|
|
||||||
this._state.isStreaming = true;
|
|
||||||
this._state.streamMessage = null;
|
|
||||||
this._state.error = undefined;
|
|
||||||
|
|
||||||
const reasoning = this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel;
|
|
||||||
|
|
||||||
const context: AgentContext = {
|
|
||||||
systemPrompt: this._state.systemPrompt,
|
|
||||||
messages: this._state.messages.slice(),
|
|
||||||
tools: this._state.tools,
|
|
||||||
};
|
|
||||||
|
|
||||||
let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true;
|
|
||||||
|
|
||||||
const config: AgentLoopConfig = {
|
|
||||||
model,
|
|
||||||
reasoning,
|
|
||||||
sessionId: this._sessionId,
|
|
||||||
onPayload: this._onPayload,
|
|
||||||
transport: this._transport,
|
|
||||||
thinkingBudgets: this._thinkingBudgets,
|
|
||||||
maxRetryDelayMs: this._maxRetryDelayMs,
|
|
||||||
toolExecution: this._toolExecution,
|
|
||||||
beforeToolCall: this._beforeToolCall,
|
|
||||||
afterToolCall: this._afterToolCall,
|
|
||||||
convertToLlm: this.convertToLlm,
|
|
||||||
transformContext: this.transformContext,
|
|
||||||
getApiKey: this.getApiKey,
|
|
||||||
getSteeringMessages: async () => {
|
|
||||||
if (skipInitialSteeringPoll) {
|
|
||||||
skipInitialSteeringPoll = false;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return this.dequeueSteeringMessages();
|
|
||||||
},
|
|
||||||
getFollowUpMessages: async () => this.dequeueFollowUpMessages(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (messages) {
|
|
||||||
await runAgentLoop(
|
|
||||||
messages,
|
|
||||||
context,
|
|
||||||
config,
|
|
||||||
async (event) => this._processLoopEvent(event),
|
|
||||||
this.abortController.signal,
|
|
||||||
this.streamFn,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await runAgentLoopContinue(
|
|
||||||
context,
|
|
||||||
config,
|
|
||||||
async (event) => this._processLoopEvent(event),
|
|
||||||
this.abortController.signal,
|
|
||||||
this.streamFn,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg: AgentMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "" }],
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
|
|
||||||
errorMessage: err?.message || String(err),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
} as AgentMessage;
|
|
||||||
|
|
||||||
this.appendMessage(errorMsg);
|
|
||||||
this._state.error = err?.message || String(err);
|
|
||||||
this.emit({ type: "agent_end", messages: [errorMsg] });
|
|
||||||
} finally {
|
|
||||||
this._state.isStreaming = false;
|
|
||||||
this._state.streamMessage = null;
|
|
||||||
this._state.pendingToolCalls = new Set<string>();
|
|
||||||
this.abortController = undefined;
|
|
||||||
this.resolveRunningPrompt?.();
|
|
||||||
this.runningPrompt = undefined;
|
|
||||||
this.resolveRunningPrompt = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private emit(e: AgentEvent) {
|
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
listener(e);
|
listener(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,37 +245,55 @@ export interface CustomAgentMessages {
|
|||||||
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
|
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent state containing all configuration and conversation data.
|
* Public agent state.
|
||||||
|
*
|
||||||
|
* `tools` and `messages` use accessor properties so implementations can copy
|
||||||
|
* assigned arrays before storing them.
|
||||||
*/
|
*/
|
||||||
export interface AgentState {
|
export interface AgentState {
|
||||||
|
/** System prompt sent with each model request. */
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
|
/** Active model used for future turns. */
|
||||||
model: Model<any>;
|
model: Model<any>;
|
||||||
|
/** Requested reasoning level for future turns. */
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
tools: AgentTool<any>[];
|
/** Available tools. Assigning a new array copies the top-level array. */
|
||||||
messages: AgentMessage[]; // Can include attachments + custom message types
|
set tools(tools: AgentTool<any>[]);
|
||||||
isStreaming: boolean;
|
get tools(): AgentTool<any>[];
|
||||||
streamMessage: AgentMessage | null;
|
/** Conversation transcript. Assigning a new array copies the top-level array. */
|
||||||
pendingToolCalls: Set<string>;
|
set messages(messages: AgentMessage[]);
|
||||||
error?: string;
|
get messages(): AgentMessage[];
|
||||||
|
/** True while the agent is processing a prompt or continuation. */
|
||||||
|
readonly isStreaming: boolean;
|
||||||
|
/** Partial assistant message for the current streamed response, if any. */
|
||||||
|
readonly streamingMessage?: AgentMessage;
|
||||||
|
/** Tool call ids currently executing. */
|
||||||
|
readonly pendingToolCalls: ReadonlySet<string>;
|
||||||
|
/** Error message from the most recent failed or aborted assistant turn, if any. */
|
||||||
|
readonly errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Final or partial result produced by a tool. */
|
||||||
export interface AgentToolResult<T> {
|
export interface AgentToolResult<T> {
|
||||||
// Content blocks supporting text and images
|
/** Text or image content returned to the model. */
|
||||||
content: (TextContent | ImageContent)[];
|
content: (TextContent | ImageContent)[];
|
||||||
// Details to be displayed in a UI or logged
|
/** Arbitrary structured details for logs or UI rendering. */
|
||||||
details: T;
|
details: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback for streaming tool execution updates
|
/** Callback used by tools to stream partial execution updates. */
|
||||||
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
||||||
|
|
||||||
// AgentTool extends Tool but adds argument preparation and execution hooks
|
/** Tool definition used by the agent runtime. */
|
||||||
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
|
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
|
||||||
// A human-readable label for the tool to be displayed in UI
|
/** Human-readable label for UI display. */
|
||||||
label: string;
|
label: string;
|
||||||
// Optional compatibility shim to prepare raw tool call arguments before schema validation.
|
/**
|
||||||
// Must return an object conforming to TParameters.
|
* Optional compatibility shim for raw tool-call arguments before schema validation.
|
||||||
|
* Must return an object that matches `TParameters`.
|
||||||
|
*/
|
||||||
prepareArguments?: (args: unknown) => Static<TParameters>;
|
prepareArguments?: (args: unknown) => Static<TParameters>;
|
||||||
|
/** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
|
||||||
execute: (
|
execute: (
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
params: Static<TParameters>,
|
params: Static<TParameters>,
|
||||||
@@ -284,10 +302,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|||||||
) => Promise<AgentToolResult<TDetails>>;
|
) => Promise<AgentToolResult<TDetails>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentContext is like Context but uses AgentTool
|
/** Context snapshot passed into the low-level agent loop. */
|
||||||
export interface AgentContext {
|
export interface AgentContext {
|
||||||
|
/** System prompt included with the request. */
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
|
/** Transcript visible to the model. */
|
||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
|
/** Tools available for this run. */
|
||||||
tools?: AgentTool<any>[];
|
tools?: AgentTool<any>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ describe("Agent", () => {
|
|||||||
expect(agent.state.tools).toEqual([]);
|
expect(agent.state.tools).toEqual([]);
|
||||||
expect(agent.state.messages).toEqual([]);
|
expect(agent.state.messages).toEqual([]);
|
||||||
expect(agent.state.isStreaming).toBe(false);
|
expect(agent.state.isStreaming).toBe(false);
|
||||||
expect(agent.state.streamMessage).toBe(null);
|
expect(agent.state.streamingMessage).toBe(undefined);
|
||||||
expect(agent.state.pendingToolCalls).toEqual(new Set());
|
expect(agent.state.pendingToolCalls).toEqual(new Set());
|
||||||
expect(agent.state.error).toBeUndefined();
|
expect(agent.state.errorMessage).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create an agent instance with custom initial state", () => {
|
it("should create an agent instance with custom initial state", () => {
|
||||||
@@ -79,13 +79,13 @@ describe("Agent", () => {
|
|||||||
expect(eventCount).toBe(0);
|
expect(eventCount).toBe(0);
|
||||||
|
|
||||||
// State mutators don't emit events
|
// State mutators don't emit events
|
||||||
agent.setSystemPrompt("Test prompt");
|
agent.state.systemPrompt = "Test prompt";
|
||||||
expect(eventCount).toBe(0);
|
expect(eventCount).toBe(0);
|
||||||
expect(agent.state.systemPrompt).toBe("Test prompt");
|
expect(agent.state.systemPrompt).toBe("Test prompt");
|
||||||
|
|
||||||
// Unsubscribe should work
|
// Unsubscribe should work
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
agent.setSystemPrompt("Another prompt");
|
agent.state.systemPrompt = "Another prompt";
|
||||||
expect(eventCount).toBe(0); // Should not increase
|
expect(eventCount).toBe(0); // Should not increase
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,37 +93,38 @@ describe("Agent", () => {
|
|||||||
const agent = new Agent();
|
const agent = new Agent();
|
||||||
|
|
||||||
// Test setSystemPrompt
|
// Test setSystemPrompt
|
||||||
agent.setSystemPrompt("Custom prompt");
|
agent.state.systemPrompt = "Custom prompt";
|
||||||
expect(agent.state.systemPrompt).toBe("Custom prompt");
|
expect(agent.state.systemPrompt).toBe("Custom prompt");
|
||||||
|
|
||||||
// Test setModel
|
// Test setModel
|
||||||
const newModel = getModel("google", "gemini-2.5-flash");
|
const newModel = getModel("google", "gemini-2.5-flash");
|
||||||
agent.setModel(newModel);
|
agent.state.model = newModel;
|
||||||
expect(agent.state.model).toBe(newModel);
|
expect(agent.state.model).toBe(newModel);
|
||||||
|
|
||||||
// Test setThinkingLevel
|
// Test setThinkingLevel
|
||||||
agent.setThinkingLevel("high");
|
agent.state.thinkingLevel = "high";
|
||||||
expect(agent.state.thinkingLevel).toBe("high");
|
expect(agent.state.thinkingLevel).toBe("high");
|
||||||
|
|
||||||
// Test setTools
|
// Test setTools
|
||||||
const tools = [{ name: "test", description: "test tool" } as any];
|
const tools = [{ name: "test", description: "test tool" } as any];
|
||||||
agent.setTools(tools);
|
agent.state.tools = tools;
|
||||||
expect(agent.state.tools).toBe(tools);
|
expect(agent.state.tools).toEqual(tools);
|
||||||
|
expect(agent.state.tools).not.toBe(tools); // Should be a copy
|
||||||
|
|
||||||
// Test replaceMessages
|
// Test replaceMessages
|
||||||
const messages = [{ role: "user" as const, content: "Hello", timestamp: Date.now() }];
|
const messages = [{ role: "user" as const, content: "Hello", timestamp: Date.now() }];
|
||||||
agent.replaceMessages(messages);
|
agent.state.messages = messages;
|
||||||
expect(agent.state.messages).toEqual(messages);
|
expect(agent.state.messages).toEqual(messages);
|
||||||
expect(agent.state.messages).not.toBe(messages); // Should be a copy
|
expect(agent.state.messages).not.toBe(messages); // Should be a copy
|
||||||
|
|
||||||
// Test appendMessage
|
// Test appendMessage
|
||||||
const newMessage = { role: "assistant" as const, content: [{ type: "text" as const, text: "Hi" }] };
|
const newMessage = { role: "assistant" as const, content: [{ type: "text" as const, text: "Hi" }] };
|
||||||
agent.appendMessage(newMessage as any);
|
agent.state.messages.push(newMessage as any);
|
||||||
expect(agent.state.messages).toHaveLength(2);
|
expect(agent.state.messages).toHaveLength(2);
|
||||||
expect(agent.state.messages[1]).toBe(newMessage);
|
expect(agent.state.messages[1]).toBe(newMessage);
|
||||||
|
|
||||||
// Test clearMessages
|
// Test clearMessages
|
||||||
agent.clearMessages();
|
agent.state.messages = [];
|
||||||
expect(agent.state.messages).toEqual([]);
|
expect(agent.state.messages).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,14 +242,14 @@ describe("Agent", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
agent.replaceMessages([
|
agent.state.messages = [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "Initial" }],
|
content: [{ type: "text", text: "Initial" }],
|
||||||
timestamp: Date.now() - 10,
|
timestamp: Date.now() - 10,
|
||||||
},
|
},
|
||||||
createAssistantMessage("Initial response"),
|
createAssistantMessage("Initial response"),
|
||||||
]);
|
];
|
||||||
|
|
||||||
agent.followUp({
|
agent.followUp({
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -285,14 +286,14 @@ describe("Agent", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
agent.replaceMessages([
|
agent.state.messages = [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "Initial" }],
|
content: [{ type: "text", text: "Initial" }],
|
||||||
timestamp: Date.now() - 10,
|
timestamp: Date.now() - 10,
|
||||||
},
|
},
|
||||||
createAssistantMessage("Initial response"),
|
createAssistantMessage("Initial response"),
|
||||||
]);
|
];
|
||||||
|
|
||||||
agent.steer({
|
agent.steer({
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
/**
|
|
||||||
* A test suite to ensure Amazon Bedrock models work correctly with the agent loop.
|
|
||||||
*
|
|
||||||
* Some Bedrock models don't support all features (e.g., reasoning signatures).
|
|
||||||
* This test suite verifies that the agent loop works with various Bedrock models.
|
|
||||||
*
|
|
||||||
* This test suite is not enabled by default unless AWS credentials and
|
|
||||||
* `BEDROCK_EXTENSIVE_MODEL_TEST` environment variables are set.
|
|
||||||
*
|
|
||||||
* You can run this test suite with:
|
|
||||||
* ```bash
|
|
||||||
* $ AWS_REGION=us-east-1 BEDROCK_EXTENSIVE_MODEL_TEST=1 AWS_PROFILE=pi npm test -- ./test/bedrock-models.test.ts
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* ## Known Issues by Category
|
|
||||||
*
|
|
||||||
* 1. **Inference Profile Required**: Some models require an inference profile ARN instead of on-demand.
|
|
||||||
* 2. **Invalid Model ID**: Model identifiers that don't exist in the current region.
|
|
||||||
* 3. **Max Tokens Exceeded**: Model's maxTokens in our config exceeds the actual limit.
|
|
||||||
* 4. **No Reasoning in User Messages**: Model rejects reasoning content when replayed in conversation.
|
|
||||||
* 5. **Invalid Signature Format**: Model validates signature format (Anthropic newer models).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { getModels } from "@mariozechner/pi-ai";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { Agent } from "../src/index.js";
|
|
||||||
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Known Issue Categories
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/** Models that require inference profile ARN (not available on-demand in us-east-1) */
|
|
||||||
const REQUIRES_INFERENCE_PROFILE = new Set([
|
|
||||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
||||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
|
||||||
"anthropic.claude-3-opus-20240229-v1:0",
|
|
||||||
"meta.llama3-1-70b-instruct-v1:0",
|
|
||||||
"meta.llama3-1-8b-instruct-v1:0",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Models with invalid identifiers (not available in us-east-1 or don't exist) */
|
|
||||||
const INVALID_MODEL_ID = new Set([
|
|
||||||
"deepseek.v3-v1:0",
|
|
||||||
"eu.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
||||||
"eu.anthropic.claude-opus-4-5-20251101-v1:0",
|
|
||||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
||||||
"qwen.qwen3-235b-a22b-2507-v1:0",
|
|
||||||
"qwen.qwen3-coder-480b-a35b-v1:0",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Models where our maxTokens config exceeds the model's actual limit */
|
|
||||||
const MAX_TOKENS_EXCEEDED = new Set([
|
|
||||||
"us.meta.llama4-maverick-17b-instruct-v1:0",
|
|
||||||
"us.meta.llama4-scout-17b-instruct-v1:0",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Models that reject reasoning content in user messages (when replaying conversation).
|
|
||||||
* These work for multi-turn but fail when synthetic thinking is injected.
|
|
||||||
*/
|
|
||||||
const NO_REASONING_IN_USER_MESSAGES = new Set([
|
|
||||||
// Mistral models
|
|
||||||
"mistral.ministral-3-14b-instruct",
|
|
||||||
"mistral.ministral-3-8b-instruct",
|
|
||||||
"mistral.mistral-large-2402-v1:0",
|
|
||||||
"mistral.voxtral-mini-3b-2507",
|
|
||||||
"mistral.voxtral-small-24b-2507",
|
|
||||||
// Nvidia models
|
|
||||||
"nvidia.nemotron-nano-12b-v2",
|
|
||||||
"nvidia.nemotron-nano-9b-v2",
|
|
||||||
// Qwen models
|
|
||||||
"qwen.qwen3-coder-30b-a3b-v1:0",
|
|
||||||
// Amazon Nova models
|
|
||||||
"us.amazon.nova-lite-v1:0",
|
|
||||||
"us.amazon.nova-micro-v1:0",
|
|
||||||
"us.amazon.nova-premier-v1:0",
|
|
||||||
"us.amazon.nova-pro-v1:0",
|
|
||||||
// Meta Llama models
|
|
||||||
"us.meta.llama3-2-11b-instruct-v1:0",
|
|
||||||
"us.meta.llama3-2-1b-instruct-v1:0",
|
|
||||||
"us.meta.llama3-2-3b-instruct-v1:0",
|
|
||||||
"us.meta.llama3-2-90b-instruct-v1:0",
|
|
||||||
"us.meta.llama3-3-70b-instruct-v1:0",
|
|
||||||
// DeepSeek
|
|
||||||
"us.deepseek.r1-v1:0",
|
|
||||||
// Older Anthropic models
|
|
||||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
|
||||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
|
||||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
|
||||||
// Cohere models
|
|
||||||
"cohere.command-r-plus-v1:0",
|
|
||||||
"cohere.command-r-v1:0",
|
|
||||||
// Google models
|
|
||||||
"google.gemma-3-27b-it",
|
|
||||||
"google.gemma-3-4b-it",
|
|
||||||
// Non-Anthropic models that don't support signatures (now handled by omitting signature)
|
|
||||||
// but still reject reasoning content in user messages
|
|
||||||
"global.amazon.nova-2-lite-v1:0",
|
|
||||||
"minimax.minimax-m2",
|
|
||||||
"moonshot.kimi-k2-thinking",
|
|
||||||
"openai.gpt-oss-120b-1:0",
|
|
||||||
"openai.gpt-oss-20b-1:0",
|
|
||||||
"openai.gpt-oss-safeguard-120b",
|
|
||||||
"openai.gpt-oss-safeguard-20b",
|
|
||||||
"qwen.qwen3-32b-v1:0",
|
|
||||||
"qwen.qwen3-next-80b-a3b",
|
|
||||||
"qwen.qwen3-vl-235b-a22b",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Models that validate signature format (Anthropic newer models).
|
|
||||||
* These work for multi-turn but fail when synthetic/invalid signature is injected.
|
|
||||||
*/
|
|
||||||
const VALIDATES_SIGNATURE_FORMAT = new Set([
|
|
||||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
||||||
"global.anthropic.claude-opus-4-5-20251101-v1:0",
|
|
||||||
"global.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
||||||
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
||||||
"us.anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
"us.anthropic.claude-opus-4-1-20250805-v1:0",
|
|
||||||
"us.anthropic.claude-opus-4-20250514-v1:0",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DeepSeek R1 fails multi-turn because it rejects reasoning in the replayed assistant message.
|
|
||||||
*/
|
|
||||||
const REJECTS_REASONING_ON_REPLAY = new Set(["us.deepseek.r1-v1:0"]);
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Helper Functions
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function isModelUnavailable(modelId: string): boolean {
|
|
||||||
return REQUIRES_INFERENCE_PROFILE.has(modelId) || INVALID_MODEL_ID.has(modelId) || MAX_TOKENS_EXCEEDED.has(modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function failsMultiTurnWithThinking(modelId: string): boolean {
|
|
||||||
return REJECTS_REASONING_ON_REPLAY.has(modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function failsSyntheticSignature(modelId: string): boolean {
|
|
||||||
return NO_REASONING_IN_USER_MESSAGES.has(modelId) || VALIDATES_SIGNATURE_FORMAT.has(modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Tests
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("Amazon Bedrock Models - Agent Loop", () => {
|
|
||||||
const shouldRunExtensiveTests = hasBedrockCredentials() && process.env.BEDROCK_EXTENSIVE_MODEL_TEST;
|
|
||||||
|
|
||||||
// Get all Amazon Bedrock models
|
|
||||||
const allBedrockModels = getModels("amazon-bedrock");
|
|
||||||
|
|
||||||
if (shouldRunExtensiveTests) {
|
|
||||||
for (const model of allBedrockModels) {
|
|
||||||
const modelId = model.id;
|
|
||||||
|
|
||||||
describe(`Model: ${modelId}`, () => {
|
|
||||||
// Skip entirely unavailable models
|
|
||||||
const unavailable = isModelUnavailable(modelId);
|
|
||||||
|
|
||||||
it.skipIf(unavailable)("should handle basic text prompt", { timeout: 60_000 }, async () => {
|
|
||||||
const agent = new Agent({
|
|
||||||
initialState: {
|
|
||||||
systemPrompt: "You are a helpful assistant. Be extremely concise.",
|
|
||||||
model,
|
|
||||||
thinkingLevel: "off",
|
|
||||||
tools: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await agent.prompt("Reply with exactly: 'OK'");
|
|
||||||
|
|
||||||
if (agent.state.error) {
|
|
||||||
throw new Error(`Basic prompt error: ${agent.state.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(agent.state.isStreaming).toBe(false);
|
|
||||||
expect(agent.state.messages.length).toBe(2);
|
|
||||||
|
|
||||||
const assistantMessage = agent.state.messages[1];
|
|
||||||
if (assistantMessage.role !== "assistant") throw new Error("Expected assistant message");
|
|
||||||
|
|
||||||
console.log(`${modelId}: OK`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Skip if model is unavailable or known to fail multi-turn with thinking
|
|
||||||
const skipMultiTurn = unavailable || failsMultiTurnWithThinking(modelId);
|
|
||||||
|
|
||||||
it.skipIf(skipMultiTurn)(
|
|
||||||
"should handle multi-turn conversation with thinking content in history",
|
|
||||||
{ timeout: 120_000 },
|
|
||||||
async () => {
|
|
||||||
const agent = new Agent({
|
|
||||||
initialState: {
|
|
||||||
systemPrompt: "You are a helpful assistant. Be extremely concise.",
|
|
||||||
model,
|
|
||||||
thinkingLevel: "medium",
|
|
||||||
tools: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// First turn
|
|
||||||
await agent.prompt("My name is Alice.");
|
|
||||||
|
|
||||||
if (agent.state.error) {
|
|
||||||
throw new Error(`First turn error: ${agent.state.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second turn - this should replay the first assistant message which may contain thinking
|
|
||||||
await agent.prompt("What is my name?");
|
|
||||||
|
|
||||||
if (agent.state.error) {
|
|
||||||
throw new Error(`Second turn error: ${agent.state.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(agent.state.messages.length).toBe(4);
|
|
||||||
console.log(`${modelId}: multi-turn OK`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip if model is unavailable or known to fail synthetic signature
|
|
||||||
const skipSynthetic = unavailable || failsSyntheticSignature(modelId);
|
|
||||||
|
|
||||||
it.skipIf(skipSynthetic)(
|
|
||||||
"should handle conversation with synthetic thinking signature in history",
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
async () => {
|
|
||||||
const agent = new Agent({
|
|
||||||
initialState: {
|
|
||||||
systemPrompt: "You are a helpful assistant. Be extremely concise.",
|
|
||||||
model,
|
|
||||||
thinkingLevel: "off",
|
|
||||||
tools: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inject a message with a thinking block that has a signature
|
|
||||||
const syntheticAssistantMessage: AssistantMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "thinking",
|
|
||||||
thinking: "I need to remember the user's name.",
|
|
||||||
thinkingSignature: "synthetic-signature-123",
|
|
||||||
},
|
|
||||||
{ type: "text", text: "Nice to meet you, Alice!" },
|
|
||||||
],
|
|
||||||
api: "bedrock-converse-stream",
|
|
||||||
provider: "amazon-bedrock",
|
|
||||||
model: modelId,
|
|
||||||
usage: {
|
|
||||||
input: 10,
|
|
||||||
output: 20,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 30,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
agent.replaceMessages([
|
|
||||||
{ role: "user", content: "My name is Alice.", timestamp: Date.now() },
|
|
||||||
syntheticAssistantMessage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
await agent.prompt("What is my name?");
|
|
||||||
|
|
||||||
if (agent.state.error) {
|
|
||||||
throw new Error(`Synthetic signature error: ${agent.state.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(agent.state.messages.length).toBe(4);
|
|
||||||
console.log(`${modelId}: synthetic signature OK`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
it.skip("skipped - set AWS credentials and BEDROCK_EXTENSIVE_MODEL_TEST=1 to run", () => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Utility functions for Amazon Bedrock tests
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if any valid AWS credentials are configured for Bedrock.
|
|
||||||
* Returns true if any of the following are set:
|
|
||||||
* - AWS_PROFILE (named profile from ~/.aws/credentials)
|
|
||||||
* - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (IAM keys)
|
|
||||||
* - AWS_BEARER_TOKEN_BEDROCK (Bedrock API key)
|
|
||||||
*/
|
|
||||||
export function hasBedrockCredentials(): boolean {
|
|
||||||
return !!(
|
|
||||||
process.env.AWS_PROFILE ||
|
|
||||||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
|
|
||||||
process.env.AWS_BEARER_TOKEN_BEDROCK
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,41 @@
|
|||||||
import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
import {
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
type AssistantMessage,
|
||||||
import { describe, expect, it } from "vitest";
|
type FauxProviderRegistration,
|
||||||
import { Agent } from "../src/index.js";
|
fauxAssistantMessage,
|
||||||
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
fauxText,
|
||||||
|
fauxThinking,
|
||||||
|
fauxToolCall,
|
||||||
|
type Model,
|
||||||
|
registerFauxProvider,
|
||||||
|
type ToolResultMessage,
|
||||||
|
type UserMessage,
|
||||||
|
} from "@mariozechner/pi-ai";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { Agent, type AgentEvent } from "../src/index.js";
|
||||||
import { calculateTool } from "./utils/calculate.js";
|
import { calculateTool } from "./utils/calculate.js";
|
||||||
|
|
||||||
delete process.env.ANTHROPIC_OAUTH_TOKEN;
|
const registrations: FauxProviderRegistration[] = [];
|
||||||
|
|
||||||
async function basicPrompt(model: Model<any>) {
|
function createFauxRegistration(options: Parameters<typeof registerFauxProvider>[0] = {}): FauxProviderRegistration {
|
||||||
|
const registration = registerFauxProvider(options);
|
||||||
|
registrations.push(registration);
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContent(message: AssistantMessage | ToolResultMessage): string {
|
||||||
|
return message.content
|
||||||
|
.filter((block) => block.type === "text")
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (registrations.length > 0) {
|
||||||
|
registrations.pop()?.unregister();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function basicPrompt(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant. Keep your responses concise.",
|
systemPrompt: "You are a helpful assistant. Keep your responses concise.",
|
||||||
@@ -26,15 +54,10 @@ async function basicPrompt(model: Model<any>) {
|
|||||||
|
|
||||||
const assistantMessage = agent.state.messages[1];
|
const assistantMessage = agent.state.messages[1];
|
||||||
if (assistantMessage.role !== "assistant") throw new Error("Expected assistant message");
|
if (assistantMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||||
expect(assistantMessage.content.length).toBeGreaterThan(0);
|
expect(getTextContent(assistantMessage)).toContain("4");
|
||||||
|
|
||||||
const textContent = assistantMessage.content.find((c) => c.type === "text");
|
|
||||||
expect(textContent).toBeDefined();
|
|
||||||
if (textContent?.type !== "text") throw new Error("Expected text content");
|
|
||||||
expect(textContent.text).toContain("4");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toolExecution(model: Model<any>) {
|
async function toolExecution(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
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.",
|
||||||
@@ -44,52 +67,49 @@ async function toolExecution(model: Model<any>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pendingToolCallsDuringEvents: Array<{ type: AgentEvent["type"]; ids: string[] }> = [];
|
||||||
|
agent.subscribe((event) => {
|
||||||
|
if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
|
||||||
|
pendingToolCallsDuringEvents.push({
|
||||||
|
type: event.type,
|
||||||
|
ids: [...agent.state.pendingToolCalls],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await agent.prompt("Calculate 123 * 456 using the calculator tool.");
|
await agent.prompt("Calculate 123 * 456 using the calculator tool.");
|
||||||
|
|
||||||
expect(agent.state.isStreaming).toBe(false);
|
expect(agent.state.isStreaming).toBe(false);
|
||||||
expect(agent.state.messages.length).toBeGreaterThanOrEqual(3);
|
expect(agent.state.messages.length).toBeGreaterThanOrEqual(4);
|
||||||
|
const toolResultMsg = agent.state.messages.find((message) => message.role === "toolResult");
|
||||||
const toolResultMsg = agent.state.messages.find((m) => m.role === "toolResult");
|
|
||||||
expect(toolResultMsg).toBeDefined();
|
expect(toolResultMsg).toBeDefined();
|
||||||
if (toolResultMsg?.role !== "toolResult") throw new Error("Expected tool result message");
|
if (toolResultMsg?.role !== "toolResult") throw new Error("Expected tool result message");
|
||||||
const textContent =
|
expect(getTextContent(toolResultMsg)).toContain("123 * 456 = 56088");
|
||||||
toolResultMsg.content
|
|
||||||
?.filter((c) => c.type === "text")
|
|
||||||
.map((c: any) => c.text)
|
|
||||||
.join("\n") || "";
|
|
||||||
expect(textContent).toBeDefined();
|
|
||||||
|
|
||||||
const expectedResult = 123 * 456;
|
|
||||||
expect(textContent).toContain(String(expectedResult));
|
|
||||||
|
|
||||||
const finalMessage = agent.state.messages[agent.state.messages.length - 1];
|
const finalMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||||
if (finalMessage.role !== "assistant") throw new Error("Expected final assistant message");
|
if (finalMessage.role !== "assistant") throw new Error("Expected final assistant message");
|
||||||
const finalText = finalMessage.content.find((c) => c.type === "text");
|
expect(getTextContent(finalMessage)).toContain("56088");
|
||||||
expect(finalText).toBeDefined();
|
expect(agent.state.pendingToolCalls.size).toBe(0);
|
||||||
if (finalText?.type !== "text") throw new Error("Expected text content");
|
expect(pendingToolCallsDuringEvents).toEqual([
|
||||||
// Check for number with or without comma formatting
|
{ type: "tool_execution_start", ids: ["calc-1"] },
|
||||||
const hasNumber =
|
{ type: "tool_execution_end", ids: [] },
|
||||||
finalText.text.includes(String(expectedResult)) ||
|
]);
|
||||||
finalText.text.includes("56,088") ||
|
|
||||||
finalText.text.includes("56088");
|
|
||||||
expect(hasNumber).toBe(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function abortExecution(model: Model<any>) {
|
async function abortExecution(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
model,
|
model,
|
||||||
thinkingLevel: "off",
|
thinkingLevel: "off",
|
||||||
tools: [calculateTool],
|
tools: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const promptPromise = agent.prompt("Calculate 100 * 200, then 300 * 400, then sum the results.");
|
const promptPromise = agent.prompt("Count slowly from 1 to 20.");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
agent.abort();
|
agent.abort();
|
||||||
}, 100);
|
}, 30);
|
||||||
|
|
||||||
await promptPromise;
|
await promptPromise;
|
||||||
|
|
||||||
@@ -100,11 +120,10 @@ async function abortExecution(model: Model<any>) {
|
|||||||
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||||
expect(lastMessage.stopReason).toBe("aborted");
|
expect(lastMessage.stopReason).toBe("aborted");
|
||||||
expect(lastMessage.errorMessage).toBeDefined();
|
expect(lastMessage.errorMessage).toBeDefined();
|
||||||
expect(agent.state.error).toBeDefined();
|
expect(agent.state.errorMessage).toBe(lastMessage.errorMessage);
|
||||||
expect(agent.state.error).toBe(lastMessage.errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stateUpdates(model: Model<any>) {
|
async function stateUpdates(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
@@ -114,29 +133,29 @@ async function stateUpdates(model: Model<any>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const events: Array<string> = [];
|
const events: AgentEvent["type"][] = [];
|
||||||
|
|
||||||
agent.subscribe((event) => {
|
agent.subscribe((event) => {
|
||||||
events.push(event.type);
|
events.push(event.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
await agent.prompt("Count from 1 to 5.");
|
await agent.prompt("Count from 1 to 5.");
|
||||||
|
|
||||||
// Should have received lifecycle events
|
|
||||||
expect(events).toContain("agent_start");
|
expect(events).toContain("agent_start");
|
||||||
expect(events).toContain("agent_end");
|
expect(events).toContain("turn_start");
|
||||||
expect(events).toContain("message_start");
|
expect(events).toContain("message_start");
|
||||||
|
expect(events).toContain("message_update");
|
||||||
expect(events).toContain("message_end");
|
expect(events).toContain("message_end");
|
||||||
// May have message_update events during streaming
|
expect(events).toContain("turn_end");
|
||||||
const hasMessageUpdates = events.some((e) => e === "message_update");
|
expect(events).toContain("agent_end");
|
||||||
expect(hasMessageUpdates).toBe(true);
|
expect(events.indexOf("agent_start")).toBeLessThan(events.indexOf("message_start"));
|
||||||
|
expect(events.indexOf("message_start")).toBeLessThan(events.indexOf("message_end"));
|
||||||
|
expect(events.indexOf("message_end")).toBeLessThan(events.lastIndexOf("agent_end"));
|
||||||
|
|
||||||
// Check final state
|
|
||||||
expect(agent.state.isStreaming).toBe(false);
|
expect(agent.state.isStreaming).toBe(false);
|
||||||
expect(agent.state.messages.length).toBe(2); // User message + assistant response
|
expect(agent.state.messages.length).toBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function multiTurnConversation(model: Model<any>) {
|
async function multiTurnConversation(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
@@ -154,232 +173,120 @@ async function multiTurnConversation(model: Model<any>) {
|
|||||||
|
|
||||||
const lastMessage = agent.state.messages[3];
|
const lastMessage = agent.state.messages[3];
|
||||||
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||||
const lastText = lastMessage.content.find((c) => c.type === "text");
|
expect(getTextContent(lastMessage).toLowerCase()).toContain("alice");
|
||||||
if (lastText?.type !== "text") throw new Error("Expected text content");
|
|
||||||
expect(lastText.text.toLowerCase()).toContain("alice");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Agent E2E Tests", () => {
|
describe("Agent integration with faux provider", () => {
|
||||||
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider (gemini-2.5-flash)", () => {
|
it("handles a basic text prompt", async () => {
|
||||||
const model = getModel("google", "gemini-2.5-flash");
|
const faux = createFauxRegistration();
|
||||||
|
faux.setResponses([fauxAssistantMessage("4")]);
|
||||||
it("should handle basic text prompt", async () => {
|
await basicPrompt(faux.getModel());
|
||||||
await basicPrompt(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should execute tools correctly", async () => {
|
|
||||||
await toolExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle abort during execution", async () => {
|
|
||||||
await abortExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit state updates during streaming", async () => {
|
|
||||||
await stateUpdates(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Provider (gpt-4o-mini)", () => {
|
it("executes tools and tracks pending tool calls", async () => {
|
||||||
const model = getModel("openai", "gpt-4o-mini");
|
const faux = createFauxRegistration();
|
||||||
|
faux.setResponses([
|
||||||
it("should handle basic text prompt", async () => {
|
fauxAssistantMessage(
|
||||||
await basicPrompt(model);
|
[
|
||||||
});
|
fauxText("Let me calculate that."),
|
||||||
|
fauxToolCall("calculate", { expression: "123 * 456" }, { id: "calc-1" }),
|
||||||
it("should execute tools correctly", async () => {
|
],
|
||||||
await toolExecution(model);
|
{ stopReason: "toolUse" },
|
||||||
});
|
),
|
||||||
|
fauxAssistantMessage("The result is 56088."),
|
||||||
it("should handle abort during execution", async () => {
|
]);
|
||||||
await abortExecution(model);
|
await toolExecution(faux.getModel());
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit state updates during streaming", async () => {
|
|
||||||
await stateUpdates(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider (claude-haiku-4-5)", () => {
|
it("handles abort during streaming", async () => {
|
||||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
const faux = createFauxRegistration({
|
||||||
|
tokensPerSecond: 20,
|
||||||
it("should handle basic text prompt", async () => {
|
tokenSize: { min: 2, max: 2 },
|
||||||
await basicPrompt(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should execute tools correctly", async () => {
|
|
||||||
await toolExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle abort during execution", async () => {
|
|
||||||
await abortExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit state updates during streaming", async () => {
|
|
||||||
await stateUpdates(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
});
|
||||||
|
faux.setResponses([
|
||||||
|
fauxAssistantMessage(
|
||||||
|
"one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await abortExecution(faux.getModel());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-3)", () => {
|
it("emits lifecycle updates while streaming", async () => {
|
||||||
const model = getModel("xai", "grok-3");
|
const faux = createFauxRegistration({ tokenSize: { min: 1, max: 1 } });
|
||||||
|
faux.setResponses([fauxAssistantMessage("1 2 3 4 5")]);
|
||||||
it("should handle basic text prompt", async () => {
|
await stateUpdates(faux.getModel());
|
||||||
await basicPrompt(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should execute tools correctly", async () => {
|
|
||||||
await toolExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle abort during execution", async () => {
|
|
||||||
await abortExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit state updates during streaming", async () => {
|
|
||||||
await stateUpdates(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!process.env.GROQ_API_KEY)("Groq Provider (openai/gpt-oss-20b)", () => {
|
it("maintains context across multiple turns", async () => {
|
||||||
const model = getModel("groq", "openai/gpt-oss-20b");
|
const faux = createFauxRegistration();
|
||||||
|
faux.setResponses([
|
||||||
it("should handle basic text prompt", async () => {
|
fauxAssistantMessage("Nice to meet you, Alice."),
|
||||||
await basicPrompt(model);
|
(context) => {
|
||||||
});
|
const hasAlice = context.messages.some((message) => {
|
||||||
|
if (message.role !== "user") return false;
|
||||||
it("should execute tools correctly", async () => {
|
if (typeof message.content === "string") return message.content.includes("Alice");
|
||||||
await toolExecution(model);
|
return message.content.some((block) => block.type === "text" && block.text.includes("Alice"));
|
||||||
});
|
});
|
||||||
|
return fauxAssistantMessage(hasAlice ? "Your name is Alice." : "I do not know your name.");
|
||||||
it("should handle abort during execution", async () => {
|
},
|
||||||
await abortExecution(model);
|
]);
|
||||||
});
|
await multiTurnConversation(faux.getModel());
|
||||||
|
|
||||||
it("should emit state updates during streaming", async () => {
|
|
||||||
await stateUpdates(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/*describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider (gpt-oss-120b)", () => {
|
it("preserves thinking content blocks", async () => {
|
||||||
const model = getModel("cerebras", "gpt-oss-120b");
|
const faux = createFauxRegistration({ models: [{ id: "faux-reasoning", reasoning: true }] });
|
||||||
|
faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]);
|
||||||
|
|
||||||
it("should handle basic text prompt", async () => {
|
const agent = new Agent({
|
||||||
await basicPrompt(model);
|
initialState: {
|
||||||
|
systemPrompt: "You are a helpful assistant.",
|
||||||
|
model: faux.getModel(),
|
||||||
|
thinkingLevel: "low",
|
||||||
|
tools: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should execute tools correctly", async () => {
|
await agent.prompt("What is 2+2?");
|
||||||
await toolExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle abort during execution", async () => {
|
const assistantMessage = agent.state.messages[1];
|
||||||
await abortExecution(model);
|
if (assistantMessage?.role !== "assistant") throw new Error("Expected assistant message");
|
||||||
});
|
expect(assistantMessage.content).toEqual([
|
||||||
|
{ type: "thinking", thinking: "step by step" },
|
||||||
it("should emit state updates during streaming", async () => {
|
{ type: "text", text: "4" },
|
||||||
await stateUpdates(model);
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
|
||||||
});*/
|
|
||||||
|
|
||||||
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air)", () => {
|
|
||||||
const model = getModel("zai", "glm-4.5-air");
|
|
||||||
|
|
||||||
it("should handle basic text prompt", async () => {
|
|
||||||
await basicPrompt(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should execute tools correctly", async () => {
|
|
||||||
await toolExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle abort during execution", async () => {
|
|
||||||
await abortExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit state updates during streaming", async () => {
|
|
||||||
await stateUpdates(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-sonnet-4-5)", () => {
|
|
||||||
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
|
||||||
|
|
||||||
it("should handle basic text prompt", async () => {
|
|
||||||
await basicPrompt(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should execute tools correctly", async () => {
|
|
||||||
await toolExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle abort during execution", async () => {
|
|
||||||
await abortExecution(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit state updates during streaming", async () => {
|
|
||||||
await stateUpdates(model);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain context across multiple turns", async () => {
|
|
||||||
await multiTurnConversation(model);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Agent.continue()", () => {
|
describe("Agent.continue() with faux provider", () => {
|
||||||
describe("validation", () => {
|
describe("validation", () => {
|
||||||
it("should throw when no messages in context", async () => {
|
it("throws when no messages in context", async () => {
|
||||||
|
const faux = createFauxRegistration();
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
model: getModel("openai", "gpt-5.4"),
|
model: faux.getModel(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(agent.continue()).rejects.toThrow("No messages to continue from");
|
await expect(agent.continue()).rejects.toThrow("No messages to continue from");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when last message is assistant", async () => {
|
it("throws when last message is assistant", async () => {
|
||||||
|
const faux = createFauxRegistration();
|
||||||
|
const model = faux.getModel();
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
model: getModel("openai", "gpt-5.4"),
|
model,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const assistantMessage: AssistantMessage = {
|
const assistantMessage: AssistantMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text: "Hello" }],
|
content: [{ type: "text", text: "Hello" }],
|
||||||
api: "openai-responses",
|
api: model.api,
|
||||||
provider: "openai",
|
provider: model.provider,
|
||||||
model: "gpt-5.4",
|
model: model.id,
|
||||||
usage: {
|
usage: {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
@@ -391,34 +298,32 @@ describe("Agent.continue()", () => {
|
|||||||
stopReason: "stop",
|
stopReason: "stop",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
agent.replaceMessages([assistantMessage]);
|
agent.state.messages = [assistantMessage];
|
||||||
|
|
||||||
await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant");
|
await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!process.env.OPENAI_API_KEY)("continue from user message", () => {
|
describe("continue from user message", () => {
|
||||||
const model = getModel("openai", "gpt-5.4");
|
it("continues and gets a response when last message is user", async () => {
|
||||||
|
const faux = createFauxRegistration();
|
||||||
it("should continue and get response when last message is user", async () => {
|
faux.setResponses([fauxAssistantMessage("HELLO WORLD")]);
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
|
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
|
||||||
model,
|
model: faux.getModel(),
|
||||||
thinkingLevel: "off",
|
thinkingLevel: "off",
|
||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manually add a user message without calling prompt()
|
|
||||||
const userMessage: UserMessage = {
|
const userMessage: UserMessage = {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "Say exactly: HELLO WORLD" }],
|
content: [{ type: "text", text: "Say exactly: HELLO WORLD" }],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
agent.replaceMessages([userMessage]);
|
agent.state.messages = [userMessage];
|
||||||
|
|
||||||
// Continue from the user message
|
|
||||||
await agent.continue();
|
await agent.continue();
|
||||||
|
|
||||||
expect(agent.state.isStreaming).toBe(false);
|
expect(agent.state.isStreaming).toBe(false);
|
||||||
@@ -426,19 +331,17 @@ describe("Agent.continue()", () => {
|
|||||||
expect(agent.state.messages[0].role).toBe("user");
|
expect(agent.state.messages[0].role).toBe("user");
|
||||||
expect(agent.state.messages[1].role).toBe("assistant");
|
expect(agent.state.messages[1].role).toBe("assistant");
|
||||||
|
|
||||||
const assistantMsg = agent.state.messages[1] as AssistantMessage;
|
const assistantMsg = agent.state.messages[1];
|
||||||
const textContent = assistantMsg.content.find((c) => c.type === "text");
|
if (assistantMsg.role !== "assistant") throw new Error("Expected assistant message");
|
||||||
expect(textContent).toBeDefined();
|
expect(getTextContent(assistantMsg).toUpperCase()).toContain("HELLO WORLD");
|
||||||
if (textContent?.type === "text") {
|
|
||||||
expect(textContent.text.toUpperCase()).toContain("HELLO WORLD");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!process.env.OPENAI_API_KEY)("continue from tool result", () => {
|
describe("continue from tool result", () => {
|
||||||
const model = getModel("openai", "gpt-5.4");
|
it("continues and processes tool results", async () => {
|
||||||
|
const faux = createFauxRegistration();
|
||||||
it("should continue and process tool results", async () => {
|
const model = faux.getModel();
|
||||||
|
faux.setResponses([fauxAssistantMessage("The answer is 8.")]);
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
@@ -449,7 +352,6 @@ describe("Agent.continue()", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up a conversation state as if tool was just executed
|
|
||||||
const userMessage: UserMessage = {
|
const userMessage: UserMessage = {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "What is 5 + 3?" }],
|
content: [{ type: "text", text: "What is 5 + 3?" }],
|
||||||
@@ -462,9 +364,9 @@ describe("Agent.continue()", () => {
|
|||||||
{ type: "text", text: "Let me calculate that." },
|
{ type: "text", text: "Let me calculate that." },
|
||||||
{ type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } },
|
{ type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } },
|
||||||
],
|
],
|
||||||
api: "anthropic-messages",
|
api: model.api,
|
||||||
provider: "anthropic",
|
provider: model.provider,
|
||||||
model: "claude-haiku-4-5",
|
model: model.id,
|
||||||
usage: {
|
usage: {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
@@ -486,26 +388,17 @@ describe("Agent.continue()", () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
agent.replaceMessages([userMessage, assistantMessage, toolResult]);
|
agent.state.messages = [userMessage, assistantMessage, toolResult];
|
||||||
|
|
||||||
// Continue from the tool result
|
|
||||||
await agent.continue();
|
await agent.continue();
|
||||||
|
|
||||||
expect(agent.state.isStreaming).toBe(false);
|
expect(agent.state.isStreaming).toBe(false);
|
||||||
// Should have added an assistant response
|
|
||||||
expect(agent.state.messages.length).toBeGreaterThanOrEqual(4);
|
expect(agent.state.messages.length).toBeGreaterThanOrEqual(4);
|
||||||
|
|
||||||
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
|
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||||
expect(lastMessage.role).toBe("assistant");
|
expect(lastMessage.role).toBe("assistant");
|
||||||
|
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||||
if (lastMessage.role === "assistant") {
|
expect(getTextContent(lastMessage)).toContain("8");
|
||||||
const textContent = lastMessage.content
|
|
||||||
.filter((c) => c.type === "text")
|
|
||||||
.map((c) => (c as { type: "text"; text: string }).text)
|
|
||||||
.join(" ");
|
|
||||||
// Should mention 8 in the response
|
|
||||||
expect(textContent).toMatch(/8/);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2304,6 +2304,23 @@ export const MODELS = {
|
|||||||
contextWindow: 400000,
|
contextWindow: 400000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"azure-openai-responses">,
|
} satisfies Model<"azure-openai-responses">,
|
||||||
|
"gpt-5.3-chat-latest": {
|
||||||
|
id: "gpt-5.3-chat-latest",
|
||||||
|
name: "GPT-5.3 Chat (latest)",
|
||||||
|
api: "azure-openai-responses",
|
||||||
|
provider: "azure-openai-responses",
|
||||||
|
baseUrl: "",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: {
|
||||||
|
input: 1.75,
|
||||||
|
output: 14,
|
||||||
|
cacheRead: 0.175,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 16384,
|
||||||
|
} satisfies Model<"azure-openai-responses">,
|
||||||
"gpt-5.3-codex": {
|
"gpt-5.3-codex": {
|
||||||
id: "gpt-5.3-codex",
|
id: "gpt-5.3-codex",
|
||||||
name: "GPT-5.3 Codex",
|
name: "GPT-5.3 Codex",
|
||||||
@@ -5029,22 +5046,39 @@ export const MODELS = {
|
|||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"mistral-conversations">,
|
} satisfies Model<"mistral-conversations">,
|
||||||
|
"mistral-small-2603": {
|
||||||
|
id: "mistral-small-2603",
|
||||||
|
name: "Mistral Small 4",
|
||||||
|
api: "mistral-conversations",
|
||||||
|
provider: "mistral",
|
||||||
|
baseUrl: "https://api.mistral.ai",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: {
|
||||||
|
input: 0.15,
|
||||||
|
output: 0.6,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 256000,
|
||||||
|
maxTokens: 256000,
|
||||||
|
} satisfies Model<"mistral-conversations">,
|
||||||
"mistral-small-latest": {
|
"mistral-small-latest": {
|
||||||
id: "mistral-small-latest",
|
id: "mistral-small-latest",
|
||||||
name: "Mistral Small (latest)",
|
name: "Mistral Small (latest)",
|
||||||
api: "mistral-conversations",
|
api: "mistral-conversations",
|
||||||
provider: "mistral",
|
provider: "mistral",
|
||||||
baseUrl: "https://api.mistral.ai",
|
baseUrl: "https://api.mistral.ai",
|
||||||
reasoning: false,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.1,
|
input: 0.15,
|
||||||
output: 0.3,
|
output: 0.6,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 128000,
|
contextWindow: 256000,
|
||||||
maxTokens: 16384,
|
maxTokens: 256000,
|
||||||
} satisfies Model<"mistral-conversations">,
|
} satisfies Model<"mistral-conversations">,
|
||||||
"open-mistral-7b": {
|
"open-mistral-7b": {
|
||||||
id: "open-mistral-7b",
|
id: "open-mistral-7b",
|
||||||
@@ -5575,6 +5609,23 @@ export const MODELS = {
|
|||||||
contextWindow: 400000,
|
contextWindow: 400000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-responses">,
|
} satisfies Model<"openai-responses">,
|
||||||
|
"gpt-5.3-chat-latest": {
|
||||||
|
id: "gpt-5.3-chat-latest",
|
||||||
|
name: "GPT-5.3 Chat (latest)",
|
||||||
|
api: "openai-responses",
|
||||||
|
provider: "openai",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: {
|
||||||
|
input: 1.75,
|
||||||
|
output: 14,
|
||||||
|
cacheRead: 0.175,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 16384,
|
||||||
|
} satisfies Model<"openai-responses">,
|
||||||
"gpt-5.3-codex": {
|
"gpt-5.3-codex": {
|
||||||
id: "gpt-5.3-codex",
|
id: "gpt-5.3-codex",
|
||||||
name: "GPT-5.3 Codex",
|
name: "GPT-5.3 Codex",
|
||||||
@@ -10779,12 +10830,12 @@ export const MODELS = {
|
|||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.071,
|
input: 0.22,
|
||||||
output: 0.463,
|
output: 0.88,
|
||||||
cacheRead: 0,
|
cacheRead: 0.11,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 40960,
|
contextWindow: 32768,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
"alibaba/qwen-3-30b": {
|
"alibaba/qwen-3-30b": {
|
||||||
@@ -10813,13 +10864,13 @@ export const MODELS = {
|
|||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.29,
|
input: 0.16,
|
||||||
output: 0.59,
|
output: 0.64,
|
||||||
cacheRead: 0.145,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 131072,
|
contextWindow: 128000,
|
||||||
maxTokens: 40960,
|
maxTokens: 8192,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
"alibaba/qwen3-235b-a22b-thinking": {
|
"alibaba/qwen3-235b-a22b-thinking": {
|
||||||
id: "alibaba/qwen3-235b-a22b-thinking",
|
id: "alibaba/qwen3-235b-a22b-thinking",
|
||||||
@@ -10847,13 +10898,13 @@ export const MODELS = {
|
|||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.39999999999999997,
|
input: 1.5,
|
||||||
output: 1.5999999999999999,
|
output: 7.5,
|
||||||
cacheRead: 0.022,
|
cacheRead: 0.3,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 262144,
|
contextWindow: 262144,
|
||||||
maxTokens: 66536,
|
maxTokens: 65536,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
"alibaba/qwen3-coder-30b-a3b": {
|
"alibaba/qwen3-coder-30b-a3b": {
|
||||||
id: "alibaba/qwen3-coder-30b-a3b",
|
id: "alibaba/qwen3-coder-30b-a3b",
|
||||||
@@ -11323,13 +11374,13 @@ export const MODELS = {
|
|||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.5,
|
input: 0.56,
|
||||||
output: 1.5,
|
output: 1.68,
|
||||||
cacheRead: 0,
|
cacheRead: 0.28,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 163840,
|
contextWindow: 163840,
|
||||||
maxTokens: 16384,
|
maxTokens: 8192,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
"deepseek/deepseek-v3.1-terminus": {
|
"deepseek/deepseek-v3.1-terminus": {
|
||||||
id: "deepseek/deepseek-v3.1-terminus",
|
id: "deepseek/deepseek-v3.1-terminus",
|
||||||
@@ -11601,7 +11652,7 @@ export const MODELS = {
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 100000,
|
maxTokens: 8192,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
"meituan/longcat-flash-thinking": {
|
"meituan/longcat-flash-thinking": {
|
||||||
id: "meituan/longcat-flash-thinking",
|
id: "meituan/longcat-flash-thinking",
|
||||||
@@ -11646,13 +11697,13 @@ export const MODELS = {
|
|||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.09999999999999999,
|
input: 0.22,
|
||||||
output: 0.09999999999999999,
|
output: 0.22,
|
||||||
cacheRead: 0.09999999999999999,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 16384,
|
maxTokens: 8192,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
"meta/llama-3.2-11b": {
|
"meta/llama-3.2-11b": {
|
||||||
id: "meta/llama-3.2-11b",
|
id: "meta/llama-3.2-11b",
|
||||||
@@ -11714,12 +11765,12 @@ export const MODELS = {
|
|||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.24,
|
input: 0.35,
|
||||||
output: 0.9700000000000001,
|
output: 1.15,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 128000,
|
contextWindow: 524288,
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
"meta/llama-4-scout": {
|
"meta/llama-4-scout": {
|
||||||
@@ -12623,6 +12674,23 @@ export const MODELS = {
|
|||||||
contextWindow: 1050000,
|
contextWindow: 1050000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
|
"openai/gpt-oss-120b": {
|
||||||
|
id: "openai/gpt-oss-120b",
|
||||||
|
name: "gpt-oss-120b",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "vercel-ai-gateway",
|
||||||
|
baseUrl: "https://ai-gateway.vercel.sh",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0.15,
|
||||||
|
output: 0.6,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
} satisfies Model<"anthropic-messages">,
|
||||||
"openai/gpt-oss-20b": {
|
"openai/gpt-oss-20b": {
|
||||||
id: "openai/gpt-oss-20b",
|
id: "openai/gpt-oss-20b",
|
||||||
name: "gpt-oss-20b",
|
name: "gpt-oss-20b",
|
||||||
@@ -13108,9 +13176,9 @@ export const MODELS = {
|
|||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.09999999999999999,
|
input: 0.09,
|
||||||
output: 0.3,
|
output: 0.29,
|
||||||
cacheRead: 0.02,
|
cacheRead: 0.045,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 262144,
|
contextWindow: 262144,
|
||||||
@@ -13246,7 +13314,7 @@ export const MODELS = {
|
|||||||
cost: {
|
cost: {
|
||||||
input: 0.6,
|
input: 0.6,
|
||||||
output: 2.2,
|
output: 2.2,
|
||||||
cacheRead: 0,
|
cacheRead: 0.11,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
@@ -13286,6 +13354,23 @@ export const MODELS = {
|
|||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
|
"zai/glm-5": {
|
||||||
|
id: "zai/glm-5",
|
||||||
|
name: "GLM 5",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "vercel-ai-gateway",
|
||||||
|
baseUrl: "https://ai-gateway.vercel.sh",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 1,
|
||||||
|
output: 3.1999999999999997,
|
||||||
|
cacheRead: 0.19999999999999998,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 202800,
|
||||||
|
maxTokens: 131100,
|
||||||
|
} satisfies Model<"anthropic-messages">,
|
||||||
"zai/glm-5-turbo": {
|
"zai/glm-5-turbo": {
|
||||||
id: "zai/glm-5-turbo",
|
id: "zai/glm-5-turbo",
|
||||||
name: "GLM 5 Turbo",
|
name: "GLM 5 Turbo",
|
||||||
|
|||||||
@@ -171,10 +171,15 @@ const state = session.agent.state;
|
|||||||
// state.model: Model - current model
|
// state.model: Model - current model
|
||||||
// state.thinkingLevel: ThinkingLevel - current thinking level
|
// state.thinkingLevel: ThinkingLevel - current thinking level
|
||||||
// state.systemPrompt: string - system prompt
|
// state.systemPrompt: string - system prompt
|
||||||
// state.tools: Tool[] - available tools
|
// state.tools: AgentTool[] - available tools
|
||||||
|
// state.streamingMessage?: AgentMessage - current partial assistant message
|
||||||
|
// state.errorMessage?: string - latest assistant error
|
||||||
|
|
||||||
// Replace messages (useful for branching, restoration)
|
// Replace messages (useful for branching or restoration)
|
||||||
session.agent.replaceMessages(messages);
|
session.agent.state.messages = messages; // copies the top-level array
|
||||||
|
|
||||||
|
// Replace tools
|
||||||
|
session.agent.state.tools = tools; // copies the top-level array
|
||||||
|
|
||||||
// Wait for agent to finish processing
|
// Wait for agent to finish processing
|
||||||
await session.agent.waitForIdle();
|
await session.agent.waitForIdle();
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ Flow:
|
|||||||
4. Fire `session_before_tree` event (hook can cancel or provide summary)
|
4. Fire `session_before_tree` event (hook can cancel or provide summary)
|
||||||
5. Run default summarizer if needed
|
5. Run default summarizer if needed
|
||||||
6. Switch leaf via `branch()` or `branchWithSummary()`
|
6. Switch leaf via `branch()` or `branchWithSummary()`
|
||||||
7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)`
|
7. Update agent: `agent.state.messages = sessionManager.buildSessionContext().messages`
|
||||||
8. Fire `session_tree` event
|
8. Fire `session_tree` event
|
||||||
9. Notify custom tools via session event
|
9. Notify custom tools via session event
|
||||||
10. Return result with `editorText` if user message was selected
|
10. Return result with `editorText` if user message was selected
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export class AgentSession {
|
|||||||
* happens here instead of in wrappers.
|
* happens here instead of in wrappers.
|
||||||
*/
|
*/
|
||||||
private _installAgentToolHooks(): void {
|
private _installAgentToolHooks(): void {
|
||||||
this.agent.setBeforeToolCall(async ({ toolCall, args }) => {
|
this.agent.beforeToolCall = async ({ toolCall, args }) => {
|
||||||
const runner = this._extensionRunner;
|
const runner = this._extensionRunner;
|
||||||
if (!runner?.hasHandlers("tool_call")) {
|
if (!runner?.hasHandlers("tool_call")) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -379,9 +379,9 @@ export class AgentSession {
|
|||||||
}
|
}
|
||||||
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
this.agent.setAfterToolCall(async ({ toolCall, args, result, isError }) => {
|
this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
|
||||||
const runner = this._extensionRunner;
|
const runner = this._extensionRunner;
|
||||||
if (!runner?.hasHandlers("tool_result")) {
|
if (!runner?.hasHandlers("tool_result")) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -405,7 +405,7 @@ export class AgentSession {
|
|||||||
content: hookResult.content,
|
content: hookResult.content,
|
||||||
details: hookResult.details,
|
details: hookResult.details,
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -790,11 +790,11 @@ export class AgentSession {
|
|||||||
validToolNames.push(name);
|
validToolNames.push(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.agent.setTools(tools);
|
this.agent.state.tools = tools;
|
||||||
|
|
||||||
// Rebuild base system prompt with new tool set
|
// Rebuild base system prompt with new tool set
|
||||||
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
|
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
|
||||||
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
this.agent.state.systemPrompt = this._baseSystemPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether compaction or branch summarization is currently running */
|
/** Whether compaction or branch summarization is currently running */
|
||||||
@@ -813,12 +813,12 @@ export class AgentSession {
|
|||||||
|
|
||||||
/** Current steering mode */
|
/** Current steering mode */
|
||||||
get steeringMode(): "all" | "one-at-a-time" {
|
get steeringMode(): "all" | "one-at-a-time" {
|
||||||
return this.agent.getSteeringMode();
|
return this.agent.steeringMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current follow-up mode */
|
/** Current follow-up mode */
|
||||||
get followUpMode(): "all" | "one-at-a-time" {
|
get followUpMode(): "all" | "one-at-a-time" {
|
||||||
return this.agent.getFollowUpMode();
|
return this.agent.followUpMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current session file path, or undefined if sessions are disabled */
|
/** Current session file path, or undefined if sessions are disabled */
|
||||||
@@ -1051,10 +1051,10 @@ export class AgentSession {
|
|||||||
}
|
}
|
||||||
// Apply extension-modified system prompt, or reset to base
|
// Apply extension-modified system prompt, or reset to base
|
||||||
if (result?.systemPrompt) {
|
if (result?.systemPrompt) {
|
||||||
this.agent.setSystemPrompt(result.systemPrompt);
|
this.agent.state.systemPrompt = result.systemPrompt;
|
||||||
} else {
|
} else {
|
||||||
// Ensure we're using the base prompt (in case previous turn had modifications)
|
// Ensure we're using the base prompt (in case previous turn had modifications)
|
||||||
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
this.agent.state.systemPrompt = this._baseSystemPrompt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1251,7 +1251,7 @@ export class AgentSession {
|
|||||||
} else if (options?.triggerTurn) {
|
} else if (options?.triggerTurn) {
|
||||||
await this.agent.prompt(appMessage);
|
await this.agent.prompt(appMessage);
|
||||||
} else {
|
} else {
|
||||||
this.agent.appendMessage(appMessage);
|
this.agent.state.messages.push(appMessage);
|
||||||
this.sessionManager.appendCustomMessageEntry(
|
this.sessionManager.appendCustomMessageEntry(
|
||||||
message.customType,
|
message.customType,
|
||||||
message.content,
|
message.content,
|
||||||
@@ -1388,7 +1388,7 @@ export class AgentSession {
|
|||||||
await options.setup(this.sessionManager);
|
await options.setup(this.sessionManager);
|
||||||
// Sync agent state with session manager after setup
|
// Sync agent state with session manager after setup
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
this.agent.replaceMessages(sessionContext.messages);
|
this.agent.state.messages = sessionContext.messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._reconnectToAgent();
|
this._reconnectToAgent();
|
||||||
@@ -1437,7 +1437,7 @@ export class AgentSession {
|
|||||||
|
|
||||||
const previousModel = this.model;
|
const previousModel = this.model;
|
||||||
const thinkingLevel = this._getThinkingLevelForModelSwitch();
|
const thinkingLevel = this._getThinkingLevelForModelSwitch();
|
||||||
this.agent.setModel(model);
|
this.agent.state.model = model;
|
||||||
this.sessionManager.appendModelChange(model.provider, model.id);
|
this.sessionManager.appendModelChange(model.provider, model.id);
|
||||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||||
|
|
||||||
@@ -1478,7 +1478,7 @@ export class AgentSession {
|
|||||||
const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);
|
const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);
|
||||||
|
|
||||||
// Apply model
|
// Apply model
|
||||||
this.agent.setModel(next.model);
|
this.agent.state.model = next.model;
|
||||||
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
||||||
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
|
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
|
||||||
|
|
||||||
@@ -1506,7 +1506,7 @@ export class AgentSession {
|
|||||||
const nextModel = availableModels[nextIndex];
|
const nextModel = availableModels[nextIndex];
|
||||||
|
|
||||||
const thinkingLevel = this._getThinkingLevelForModelSwitch();
|
const thinkingLevel = this._getThinkingLevelForModelSwitch();
|
||||||
this.agent.setModel(nextModel);
|
this.agent.state.model = nextModel;
|
||||||
this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
|
this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
|
||||||
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
|
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
|
||||||
|
|
||||||
@@ -1534,7 +1534,7 @@ export class AgentSession {
|
|||||||
// Only persist if actually changing
|
// Only persist if actually changing
|
||||||
const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
|
const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
|
||||||
|
|
||||||
this.agent.setThinkingLevel(effectiveLevel);
|
this.agent.state.thinkingLevel = effectiveLevel;
|
||||||
|
|
||||||
if (isChanging) {
|
if (isChanging) {
|
||||||
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
||||||
@@ -1620,7 +1620,7 @@ export class AgentSession {
|
|||||||
* Saves to settings.
|
* Saves to settings.
|
||||||
*/
|
*/
|
||||||
setSteeringMode(mode: "all" | "one-at-a-time"): void {
|
setSteeringMode(mode: "all" | "one-at-a-time"): void {
|
||||||
this.agent.setSteeringMode(mode);
|
this.agent.steeringMode = mode;
|
||||||
this.settingsManager.setSteeringMode(mode);
|
this.settingsManager.setSteeringMode(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1629,7 +1629,7 @@ export class AgentSession {
|
|||||||
* Saves to settings.
|
* Saves to settings.
|
||||||
*/
|
*/
|
||||||
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
|
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
|
||||||
this.agent.setFollowUpMode(mode);
|
this.agent.followUpMode = mode;
|
||||||
this.settingsManager.setFollowUpMode(mode);
|
this.settingsManager.setFollowUpMode(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1724,7 +1724,7 @@ export class AgentSession {
|
|||||||
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
|
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
|
||||||
const newEntries = this.sessionManager.getEntries();
|
const newEntries = this.sessionManager.getEntries();
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
this.agent.replaceMessages(sessionContext.messages);
|
this.agent.state.messages = sessionContext.messages;
|
||||||
|
|
||||||
// Get the saved compaction entry for the extension event
|
// Get the saved compaction entry for the extension event
|
||||||
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
||||||
@@ -1843,7 +1843,7 @@ export class AgentSession {
|
|||||||
// but we don't want it in context for the retry)
|
// but we don't want it in context for the retry)
|
||||||
const messages = this.agent.state.messages;
|
const messages = this.agent.state.messages;
|
||||||
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
||||||
this.agent.replaceMessages(messages.slice(0, -1));
|
this.agent.state.messages = messages.slice(0, -1);
|
||||||
}
|
}
|
||||||
await this._runAutoCompaction("overflow", true);
|
await this._runAutoCompaction("overflow", true);
|
||||||
return;
|
return;
|
||||||
@@ -1995,7 +1995,7 @@ export class AgentSession {
|
|||||||
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
|
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
|
||||||
const newEntries = this.sessionManager.getEntries();
|
const newEntries = this.sessionManager.getEntries();
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
this.agent.replaceMessages(sessionContext.messages);
|
this.agent.state.messages = sessionContext.messages;
|
||||||
|
|
||||||
// Get the saved compaction entry for the extension event
|
// Get the saved compaction entry for the extension event
|
||||||
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
||||||
@@ -2022,7 +2022,7 @@ export class AgentSession {
|
|||||||
const messages = this.agent.state.messages;
|
const messages = this.agent.state.messages;
|
||||||
const lastMsg = messages[messages.length - 1];
|
const lastMsg = messages[messages.length - 1];
|
||||||
if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
|
if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
|
||||||
this.agent.replaceMessages(messages.slice(0, -1));
|
this.agent.state.messages = messages.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -2108,7 +2108,7 @@ export class AgentSession {
|
|||||||
|
|
||||||
this._resourceLoader.extendResources(extensionPaths);
|
this._resourceLoader.extendResources(extensionPaths);
|
||||||
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
|
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
|
||||||
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
this.agent.state.systemPrompt = this._baseSystemPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{
|
private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{
|
||||||
@@ -2160,7 +2160,7 @@ export class AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.agent.setModel(refreshedModel);
|
this.agent.state.model = refreshedModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _bindExtensionCore(runner: ExtensionRunner): void {
|
private _bindExtensionCore(runner: ExtensionRunner): void {
|
||||||
@@ -2500,7 +2500,7 @@ export class AgentSession {
|
|||||||
// Remove error message from agent state (keep in session for history)
|
// Remove error message from agent state (keep in session for history)
|
||||||
const messages = this.agent.state.messages;
|
const messages = this.agent.state.messages;
|
||||||
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
||||||
this.agent.replaceMessages(messages.slice(0, -1));
|
this.agent.state.messages = messages.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait with exponential backoff (abortable)
|
// Wait with exponential backoff (abortable)
|
||||||
@@ -2633,7 +2633,7 @@ export class AgentSession {
|
|||||||
this._pendingBashMessages.push(bashMessage);
|
this._pendingBashMessages.push(bashMessage);
|
||||||
} else {
|
} else {
|
||||||
// Add to agent state immediately
|
// Add to agent state immediately
|
||||||
this.agent.appendMessage(bashMessage);
|
this.agent.state.messages.push(bashMessage);
|
||||||
|
|
||||||
// Save to session
|
// Save to session
|
||||||
this.sessionManager.appendMessage(bashMessage);
|
this.sessionManager.appendMessage(bashMessage);
|
||||||
@@ -2666,7 +2666,7 @@ export class AgentSession {
|
|||||||
|
|
||||||
for (const bashMessage of this._pendingBashMessages) {
|
for (const bashMessage of this._pendingBashMessages) {
|
||||||
// Add to agent state
|
// Add to agent state
|
||||||
this.agent.appendMessage(bashMessage);
|
this.agent.state.messages.push(bashMessage);
|
||||||
|
|
||||||
// Save to session
|
// Save to session
|
||||||
this.sessionManager.appendMessage(bashMessage);
|
this.sessionManager.appendMessage(bashMessage);
|
||||||
@@ -2725,7 +2725,7 @@ export class AgentSession {
|
|||||||
|
|
||||||
// Emit session event to custom tools
|
// Emit session event to custom tools
|
||||||
|
|
||||||
this.agent.replaceMessages(sessionContext.messages);
|
this.agent.state.messages = sessionContext.messages;
|
||||||
|
|
||||||
// Restore model if saved
|
// Restore model if saved
|
||||||
if (sessionContext.model) {
|
if (sessionContext.model) {
|
||||||
@@ -2735,7 +2735,7 @@ export class AgentSession {
|
|||||||
(m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
|
(m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
|
||||||
);
|
);
|
||||||
if (match) {
|
if (match) {
|
||||||
this.agent.setModel(match);
|
this.agent.state.model = match;
|
||||||
await this._emitModelSelect(match, previousModel, "restore");
|
await this._emitModelSelect(match, previousModel, "restore");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2751,7 +2751,7 @@ export class AgentSession {
|
|||||||
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
|
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
|
||||||
? defaultThinkingLevel
|
? defaultThinkingLevel
|
||||||
: this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
|
: this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
|
||||||
this.agent.setThinkingLevel(effectiveLevel);
|
this.agent.state.thinkingLevel = effectiveLevel;
|
||||||
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2824,7 +2824,7 @@ export class AgentSession {
|
|||||||
// Emit session event to custom tools (with reason "fork")
|
// Emit session event to custom tools (with reason "fork")
|
||||||
|
|
||||||
if (!skipConversationRestore) {
|
if (!skipConversationRestore) {
|
||||||
this.agent.replaceMessages(sessionContext.messages);
|
this.agent.state.messages = sessionContext.messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { selectedText, cancelled: false };
|
return { selectedText, cancelled: false };
|
||||||
@@ -3006,7 +3006,7 @@ export class AgentSession {
|
|||||||
|
|
||||||
// Update agent state
|
// Update agent state
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
this.agent.replaceMessages(sessionContext.messages);
|
this.agent.state.messages = sessionContext.messages;
|
||||||
|
|
||||||
// Emit session_tree event
|
// Emit session_tree event
|
||||||
if (this._extensionRunner) {
|
if (this._extensionRunner) {
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
|
|
||||||
// Restore messages if session has existing data
|
// Restore messages if session has existing data
|
||||||
if (hasExistingSession) {
|
if (hasExistingSession) {
|
||||||
agent.replaceMessages(existingSession.messages);
|
agent.state.messages = existingSession.messages;
|
||||||
if (!hasThinkingEntry) {
|
if (!hasThinkingEntry) {
|
||||||
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3319,7 +3319,7 @@ export class InteractiveMode {
|
|||||||
},
|
},
|
||||||
onTransportChange: (transport) => {
|
onTransportChange: (transport) => {
|
||||||
this.settingsManager.setTransport(transport);
|
this.settingsManager.setTransport(transport);
|
||||||
this.session.agent.setTransport(transport);
|
this.session.agent.transport = transport;
|
||||||
},
|
},
|
||||||
onThinkingLevelChange: (level) => {
|
onThinkingLevelChange: (level) => {
|
||||||
this.session.setThinkingLevel(level);
|
this.session.setThinkingLevel(level);
|
||||||
|
|||||||
@@ -278,12 +278,12 @@ describe("AgentSession auto-compaction queue resume", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Put both messages into agent state so estimateContextTokens can find the successful one
|
// Put both messages into agent state so estimateContextTokens can find the successful one
|
||||||
session.agent.replaceMessages([
|
session.agent.state.messages = [
|
||||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||||
successfulAssistant,
|
successfulAssistant,
|
||||||
{ role: "user", content: [{ type: "text", text: "another prompt" }], timestamp: Date.now() + 500 },
|
{ role: "user", content: [{ type: "text", text: "another prompt" }], timestamp: Date.now() + 500 },
|
||||||
errorAssistant,
|
errorAssistant,
|
||||||
]);
|
];
|
||||||
|
|
||||||
const runAutoCompactionSpy = vi
|
const runAutoCompactionSpy = vi
|
||||||
.spyOn(
|
.spyOn(
|
||||||
@@ -328,10 +328,10 @@ describe("AgentSession auto-compaction queue resume", () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
session.agent.replaceMessages([
|
session.agent.state.messages = [
|
||||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||||
errorAssistant,
|
errorAssistant,
|
||||||
]);
|
];
|
||||||
|
|
||||||
const runAutoCompactionSpy = vi
|
const runAutoCompactionSpy = vi
|
||||||
.spyOn(
|
.spyOn(
|
||||||
@@ -407,12 +407,12 @@ describe("AgentSession auto-compaction queue resume", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Agent state has the kept assistant (pre-compaction) and the error (post-compaction)
|
// Agent state has the kept assistant (pre-compaction) and the error (post-compaction)
|
||||||
session.agent.replaceMessages([
|
session.agent.state.messages = [
|
||||||
{ role: "user", content: [{ type: "text", text: "kept user msg" }], timestamp: preCompactionTimestamp - 1000 },
|
{ role: "user", content: [{ type: "text", text: "kept user msg" }], timestamp: preCompactionTimestamp - 1000 },
|
||||||
keptAssistant,
|
keptAssistant,
|
||||||
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
|
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
|
||||||
errorAssistant,
|
errorAssistant,
|
||||||
]);
|
];
|
||||||
|
|
||||||
const runAutoCompactionSpy = vi
|
const runAutoCompactionSpy = vi
|
||||||
.spyOn(
|
.spyOn(
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function createSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncAgentMessages(session: AgentSession, sessionManager: SessionManager): void {
|
function syncAgentMessages(session: AgentSession, sessionManager: SessionManager): void {
|
||||||
session.agent.replaceMessages(sessionManager.buildSessionContext().messages);
|
session.agent.state.messages = sessionManager.buildSessionContext().messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AgentSession.getSessionStats", () => {
|
describe("AgentSession.getSessionStats", () => {
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
|
|||||||
// Load existing messages
|
// Load existing messages
|
||||||
const loadedSession = sessionManager.buildSessionContext();
|
const loadedSession = sessionManager.buildSessionContext();
|
||||||
if (loadedSession.messages.length > 0) {
|
if (loadedSession.messages.length > 0) {
|
||||||
agent.replaceMessages(loadedSession.messages);
|
agent.state.messages = loadedSession.messages;
|
||||||
log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
|
log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +656,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
|
|||||||
// This picks up any messages synced above
|
// This picks up any messages synced above
|
||||||
const reloadedSession = sessionManager.buildSessionContext();
|
const reloadedSession = sessionManager.buildSessionContext();
|
||||||
if (reloadedSession.messages.length > 0) {
|
if (reloadedSession.messages.length > 0) {
|
||||||
agent.replaceMessages(reloadedSession.messages);
|
agent.state.messages = reloadedSession.messages;
|
||||||
log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
|
log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,7 +672,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
|
|||||||
ctx.users,
|
ctx.users,
|
||||||
skills,
|
skills,
|
||||||
);
|
);
|
||||||
session.agent.setSystemPrompt(systemPrompt);
|
session.agent.state.systemPrompt = systemPrompt;
|
||||||
|
|
||||||
// Set up file upload function
|
// Set up file upload function
|
||||||
setUploadFunction(async (filePath: string, title?: string) => {
|
setUploadFunction(async (filePath: string, title?: string) => {
|
||||||
|
|||||||
@@ -202,10 +202,10 @@ await agent.prompt({ role: 'user-with-attachments', content: 'Check this', attac
|
|||||||
|
|
||||||
// Control
|
// Control
|
||||||
agent.abort();
|
agent.abort();
|
||||||
agent.setModel(newModel);
|
agent.state.model = newModel;
|
||||||
agent.setThinkingLevel('medium');
|
agent.state.thinkingLevel = 'medium';
|
||||||
agent.setTools([...]);
|
agent.state.tools = [...];
|
||||||
agent.queueMessage(customMessage);
|
agent.followUp(customMessage);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Message Types
|
## Message Types
|
||||||
@@ -312,7 +312,7 @@ replTool.runtimeProvidersFactory = () => [
|
|||||||
new ArtifactsRuntimeProvider(artifactsPanel, agent, true), // read-write
|
new ArtifactsRuntimeProvider(artifactsPanel, agent, true), // read-write
|
||||||
];
|
];
|
||||||
|
|
||||||
agent.setTools([replTool]);
|
agent.state.tools = [replTool];
|
||||||
```
|
```
|
||||||
|
|
||||||
### Extract Document
|
### Extract Document
|
||||||
@@ -325,7 +325,7 @@ import { createExtractDocumentTool } from '@mariozechner/pi-web-ui';
|
|||||||
const extractTool = createExtractDocumentTool();
|
const extractTool = createExtractDocumentTool();
|
||||||
extractTool.corsProxyUrl = 'https://corsproxy.io/?';
|
extractTool.corsProxyUrl = 'https://corsproxy.io/?';
|
||||||
|
|
||||||
agent.setTools([extractTool]);
|
agent.state.tools = [extractTool];
|
||||||
```
|
```
|
||||||
|
|
||||||
### Artifacts Tool
|
### Artifacts Tool
|
||||||
@@ -337,7 +337,7 @@ const artifactsPanel = new ArtifactsPanel();
|
|||||||
artifactsPanel.agent = agent;
|
artifactsPanel.agent = agent;
|
||||||
|
|
||||||
// The tool is available as artifactsPanel.tool
|
// The tool is available as artifactsPanel.tool
|
||||||
agent.setTools([artifactsPanel.tool]);
|
agent.state.tools = [artifactsPanel.tool];
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Tool Renderers
|
### Custom Tool Renderers
|
||||||
@@ -551,7 +551,7 @@ const success = await ApiKeyPromptDialog.prompt('anthropic');
|
|||||||
import { ModelSelector } from '@mariozechner/pi-web-ui';
|
import { ModelSelector } from '@mariozechner/pi-web-ui';
|
||||||
|
|
||||||
ModelSelector.open(currentModel, (selectedModel) => {
|
ModelSelector.open(currentModel, (selectedModel) => {
|
||||||
agent.setModel(selectedModel);
|
agent.state.model = selectedModel;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export class ChatPanel extends LitElement {
|
|||||||
const additionalTools =
|
const additionalTools =
|
||||||
config?.toolsFactory?.(agent, this.agentInterface, this.artifactsPanel, runtimeProvidersFactory) || [];
|
config?.toolsFactory?.(agent, this.agentInterface, this.artifactsPanel, runtimeProvidersFactory) || [];
|
||||||
const tools = [this.artifactsPanel.tool, ...additionalTools];
|
const tools = [this.artifactsPanel.tool, ...additionalTools];
|
||||||
this.agent.setTools(tools);
|
this.agent.state.tools = tools;
|
||||||
|
|
||||||
// Reconstruct artifacts from existing messages
|
// Reconstruct artifacts from existing messages
|
||||||
// Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
|
// Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
|
||||||
|
|||||||
@@ -376,13 +376,15 @@ export class AgentInterface extends LitElement {
|
|||||||
if (this.onModelSelect) {
|
if (this.onModelSelect) {
|
||||||
this.onModelSelect();
|
this.onModelSelect();
|
||||||
} else {
|
} else {
|
||||||
ModelSelector.open(state.model, (model) => session.setModel(model));
|
ModelSelector.open(state.model, (model) => {
|
||||||
|
session.state.model = model;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
.onThinkingChange=${
|
.onThinkingChange=${
|
||||||
this.enableThinkingSelector
|
this.enableThinkingSelector
|
||||||
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
|
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
|
||||||
session.setThinkingLevel(level);
|
session.state.thinkingLevel = level;
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { renderMessage } from "./message-renderer-registry.js";
|
|||||||
export class MessageList extends LitElement {
|
export class MessageList extends LitElement {
|
||||||
@property({ type: Array }) messages: AgentMessage[] = [];
|
@property({ type: Array }) messages: AgentMessage[] = [];
|
||||||
@property({ type: Array }) tools: AgentTool[] = [];
|
@property({ type: Array }) tools: AgentTool[] = [];
|
||||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
@property({ type: Object }) pendingToolCalls?: ReadonlySet<string>;
|
||||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||||
@property({ attribute: false }) onCostClick?: () => void;
|
@property({ attribute: false }) onCostClick?: () => void;
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class UserMessage extends LitElement {
|
|||||||
export class AssistantMessage extends LitElement {
|
export class AssistantMessage extends LitElement {
|
||||||
@property({ type: Object }) message!: AssistantMessageType;
|
@property({ type: Object }) message!: AssistantMessageType;
|
||||||
@property({ type: Array }) tools?: AgentTool<any>[];
|
@property({ type: Array }) tools?: AgentTool<any>[];
|
||||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
@property({ type: Object }) pendingToolCalls?: ReadonlySet<string>;
|
||||||
@property({ type: Boolean }) hideToolCalls = false;
|
@property({ type: Boolean }) hideToolCalls = false;
|
||||||
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
|
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
|
||||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { property, state } from "lit/decorators.js";
|
|||||||
export class StreamingMessageContainer extends LitElement {
|
export class StreamingMessageContainer extends LitElement {
|
||||||
@property({ type: Array }) tools: AgentTool[] = [];
|
@property({ type: Array }) tools: AgentTool[] = [];
|
||||||
@property({ type: Boolean }) isStreaming = false;
|
@property({ type: Boolean }) isStreaming = false;
|
||||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
@property({ type: Object }) pendingToolCalls?: ReadonlySet<string>;
|
||||||
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
|
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
|
||||||
@property({ attribute: false }) onCostClick?: () => void;
|
@property({ attribute: false }) onCostClick?: () => void;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import {
|
import {
|
||||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
|
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
|
||||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
|
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
|
||||||
@@ -13,7 +14,7 @@ interface ArtifactsPanelLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AgentLike {
|
interface AgentLike {
|
||||||
appendMessage(message: any): void;
|
state: { messages: AgentMessage[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,7 +172,7 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
|||||||
filename,
|
filename,
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
this.agent?.appendMessage({
|
this.agent?.state.messages.push({
|
||||||
role: "artifact",
|
role: "artifact",
|
||||||
action,
|
action,
|
||||||
filename,
|
filename,
|
||||||
@@ -192,7 +193,7 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
|||||||
command: "delete",
|
command: "delete",
|
||||||
filename,
|
filename,
|
||||||
});
|
});
|
||||||
this.agent?.appendMessage({
|
this.agent?.state.messages.push({
|
||||||
role: "artifact",
|
role: "artifact",
|
||||||
action: "delete",
|
action: "delete",
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
Reference in New Issue
Block a user