diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index be29e30d..53451fb1 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,33 @@ ## [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` + - `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 ### Added diff --git a/packages/agent/README.md b/packages/agent/README.md index 6461422c..1b19d3f2 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -204,14 +204,18 @@ interface AgentState { thinkingLevel: ThinkingLevel; tools: AgentTool[]; messages: AgentMessage[]; - isStreaming: boolean; - streamMessage: AgentMessage | null; // Current partial during streaming - pendingToolCalls: Set; - error?: string; + readonly isStreaming: boolean; + readonly streamingMessage?: AgentMessage; + readonly pendingToolCalls: ReadonlySet; + 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 @@ -236,17 +240,16 @@ await agent.continue(); ### State Management ```typescript -agent.setSystemPrompt("New prompt"); -agent.setModel(getModel("openai", "gpt-4o")); -agent.setThinkingLevel("medium"); -agent.setTools([myTool]); -agent.setToolExecution("sequential"); -agent.setBeforeToolCall(async ({ toolCall }) => undefined); -agent.setAfterToolCall(async ({ toolCall, result }) => undefined); -agent.replaceMessages(newMessages); -agent.appendMessage(message); -agent.clearMessages(); -agent.reset(); // Clear everything +agent.state.systemPrompt = "New prompt"; +agent.state.model = getModel("openai", "gpt-4o"); +agent.state.thinkingLevel = "medium"; +agent.state.tools = [myTool]; +agent.toolExecution = "sequential"; +agent.beforeToolCall = async ({ toolCall }) => undefined; +agent.afterToolCall = async ({ toolCall, result }) => undefined; +agent.state.messages = newMessages; // top-level array is copied +agent.state.messages.push(message); +agent.reset(); ``` ### 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. ```typescript -agent.setSteeringMode("one-at-a-time"); -agent.setFollowUpMode("one-at-a-time"); +agent.steeringMode = "one-at-a-time"; +agent.followUpMode = "one-at-a-time"; // While agent is running tools agent.steer({ @@ -300,8 +303,8 @@ agent.followUp({ timestamp: Date.now(), }); -const steeringMode = agent.getSteeringMode(); -const followUpMode = agent.getFollowUpMode(); +const steeringMode = agent.steeringMode; +const followUpMode = agent.followUpMode; agent.clearSteeringQueue(); agent.clearFollowUpQueue(); @@ -370,7 +373,7 @@ const readFileTool: AgentTool = { }, }; -agent.setTools([readFileTool]); +agent.state.tools = [readFileTool]; ``` ### Error Handling diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 08b4aaf7..f4faa496 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,10 +1,4 @@ -/** - * Agent class that uses the agent-loop directly. - * No transport abstraction - calls streamSimple via the loop. - */ - import { - getModel, type ImageContent, type Message, type Model, @@ -27,453 +21,466 @@ import type { BeforeToolCallContext, BeforeToolCallResult, StreamFn, - ThinkingLevel, ToolExecutionMode, } from "./types.js"; -/** - * Default convertToLlm: Keep only LLM-compatible messages, convert attachments. - */ 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 { - initialState?: Partial; +const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; - /** - * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. - * Default filters to user/assistant/toolResult and converts attachments. - */ - convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; +const DEFAULT_MODEL = { + id: "unknown", + name: "unknown", + api: "unknown", + provider: "unknown", + baseUrl: "", + reasoning: false, + input: [], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 0, + maxTokens: 0, +} satisfies Model; - /** - * Optional transform applied to context before convertToLlm. - * Use for context pruning, injecting external context, etc. - */ - transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; +type QueueMode = "all" | "one-at-a-time"; - /** - * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn - */ - steeringMode?: "all" | "one-at-a-time"; +type MutableAgentState = Omit & { + isStreaming: boolean; + streamingMessage?: AgentMessage; + pendingToolCalls: Set; + errorMessage?: string; +}; - /** - * Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn - */ - followUpMode?: "all" | "one-at-a-time"; +function createMutableAgentState( + initialState?: Partial>, +): MutableAgentState { + let tools = initialState?.tools?.slice() ?? []; + let messages = initialState?.messages?.slice() ?? []; - /** - * Custom stream function (for proxy backends, etc.). Default uses streamSimple. - */ - streamFn?: StreamFn; - - /** - * Optional session identifier forwarded to LLM providers. - * Used by providers that support session-based caching (e.g., OpenAI Codex). - */ - sessionId?: string; - - /** - * Resolves an API key dynamically for each LLM call. - * Useful for expiring tokens (e.g., GitHub Copilot OAuth). - */ - getApiKey?: (provider: string) => Promise | 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; - - /** Called after a tool finishes executing, before final tool events are emitted. */ - afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; -} - -export class Agent { - private _state: AgentState = { - systemPrompt: "", - model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"), - thinkingLevel: "off", - tools: [], - messages: [], + return { + systemPrompt: initialState?.systemPrompt ?? "", + model: initialState?.model ?? DEFAULT_MODEL, + thinkingLevel: initialState?.thinkingLevel ?? "off", + get tools() { + return tools; + }, + set tools(nextTools: AgentTool[]) { + tools = nextTools.slice(); + }, + get messages() { + return messages; + }, + set messages(nextMessages: AgentMessage[]) { + messages = nextMessages.slice(); + }, isStreaming: false, - streamMessage: null, + streamingMessage: undefined, pendingToolCalls: new Set(), - error: undefined, + errorMessage: undefined, }; +} - private listeners = new Set<(e: AgentEvent) => void>(); - private abortController?: AbortController; - private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; - private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - private steeringQueue: AgentMessage[] = []; - private followUpQueue: AgentMessage[] = []; - private steeringMode: "all" | "one-at-a-time"; - private followUpMode: "all" | "one-at-a-time"; +/** Options for constructing an {@link Agent}. */ +export interface AgentOptions { + initialState?: Partial>; + convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + streamFn?: StreamFn; + getApiKey?: (provider: string) => Promise | string | undefined; + onPayload?: SimpleStreamOptions["onPayload"]; + beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise; + afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; + 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; + 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; + public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; public streamFn: StreamFn; - private _sessionId?: string; public getApiKey?: (provider: string) => Promise | string | undefined; - private _onPayload?: SimpleStreamOptions["onPayload"]; - private runningPrompt?: Promise; - private resolveRunningPrompt?: () => void; - private _thinkingBudgets?: ThinkingBudgets; - private _transport: Transport; - private _maxRetryDelayMs?: number; - private _toolExecution: ToolExecutionMode; - private _beforeToolCall?: ( + public onPayload?: SimpleStreamOptions["onPayload"]; + public beforeToolCall?: ( context: BeforeToolCallContext, signal?: AbortSignal, ) => Promise; - private _afterToolCall?: ( + public afterToolCall?: ( context: AfterToolCallContext, signal?: AbortSignal, ) => Promise; + 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 = {}) { - this._state = { ...this._state, ...opts.initialState }; - this.convertToLlm = opts.convertToLlm || defaultConvertToLlm; - this.transformContext = opts.transformContext; - this.steeringMode = opts.steeringMode || "one-at-a-time"; - this.followUpMode = opts.followUpMode || "one-at-a-time"; - this.streamFn = opts.streamFn || streamSimple; - this._sessionId = opts.sessionId; - this.getApiKey = opts.getApiKey; - this._onPayload = opts.onPayload; - this._thinkingBudgets = opts.thinkingBudgets; - this._transport = opts.transport ?? "sse"; - this._maxRetryDelayMs = opts.maxRetryDelayMs; - this._toolExecution = opts.toolExecution ?? "parallel"; - this._beforeToolCall = opts.beforeToolCall; - this._afterToolCall = opts.afterToolCall; + constructor(options: AgentOptions = {}) { + this._state = createMutableAgentState(options.initialState); + this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; + this.transformContext = options.transformContext; + this.streamFn = options.streamFn ?? streamSimple; + this.getApiKey = options.getApiKey; + this.onPayload = options.onPayload; + this.beforeToolCall = options.beforeToolCall; + this.afterToolCall = options.afterToolCall; + this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); + this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); + this.sessionId = options.sessionId; + this.thinkingBudgets = options.thinkingBudgets; + this.transport = options.transport ?? "sse"; + this.maxRetryDelayMs = options.maxRetryDelayMs; + 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) - | undefined, - ) { - this._beforeToolCall = value; - } - - setAfterToolCall( - value: - | ((context: AfterToolCallContext, signal?: AbortSignal) => Promise) - | undefined, - ) { - this._afterToolCall = value; - } - get state(): AgentState { return this._state; } - subscribe(fn: (e: AgentEvent) => void): () => void { - this.listeners.add(fn); - return () => this.listeners.delete(fn); + /** Controls how queued steering messages are drained. */ + set steeringMode(mode: QueueMode) { + this.steeringQueue.mode = mode; } - // State mutators - setSystemPrompt(v: string) { - this._state.systemPrompt = v; + get steeringMode(): QueueMode { + return this.steeringQueue.mode; } - setModel(m: Model) { - this._state.model = m; + /** Controls how queued follow-up messages are drained. */ + set followUpMode(mode: QueueMode) { + this.followUpQueue.mode = mode; } - setThinkingLevel(l: ThinkingLevel) { - this._state.thinkingLevel = l; + get followUpMode(): QueueMode { + return this.followUpQueue.mode; } - setSteeringMode(mode: "all" | "one-at-a-time") { - this.steeringMode = mode; + /** Queue a message to be injected after the current assistant turn finishes. */ + steer(message: AgentMessage): void { + this.steeringQueue.enqueue(message); } - getSteeringMode(): "all" | "one-at-a-time" { - return this.steeringMode; + /** Queue a message to run only after the agent would otherwise stop. */ + followUp(message: AgentMessage): void { + this.followUpQueue.enqueue(message); } - setFollowUpMode(mode: "all" | "one-at-a-time") { - this.followUpMode = mode; + /** Remove all queued steering messages. */ + clearSteeringQueue(): void { + this.steeringQueue.clear(); } - getFollowUpMode(): "all" | "one-at-a-time" { - return this.followUpMode; + /** Remove all queued follow-up messages. */ + clearFollowUpQueue(): void { + this.followUpQueue.clear(); } - setTools(t: AgentTool[]) { - this._state.tools = t; - } - - 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 = []; + /** Remove all queued steering and follow-up messages. */ + clearAllQueues(): void { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); } + /** Returns true when either queue still contains pending messages. */ hasQueuedMessages(): boolean { - return this.steeringQueue.length > 0 || this.followUpQueue.length > 0; + return this.steeringQueue.hasItems() || this.followUpQueue.hasItems(); } - private dequeueSteeringMessages(): AgentMessage[] { - 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. */ + /** Active abort signal for the current run, if any. */ get signal(): AbortSignal | undefined { - return this.abortController?.signal; + return this.activeRun?.abortController.signal; } - abort() { - this.abortController?.abort(); + /** Abort the current run, if one is active. */ + abort(): void { + this.activeRun?.abortController.abort(); } + /** Resolve when the current run has finished. */ waitForIdle(): Promise { - 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.isStreaming = false; - this._state.streamMessage = null; + this._state.streamingMessage = undefined; this._state.pendingToolCalls = new Set(); - this._state.error = undefined; - this.steeringQueue = []; - this.followUpQueue = []; + this._state.errorMessage = undefined; + this.clearFollowUpQueue(); + 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; async prompt(input: string, images?: ImageContent[]): Promise; - async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) { - if (this._state.isStreaming) { + async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise { + if (this.activeRun) { throw new Error( "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", ); } - - const model = this._state.model; - 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 = [{ 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); + const messages = this.normalizePromptInput(input, images); + await this.runPromptMessages(messages); } - /** - * Continue from current context (used for retries and resuming queued messages). - */ - async continue() { - if (this._state.isStreaming) { + /** Continue from the current transcript. The last message must be a user or tool-result message. */ + async continue(): Promise { + if (this.activeRun) { throw new Error("Agent is already processing. Wait for completion before continuing."); } - const messages = this._state.messages; - if (messages.length === 0) { + const lastMessage = this._state.messages[this._state.messages.length - 1]; + if (!lastMessage) { 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) { - await this._runLoop(queuedSteering, { skipInitialSteeringPoll: true }); + await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true }); return; } - const queuedFollowUp = this.dequeueFollowUpMessages(); - if (queuedFollowUp.length > 0) { - await this._runLoop(queuedFollowUp); + const queuedFollowUps = this.followUpQueue.drain(); + if (queuedFollowUps.length > 0) { + await this.runPromptMessages(queuedFollowUps); return; } 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 = [{ 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 { + await this.runWithLifecycle(async (signal) => { + await runAgentLoop( + messages, + this.createContextSnapshot(), + this.createLoopConfig(options), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private async runContinuation(): Promise { + 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): Promise { + if (this.activeRun) { + throw new Error("Agent is already processing."); + } + + const abortController = new AbortController(); + let resolvePromise = () => {}; + const promise = new Promise((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(); + this.activeRun?.resolve(); + this.activeRun = undefined; + } + + private processEvents(event: AgentEvent): void { switch (event.type) { case "message_start": - this._state.streamMessage = event.message; + this._state.streamingMessage = event.message; break; case "message_update": - this._state.streamMessage = event.message; + this._state.streamingMessage = event.message; break; case "message_end": - this._state.streamMessage = null; - this.appendMessage(event.message); + this._state.streamingMessage = undefined; + this._state.messages.push(event.message); break; case "tool_execution_start": { @@ -491,128 +498,19 @@ export class Agent { } case "turn_end": - if (event.message.role === "assistant" && (event.message as any).errorMessage) { - this._state.error = (event.message as any).errorMessage; + if (event.message.role === "assistant" && event.message.errorMessage) { + this._state.errorMessage = event.message.errorMessage; } break; case "agent_end": this._state.isStreaming = false; - this._state.streamMessage = null; + this._state.streamingMessage = undefined; 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((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(); - this.abortController = undefined; - this.resolveRunningPrompt?.(); - this.runningPrompt = undefined; - this.resolveRunningPrompt = undefined; - } - } - - private emit(e: AgentEvent) { for (const listener of this.listeners) { - listener(e); + listener(event); } } } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index c391cc01..8d4e2561 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -245,37 +245,55 @@ export interface 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 { + /** System prompt sent with each model request. */ systemPrompt: string; + /** Active model used for future turns. */ model: Model; + /** Requested reasoning level for future turns. */ thinkingLevel: ThinkingLevel; - tools: AgentTool[]; - messages: AgentMessage[]; // Can include attachments + custom message types - isStreaming: boolean; - streamMessage: AgentMessage | null; - pendingToolCalls: Set; - error?: string; + /** Available tools. Assigning a new array copies the top-level array. */ + set tools(tools: AgentTool[]); + get tools(): AgentTool[]; + /** Conversation transcript. Assigning a new array copies the top-level array. */ + set messages(messages: AgentMessage[]); + 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; + /** 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 { - // Content blocks supporting text and images + /** Text or image content returned to the model. */ content: (TextContent | ImageContent)[]; - // Details to be displayed in a UI or logged + /** Arbitrary structured details for logs or UI rendering. */ details: T; } -// Callback for streaming tool execution updates +/** Callback used by tools to stream partial execution updates. */ export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; -// AgentTool extends Tool but adds argument preparation and execution hooks +/** Tool definition used by the agent runtime. */ export interface AgentTool extends Tool { - // A human-readable label for the tool to be displayed in UI + /** Human-readable label for UI display. */ 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; + /** Execute the tool call. Throw on failure instead of encoding errors in `content`. */ execute: ( toolCallId: string, params: Static, @@ -284,10 +302,13 @@ export interface AgentTool Promise>; } -// AgentContext is like Context but uses AgentTool +/** Context snapshot passed into the low-level agent loop. */ export interface AgentContext { + /** System prompt included with the request. */ systemPrompt: string; + /** Transcript visible to the model. */ messages: AgentMessage[]; + /** Tools available for this run. */ tools?: AgentTool[]; } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 3b476344..c7ef1d87 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -47,9 +47,9 @@ describe("Agent", () => { expect(agent.state.tools).toEqual([]); expect(agent.state.messages).toEqual([]); 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.error).toBeUndefined(); + expect(agent.state.errorMessage).toBeUndefined(); }); it("should create an agent instance with custom initial state", () => { @@ -79,13 +79,13 @@ describe("Agent", () => { expect(eventCount).toBe(0); // State mutators don't emit events - agent.setSystemPrompt("Test prompt"); + agent.state.systemPrompt = "Test prompt"; expect(eventCount).toBe(0); expect(agent.state.systemPrompt).toBe("Test prompt"); // Unsubscribe should work unsubscribe(); - agent.setSystemPrompt("Another prompt"); + agent.state.systemPrompt = "Another prompt"; expect(eventCount).toBe(0); // Should not increase }); @@ -93,37 +93,38 @@ describe("Agent", () => { const agent = new Agent(); // Test setSystemPrompt - agent.setSystemPrompt("Custom prompt"); + agent.state.systemPrompt = "Custom prompt"; expect(agent.state.systemPrompt).toBe("Custom prompt"); // Test setModel const newModel = getModel("google", "gemini-2.5-flash"); - agent.setModel(newModel); + agent.state.model = newModel; expect(agent.state.model).toBe(newModel); // Test setThinkingLevel - agent.setThinkingLevel("high"); + agent.state.thinkingLevel = "high"; expect(agent.state.thinkingLevel).toBe("high"); // Test setTools const tools = [{ name: "test", description: "test tool" } as any]; - agent.setTools(tools); - expect(agent.state.tools).toBe(tools); + agent.state.tools = tools; + expect(agent.state.tools).toEqual(tools); + expect(agent.state.tools).not.toBe(tools); // Should be a copy // Test replaceMessages 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).not.toBe(messages); // Should be a copy // Test appendMessage 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[1]).toBe(newMessage); // Test clearMessages - agent.clearMessages(); + agent.state.messages = []; expect(agent.state.messages).toEqual([]); }); @@ -241,14 +242,14 @@ describe("Agent", () => { }, }); - agent.replaceMessages([ + agent.state.messages = [ { role: "user", content: [{ type: "text", text: "Initial" }], timestamp: Date.now() - 10, }, createAssistantMessage("Initial response"), - ]); + ]; agent.followUp({ role: "user", @@ -285,14 +286,14 @@ describe("Agent", () => { }, }); - agent.replaceMessages([ + agent.state.messages = [ { role: "user", content: [{ type: "text", text: "Initial" }], timestamp: Date.now() - 10, }, createAssistantMessage("Initial response"), - ]); + ]; agent.steer({ role: "user", diff --git a/packages/agent/test/bedrock-models.test.ts b/packages/agent/test/bedrock-models.test.ts deleted file mode 100644 index bbd6accd..00000000 --- a/packages/agent/test/bedrock-models.test.ts +++ /dev/null @@ -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", () => {}); - } -}); diff --git a/packages/agent/test/bedrock-utils.ts b/packages/agent/test/bedrock-utils.ts deleted file mode 100644 index 11ac93de..00000000 --- a/packages/agent/test/bedrock-utils.ts +++ /dev/null @@ -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 - ); -} diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index 3ef01381..f032555e 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -1,13 +1,41 @@ -import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; -import { getModel } from "@mariozechner/pi-ai"; -import { describe, expect, it } from "vitest"; -import { Agent } from "../src/index.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; +import { + type AssistantMessage, + type FauxProviderRegistration, + fauxAssistantMessage, + 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"; -delete process.env.ANTHROPIC_OAUTH_TOKEN; +const registrations: FauxProviderRegistration[] = []; -async function basicPrompt(model: Model) { +function createFauxRegistration(options: Parameters[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) { const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant. Keep your responses concise.", @@ -26,15 +54,10 @@ async function basicPrompt(model: Model) { const assistantMessage = agent.state.messages[1]; if (assistantMessage.role !== "assistant") throw new Error("Expected assistant message"); - expect(assistantMessage.content.length).toBeGreaterThan(0); - - 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"); + expect(getTextContent(assistantMessage)).toContain("4"); } -async function toolExecution(model: Model) { +async function toolExecution(model: Model) { const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.", @@ -44,52 +67,49 @@ async function toolExecution(model: Model) { }, }); + 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."); expect(agent.state.isStreaming).toBe(false); - expect(agent.state.messages.length).toBeGreaterThanOrEqual(3); - - const toolResultMsg = agent.state.messages.find((m) => m.role === "toolResult"); + expect(agent.state.messages.length).toBeGreaterThanOrEqual(4); + const toolResultMsg = agent.state.messages.find((message) => message.role === "toolResult"); expect(toolResultMsg).toBeDefined(); if (toolResultMsg?.role !== "toolResult") throw new Error("Expected tool result message"); - const textContent = - 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)); + expect(getTextContent(toolResultMsg)).toContain("123 * 456 = 56088"); const finalMessage = agent.state.messages[agent.state.messages.length - 1]; if (finalMessage.role !== "assistant") throw new Error("Expected final assistant message"); - const finalText = finalMessage.content.find((c) => c.type === "text"); - expect(finalText).toBeDefined(); - if (finalText?.type !== "text") throw new Error("Expected text content"); - // Check for number with or without comma formatting - const hasNumber = - finalText.text.includes(String(expectedResult)) || - finalText.text.includes("56,088") || - finalText.text.includes("56088"); - expect(hasNumber).toBe(true); + expect(getTextContent(finalMessage)).toContain("56088"); + expect(agent.state.pendingToolCalls.size).toBe(0); + expect(pendingToolCallsDuringEvents).toEqual([ + { type: "tool_execution_start", ids: ["calc-1"] }, + { type: "tool_execution_end", ids: [] }, + ]); } -async function abortExecution(model: Model) { +async function abortExecution(model: Model) { const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant.", model, 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(() => { agent.abort(); - }, 100); + }, 30); await promptPromise; @@ -100,11 +120,10 @@ async function abortExecution(model: Model) { if (lastMessage.role !== "assistant") throw new Error("Expected assistant message"); expect(lastMessage.stopReason).toBe("aborted"); expect(lastMessage.errorMessage).toBeDefined(); - expect(agent.state.error).toBeDefined(); - expect(agent.state.error).toBe(lastMessage.errorMessage); + expect(agent.state.errorMessage).toBe(lastMessage.errorMessage); } -async function stateUpdates(model: Model) { +async function stateUpdates(model: Model) { const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant.", @@ -114,29 +133,29 @@ async function stateUpdates(model: Model) { }, }); - const events: Array = []; - + const events: AgentEvent["type"][] = []; agent.subscribe((event) => { events.push(event.type); }); await agent.prompt("Count from 1 to 5."); - // Should have received lifecycle events expect(events).toContain("agent_start"); - expect(events).toContain("agent_end"); + expect(events).toContain("turn_start"); expect(events).toContain("message_start"); + expect(events).toContain("message_update"); expect(events).toContain("message_end"); - // May have message_update events during streaming - const hasMessageUpdates = events.some((e) => e === "message_update"); - expect(hasMessageUpdates).toBe(true); + expect(events).toContain("turn_end"); + expect(events).toContain("agent_end"); + 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.messages.length).toBe(2); // User message + assistant response + expect(agent.state.messages.length).toBe(2); } -async function multiTurnConversation(model: Model) { +async function multiTurnConversation(model: Model) { const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant.", @@ -154,232 +173,120 @@ async function multiTurnConversation(model: Model) { const lastMessage = agent.state.messages[3]; if (lastMessage.role !== "assistant") throw new Error("Expected assistant message"); - const lastText = lastMessage.content.find((c) => c.type === "text"); - if (lastText?.type !== "text") throw new Error("Expected text content"); - expect(lastText.text.toLowerCase()).toContain("alice"); + expect(getTextContent(lastMessage).toLowerCase()).toContain("alice"); } -describe("Agent E2E Tests", () => { - describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider (gemini-2.5-flash)", () => { - const model = getModel("google", "gemini-2.5-flash"); - - 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 integration with faux provider", () => { + it("handles a basic text prompt", async () => { + const faux = createFauxRegistration(); + faux.setResponses([fauxAssistantMessage("4")]); + await basicPrompt(faux.getModel()); }); - describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Provider (gpt-4o-mini)", () => { - const model = getModel("openai", "gpt-4o-mini"); - - 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); - }); + it("executes tools and tracks pending tool calls", async () => { + const faux = createFauxRegistration(); + faux.setResponses([ + fauxAssistantMessage( + [ + fauxText("Let me calculate that."), + fauxToolCall("calculate", { expression: "123 * 456" }, { id: "calc-1" }), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("The result is 56088."), + ]); + await toolExecution(faux.getModel()); }); - describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider (claude-haiku-4-5)", () => { - const model = getModel("anthropic", "claude-haiku-4-5"); - - 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); + it("handles abort during streaming", async () => { + const faux = createFauxRegistration({ + tokensPerSecond: 20, + tokenSize: { min: 2, max: 2 }, }); + 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)", () => { - const model = getModel("xai", "grok-3"); - - 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); - }); + it("emits lifecycle updates while streaming", async () => { + const faux = createFauxRegistration({ tokenSize: { min: 1, max: 1 } }); + faux.setResponses([fauxAssistantMessage("1 2 3 4 5")]); + await stateUpdates(faux.getModel()); }); - describe.skipIf(!process.env.GROQ_API_KEY)("Groq Provider (openai/gpt-oss-20b)", () => { - const model = getModel("groq", "openai/gpt-oss-20b"); - - 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); - }); + it("maintains context across multiple turns", async () => { + const faux = createFauxRegistration(); + faux.setResponses([ + fauxAssistantMessage("Nice to meet you, Alice."), + (context) => { + const hasAlice = context.messages.some((message) => { + if (message.role !== "user") return false; + if (typeof message.content === "string") return message.content.includes("Alice"); + 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."); + }, + ]); + await multiTurnConversation(faux.getModel()); }); - /*describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider (gpt-oss-120b)", () => { - const model = getModel("cerebras", "gpt-oss-120b"); + it("preserves thinking content blocks", async () => { + 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 () => { - await basicPrompt(model); + const agent = new Agent({ + initialState: { + systemPrompt: "You are a helpful assistant.", + model: faux.getModel(), + thinkingLevel: "low", + tools: [], + }, }); - it("should execute tools correctly", async () => { - await toolExecution(model); - }); + await agent.prompt("What is 2+2?"); - 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.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); - }); + const assistantMessage = agent.state.messages[1]; + if (assistantMessage?.role !== "assistant") throw new Error("Expected assistant message"); + expect(assistantMessage.content).toEqual([ + { type: "thinking", thinking: "step by step" }, + { type: "text", text: "4" }, + ]); }); }); -describe("Agent.continue()", () => { +describe("Agent.continue() with faux provider", () => { 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({ initialState: { systemPrompt: "Test", - model: getModel("openai", "gpt-5.4"), + model: faux.getModel(), }, }); 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({ initialState: { systemPrompt: "Test", - model: getModel("openai", "gpt-5.4"), + model, }, }); const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "Hello" }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", + api: model.api, + provider: model.provider, + model: model.id, usage: { input: 0, output: 0, @@ -391,34 +298,32 @@ describe("Agent.continue()", () => { stopReason: "stop", timestamp: Date.now(), }; - agent.replaceMessages([assistantMessage]); + agent.state.messages = [assistantMessage]; await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant"); }); }); - describe.skipIf(!process.env.OPENAI_API_KEY)("continue from user message", () => { - const model = getModel("openai", "gpt-5.4"); - - it("should continue and get response when last message is user", async () => { + describe("continue from user message", () => { + it("continues and gets a response when last message is user", async () => { + const faux = createFauxRegistration(); + faux.setResponses([fauxAssistantMessage("HELLO WORLD")]); const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant. Follow instructions exactly.", - model, + model: faux.getModel(), thinkingLevel: "off", tools: [], }, }); - // Manually add a user message without calling prompt() const userMessage: UserMessage = { role: "user", content: [{ type: "text", text: "Say exactly: HELLO WORLD" }], timestamp: Date.now(), }; - agent.replaceMessages([userMessage]); + agent.state.messages = [userMessage]; - // Continue from the user message await agent.continue(); 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[1].role).toBe("assistant"); - const assistantMsg = agent.state.messages[1] as AssistantMessage; - const textContent = assistantMsg.content.find((c) => c.type === "text"); - expect(textContent).toBeDefined(); - if (textContent?.type === "text") { - expect(textContent.text.toUpperCase()).toContain("HELLO WORLD"); - } + const assistantMsg = agent.state.messages[1]; + if (assistantMsg.role !== "assistant") throw new Error("Expected assistant message"); + expect(getTextContent(assistantMsg).toUpperCase()).toContain("HELLO WORLD"); }); }); - describe.skipIf(!process.env.OPENAI_API_KEY)("continue from tool result", () => { - const model = getModel("openai", "gpt-5.4"); - - it("should continue and process tool results", async () => { + describe("continue from tool result", () => { + it("continues and processes tool results", async () => { + const faux = createFauxRegistration(); + const model = faux.getModel(); + faux.setResponses([fauxAssistantMessage("The answer is 8.")]); const agent = new Agent({ initialState: { systemPrompt: @@ -449,7 +352,6 @@ describe("Agent.continue()", () => { }, }); - // Set up a conversation state as if tool was just executed const userMessage: UserMessage = { role: "user", content: [{ type: "text", text: "What is 5 + 3?" }], @@ -462,9 +364,9 @@ describe("Agent.continue()", () => { { type: "text", text: "Let me calculate that." }, { type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } }, ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-haiku-4-5", + api: model.api, + provider: model.provider, + model: model.id, usage: { input: 0, output: 0, @@ -486,26 +388,17 @@ describe("Agent.continue()", () => { timestamp: Date.now(), }; - agent.replaceMessages([userMessage, assistantMessage, toolResult]); + agent.state.messages = [userMessage, assistantMessage, toolResult]; - // Continue from the tool result await agent.continue(); expect(agent.state.isStreaming).toBe(false); - // Should have added an assistant response expect(agent.state.messages.length).toBeGreaterThanOrEqual(4); const lastMessage = agent.state.messages[agent.state.messages.length - 1]; expect(lastMessage.role).toBe("assistant"); - - if (lastMessage.role === "assistant") { - 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/); - } + if (lastMessage.role !== "assistant") throw new Error("Expected assistant message"); + expect(getTextContent(lastMessage)).toContain("8"); }); }); }); diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index fde43ce7..eac79b85 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2304,6 +2304,23 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } 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": { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", @@ -5029,22 +5046,39 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } 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": { id: "mistral-small-latest", name: "Mistral Small (latest)", api: "mistral-conversations", provider: "mistral", baseUrl: "https://api.mistral.ai", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.1, - output: 0.3, + input: 0.15, + output: 0.6, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 256000, + maxTokens: 256000, } satisfies Model<"mistral-conversations">, "open-mistral-7b": { id: "open-mistral-7b", @@ -5575,6 +5609,23 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } 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": { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", @@ -10779,12 +10830,12 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.071, - output: 0.463, - cacheRead: 0, + input: 0.22, + output: 0.88, + cacheRead: 0.11, cacheWrite: 0, }, - contextWindow: 40960, + contextWindow: 32768, maxTokens: 16384, } satisfies Model<"anthropic-messages">, "alibaba/qwen-3-30b": { @@ -10813,13 +10864,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.29, - output: 0.59, - cacheRead: 0.145, + input: 0.16, + output: 0.64, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 40960, + contextWindow: 128000, + maxTokens: 8192, } satisfies Model<"anthropic-messages">, "alibaba/qwen3-235b-a22b-thinking": { id: "alibaba/qwen3-235b-a22b-thinking", @@ -10847,13 +10898,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, - cacheRead: 0.022, + input: 1.5, + output: 7.5, + cacheRead: 0.3, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 66536, + maxTokens: 65536, } satisfies Model<"anthropic-messages">, "alibaba/qwen3-coder-30b-a3b": { id: "alibaba/qwen3-coder-30b-a3b", @@ -11323,13 +11374,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, + input: 0.56, + output: 1.68, + cacheRead: 0.28, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 16384, + maxTokens: 8192, } satisfies Model<"anthropic-messages">, "deepseek/deepseek-v3.1-terminus": { id: "deepseek/deepseek-v3.1-terminus", @@ -11601,7 +11652,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 100000, + maxTokens: 8192, } satisfies Model<"anthropic-messages">, "meituan/longcat-flash-thinking": { id: "meituan/longcat-flash-thinking", @@ -11646,13 +11697,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0.09999999999999999, + input: 0.22, + output: 0.22, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 16384, + maxTokens: 8192, } satisfies Model<"anthropic-messages">, "meta/llama-3.2-11b": { id: "meta/llama-3.2-11b", @@ -11714,12 +11765,12 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.24, - output: 0.9700000000000001, + input: 0.35, + output: 1.15, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 524288, maxTokens: 8192, } satisfies Model<"anthropic-messages">, "meta/llama-4-scout": { @@ -12623,6 +12674,23 @@ export const MODELS = { contextWindow: 1050000, maxTokens: 128000, } 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": { id: "openai/gpt-oss-20b", name: "gpt-oss-20b", @@ -13108,9 +13176,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.3, - cacheRead: 0.02, + input: 0.09, + output: 0.29, + cacheRead: 0.045, cacheWrite: 0, }, contextWindow: 262144, @@ -13246,7 +13314,7 @@ export const MODELS = { cost: { input: 0.6, output: 2.2, - cacheRead: 0, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 200000, @@ -13286,6 +13354,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 128000, } 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": { id: "zai/glm-5-turbo", name: "GLM 5 Turbo", diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index d8382d70..877ca7c2 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -171,10 +171,15 @@ const state = session.agent.state; // state.model: Model - current model // state.thinkingLevel: ThinkingLevel - current thinking level // 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) -session.agent.replaceMessages(messages); +// Replace messages (useful for branching or restoration) +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 await session.agent.waitForIdle(); diff --git a/packages/coding-agent/docs/tree.md b/packages/coding-agent/docs/tree.md index 0beb62dd..cfc4d6ed 100644 --- a/packages/coding-agent/docs/tree.md +++ b/packages/coding-agent/docs/tree.md @@ -141,7 +141,7 @@ Flow: 4. Fire `session_before_tree` event (hook can cancel or provide summary) 5. Run default summarizer if needed 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 9. Notify custom tools via session event 10. Return result with `editorText` if user message was selected diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d1400805..5554ae0d 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -358,7 +358,7 @@ export class AgentSession { * happens here instead of in wrappers. */ private _installAgentToolHooks(): void { - this.agent.setBeforeToolCall(async ({ toolCall, args }) => { + this.agent.beforeToolCall = async ({ toolCall, args }) => { const runner = this._extensionRunner; if (!runner?.hasHandlers("tool_call")) { return undefined; @@ -379,9 +379,9 @@ export class AgentSession { } 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; if (!runner?.hasHandlers("tool_result")) { return undefined; @@ -405,7 +405,7 @@ export class AgentSession { content: hookResult.content, details: hookResult.details, }; - }); + }; } // ========================================================================= @@ -790,11 +790,11 @@ export class AgentSession { validToolNames.push(name); } } - this.agent.setTools(tools); + this.agent.state.tools = tools; // Rebuild base system prompt with new tool set this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); - this.agent.setSystemPrompt(this._baseSystemPrompt); + this.agent.state.systemPrompt = this._baseSystemPrompt; } /** Whether compaction or branch summarization is currently running */ @@ -813,12 +813,12 @@ export class AgentSession { /** Current steering mode */ get steeringMode(): "all" | "one-at-a-time" { - return this.agent.getSteeringMode(); + return this.agent.steeringMode; } /** Current follow-up mode */ 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 */ @@ -1051,10 +1051,10 @@ export class AgentSession { } // Apply extension-modified system prompt, or reset to base if (result?.systemPrompt) { - this.agent.setSystemPrompt(result.systemPrompt); + this.agent.state.systemPrompt = result.systemPrompt; } else { // 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) { await this.agent.prompt(appMessage); } else { - this.agent.appendMessage(appMessage); + this.agent.state.messages.push(appMessage); this.sessionManager.appendCustomMessageEntry( message.customType, message.content, @@ -1388,7 +1388,7 @@ export class AgentSession { await options.setup(this.sessionManager); // Sync agent state with session manager after setup const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.replaceMessages(sessionContext.messages); + this.agent.state.messages = sessionContext.messages; } this._reconnectToAgent(); @@ -1437,7 +1437,7 @@ export class AgentSession { const previousModel = this.model; const thinkingLevel = this._getThinkingLevelForModelSwitch(); - this.agent.setModel(model); + this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); @@ -1478,7 +1478,7 @@ export class AgentSession { const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); // Apply model - this.agent.setModel(next.model); + this.agent.state.model = next.model; this.sessionManager.appendModelChange(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 thinkingLevel = this._getThinkingLevelForModelSwitch(); - this.agent.setModel(nextModel); + this.agent.state.model = nextModel; this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); @@ -1534,7 +1534,7 @@ export class AgentSession { // Only persist if actually changing const isChanging = effectiveLevel !== this.agent.state.thinkingLevel; - this.agent.setThinkingLevel(effectiveLevel); + this.agent.state.thinkingLevel = effectiveLevel; if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); @@ -1620,7 +1620,7 @@ export class AgentSession { * Saves to settings. */ setSteeringMode(mode: "all" | "one-at-a-time"): void { - this.agent.setSteeringMode(mode); + this.agent.steeringMode = mode; this.settingsManager.setSteeringMode(mode); } @@ -1629,7 +1629,7 @@ export class AgentSession { * Saves to settings. */ setFollowUpMode(mode: "all" | "one-at-a-time"): void { - this.agent.setFollowUpMode(mode); + this.agent.followUpMode = mode; this.settingsManager.setFollowUpMode(mode); } @@ -1724,7 +1724,7 @@ export class AgentSession { this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); 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 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) const messages = this.agent.state.messages; 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); return; @@ -1995,7 +1995,7 @@ export class AgentSession { this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); 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 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 lastMsg = messages[messages.length - 1]; 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(() => { @@ -2108,7 +2108,7 @@ export class AgentSession { this._resourceLoader.extendResources(extensionPaths); 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<{ @@ -2160,7 +2160,7 @@ export class AgentSession { return; } - this.agent.setModel(refreshedModel); + this.agent.state.model = refreshedModel; } private _bindExtensionCore(runner: ExtensionRunner): void { @@ -2500,7 +2500,7 @@ export class AgentSession { // Remove error message from agent state (keep in session for history) const messages = this.agent.state.messages; 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) @@ -2633,7 +2633,7 @@ export class AgentSession { this._pendingBashMessages.push(bashMessage); } else { // Add to agent state immediately - this.agent.appendMessage(bashMessage); + this.agent.state.messages.push(bashMessage); // Save to session this.sessionManager.appendMessage(bashMessage); @@ -2666,7 +2666,7 @@ export class AgentSession { for (const bashMessage of this._pendingBashMessages) { // Add to agent state - this.agent.appendMessage(bashMessage); + this.agent.state.messages.push(bashMessage); // Save to session this.sessionManager.appendMessage(bashMessage); @@ -2725,7 +2725,7 @@ export class AgentSession { // Emit session event to custom tools - this.agent.replaceMessages(sessionContext.messages); + this.agent.state.messages = sessionContext.messages; // Restore model if saved if (sessionContext.model) { @@ -2735,7 +2735,7 @@ export class AgentSession { (m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId, ); if (match) { - this.agent.setModel(match); + this.agent.state.model = match; await this._emitModelSelect(match, previousModel, "restore"); } } @@ -2751,7 +2751,7 @@ export class AgentSession { const effectiveLevel = availableLevels.includes(defaultThinkingLevel) ? defaultThinkingLevel : this._clampThinkingLevel(defaultThinkingLevel, availableLevels); - this.agent.setThinkingLevel(effectiveLevel); + this.agent.state.thinkingLevel = effectiveLevel; this.sessionManager.appendThinkingLevelChange(effectiveLevel); } @@ -2824,7 +2824,7 @@ export class AgentSession { // Emit session event to custom tools (with reason "fork") if (!skipConversationRestore) { - this.agent.replaceMessages(sessionContext.messages); + this.agent.state.messages = sessionContext.messages; } return { selectedText, cancelled: false }; @@ -3006,7 +3006,7 @@ export class AgentSession { // Update agent state const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.replaceMessages(sessionContext.messages); + this.agent.state.messages = sessionContext.messages; // Emit session_tree event if (this._extensionRunner) { diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 02bc8e35..77256e97 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -326,7 +326,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Restore messages if session has existing data if (hasExistingSession) { - agent.replaceMessages(existingSession.messages); + agent.state.messages = existingSession.messages; if (!hasThinkingEntry) { sessionManager.appendThinkingLevelChange(thinkingLevel); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 48188120..e8407b02 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3319,7 +3319,7 @@ export class InteractiveMode { }, onTransportChange: (transport) => { this.settingsManager.setTransport(transport); - this.session.agent.setTransport(transport); + this.session.agent.transport = transport; }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 20937371..6a62452e 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -278,12 +278,12 @@ describe("AgentSession auto-compaction queue resume", () => { }; // 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 }, successfulAssistant, { role: "user", content: [{ type: "text", text: "another prompt" }], timestamp: Date.now() + 500 }, errorAssistant, - ]); + ]; const runAutoCompactionSpy = vi .spyOn( @@ -328,10 +328,10 @@ describe("AgentSession auto-compaction queue resume", () => { timestamp: Date.now(), }; - session.agent.replaceMessages([ + session.agent.state.messages = [ { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 }, errorAssistant, - ]); + ]; const runAutoCompactionSpy = vi .spyOn( @@ -407,12 +407,12 @@ describe("AgentSession auto-compaction queue resume", () => { }; // 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 }, keptAssistant, { role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 }, errorAssistant, - ]); + ]; const runAutoCompactionSpy = vi .spyOn( diff --git a/packages/coding-agent/test/agent-session-stats.test.ts b/packages/coding-agent/test/agent-session-stats.test.ts index 7c4d4ff2..57b8633c 100644 --- a/packages/coding-agent/test/agent-session-stats.test.ts +++ b/packages/coding-agent/test/agent-session-stats.test.ts @@ -74,7 +74,7 @@ function createSession() { } function syncAgentMessages(session: AgentSession, sessionManager: SessionManager): void { - session.agent.replaceMessages(sessionManager.buildSessionContext().messages); + session.agent.state.messages = sessionManager.buildSessionContext().messages; } describe("AgentSession.getSessionStats", () => { diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index 14229073..046954ca 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -446,7 +446,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi // Load existing messages const loadedSession = sessionManager.buildSessionContext(); 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`); } @@ -656,7 +656,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi // This picks up any messages synced above const reloadedSession = sessionManager.buildSessionContext(); if (reloadedSession.messages.length > 0) { - agent.replaceMessages(reloadedSession.messages); + agent.state.messages = reloadedSession.messages; log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`); } @@ -672,7 +672,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi ctx.users, skills, ); - session.agent.setSystemPrompt(systemPrompt); + session.agent.state.systemPrompt = systemPrompt; // Set up file upload function setUploadFunction(async (filePath: string, title?: string) => { diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md index 3c89b26a..e601f368 100644 --- a/packages/web-ui/README.md +++ b/packages/web-ui/README.md @@ -202,10 +202,10 @@ await agent.prompt({ role: 'user-with-attachments', content: 'Check this', attac // Control agent.abort(); -agent.setModel(newModel); -agent.setThinkingLevel('medium'); -agent.setTools([...]); -agent.queueMessage(customMessage); +agent.state.model = newModel; +agent.state.thinkingLevel = 'medium'; +agent.state.tools = [...]; +agent.followUp(customMessage); ``` ## Message Types @@ -312,7 +312,7 @@ replTool.runtimeProvidersFactory = () => [ new ArtifactsRuntimeProvider(artifactsPanel, agent, true), // read-write ]; -agent.setTools([replTool]); +agent.state.tools = [replTool]; ``` ### Extract Document @@ -325,7 +325,7 @@ import { createExtractDocumentTool } from '@mariozechner/pi-web-ui'; const extractTool = createExtractDocumentTool(); extractTool.corsProxyUrl = 'https://corsproxy.io/?'; -agent.setTools([extractTool]); +agent.state.tools = [extractTool]; ``` ### Artifacts Tool @@ -337,7 +337,7 @@ const artifactsPanel = new ArtifactsPanel(); artifactsPanel.agent = agent; // The tool is available as artifactsPanel.tool -agent.setTools([artifactsPanel.tool]); +agent.state.tools = [artifactsPanel.tool]; ``` ### Custom Tool Renderers @@ -551,7 +551,7 @@ const success = await ApiKeyPromptDialog.prompt('anthropic'); import { ModelSelector } from '@mariozechner/pi-web-ui'; ModelSelector.open(currentModel, (selectedModel) => { - agent.setModel(selectedModel); + agent.state.model = selectedModel; }); ``` diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts index 2c76c391..679e36a1 100644 --- a/packages/web-ui/src/ChatPanel.ts +++ b/packages/web-ui/src/ChatPanel.ts @@ -141,7 +141,7 @@ export class ChatPanel extends LitElement { const additionalTools = config?.toolsFactory?.(agent, this.agentInterface, this.artifactsPanel, runtimeProvidersFactory) || []; const tools = [this.artifactsPanel.tool, ...additionalTools]; - this.agent.setTools(tools); + this.agent.state.tools = tools; // Reconstruct artifacts from existing messages // Temporarily disable the onArtifactsChange callback to prevent auto-opening on load diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index b8f6550e..8e7af745 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -376,13 +376,15 @@ export class AgentInterface extends LitElement { if (this.onModelSelect) { this.onModelSelect(); } else { - ModelSelector.open(state.model, (model) => session.setModel(model)); + ModelSelector.open(state.model, (model) => { + session.state.model = model; + }); } }} .onThinkingChange=${ this.enableThinkingSelector ? (level: "off" | "minimal" | "low" | "medium" | "high") => { - session.setThinkingLevel(level); + session.state.thinkingLevel = level; } : undefined } diff --git a/packages/web-ui/src/components/MessageList.ts b/packages/web-ui/src/components/MessageList.ts index 75afda08..25670b3a 100644 --- a/packages/web-ui/src/components/MessageList.ts +++ b/packages/web-ui/src/components/MessageList.ts @@ -11,7 +11,7 @@ import { renderMessage } from "./message-renderer-registry.js"; export class MessageList extends LitElement { @property({ type: Array }) messages: AgentMessage[] = []; @property({ type: Array }) tools: AgentTool[] = []; - @property({ type: Object }) pendingToolCalls?: Set; + @property({ type: Object }) pendingToolCalls?: ReadonlySet; @property({ type: Boolean }) isStreaming: boolean = false; @property({ attribute: false }) onCostClick?: () => void; diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts index 485c5134..2843d563 100644 --- a/packages/web-ui/src/components/Messages.ts +++ b/packages/web-ui/src/components/Messages.ts @@ -85,7 +85,7 @@ export class UserMessage extends LitElement { export class AssistantMessage extends LitElement { @property({ type: Object }) message!: AssistantMessageType; @property({ type: Array }) tools?: AgentTool[]; - @property({ type: Object }) pendingToolCalls?: Set; + @property({ type: Object }) pendingToolCalls?: ReadonlySet; @property({ type: Boolean }) hideToolCalls = false; @property({ type: Object }) toolResultsById?: Map; @property({ type: Boolean }) isStreaming: boolean = false; diff --git a/packages/web-ui/src/components/StreamingMessageContainer.ts b/packages/web-ui/src/components/StreamingMessageContainer.ts index 3d269315..342dc7e6 100644 --- a/packages/web-ui/src/components/StreamingMessageContainer.ts +++ b/packages/web-ui/src/components/StreamingMessageContainer.ts @@ -6,7 +6,7 @@ import { property, state } from "lit/decorators.js"; export class StreamingMessageContainer extends LitElement { @property({ type: Array }) tools: AgentTool[] = []; @property({ type: Boolean }) isStreaming = false; - @property({ type: Object }) pendingToolCalls?: Set; + @property({ type: Object }) pendingToolCalls?: ReadonlySet; @property({ type: Object }) toolResultsById?: Map; @property({ attribute: false }) onCostClick?: () => void; diff --git a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts index eeda4f3c..86de7826 100644 --- a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts +++ b/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO, ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW, @@ -13,7 +14,7 @@ interface ArtifactsPanelLike { } interface AgentLike { - appendMessage(message: any): void; + state: { messages: AgentMessage[] }; } /** @@ -171,7 +172,7 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider { filename, content, }); - this.agent?.appendMessage({ + this.agent?.state.messages.push({ role: "artifact", action, filename, @@ -192,7 +193,7 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider { command: "delete", filename, }); - this.agent?.appendMessage({ + this.agent?.state.messages.push({ role: "artifact", action: "delete", filename,