import type { ImageContent, Message, QueuedMessage, ReasoningEffort, TextContent } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai"; import type { AgentTransport } from "./transports/types.js"; import type { AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "./types.js"; /** * Default message transformer: Keep only LLM-compatible messages, strip app-specific fields. * Converts attachments to proper content blocks (images → ImageContent, documents → TextContent). */ function defaultMessageTransformer(messages: AppMessage[]): Message[] { return messages .filter((m) => { // Only keep standard LLM message roles return m.role === "user" || m.role === "assistant" || m.role === "toolResult"; }) .map((m) => { if (m.role === "user") { const { attachments, ...rest } = m as any; // If no attachments, return as-is if (!attachments || attachments.length === 0) { return rest as Message; } // Convert attachments to content blocks const content = Array.isArray(rest.content) ? [...rest.content] : [{ type: "text", text: rest.content }]; for (const attachment of attachments as Attachment[]) { // Add image blocks for image attachments if (attachment.type === "image") { content.push({ type: "image", data: attachment.content, mimeType: attachment.mimeType, } as ImageContent); } // Add text blocks for documents with extracted text else if (attachment.type === "document" && attachment.extractedText) { content.push({ type: "text", text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`, isDocument: true, } as TextContent); } } return { ...rest, content } as Message; } return m as Message; }); } export interface AgentOptions { initialState?: Partial; transport: AgentTransport; // Transform app messages to LLM-compatible messages before sending to transport messageTransformer?: (messages: AppMessage[]) => Message[] | Promise; // Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn queueMode?: "all" | "one-at-a-time"; } export class Agent { private _state: AgentState = { systemPrompt: "", model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"), thinkingLevel: "off", tools: [], messages: [], isStreaming: false, streamMessage: null, pendingToolCalls: new Set(), error: undefined, }; private listeners = new Set<(e: AgentEvent) => void>(); private abortController?: AbortController; private transport: AgentTransport; private messageTransformer: (messages: AppMessage[]) => Message[] | Promise; private messageQueue: Array> = []; private queueMode: "all" | "one-at-a-time"; private runningPrompt?: Promise; private resolveRunningPrompt?: () => void; constructor(opts: AgentOptions) { this._state = { ...this._state, ...opts.initialState }; this.transport = opts.transport; this.messageTransformer = opts.messageTransformer || defaultMessageTransformer; this.queueMode = opts.queueMode || "one-at-a-time"; } get state(): AgentState { return this._state; } subscribe(fn: (e: AgentEvent) => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } // State mutators - update internal state without emitting events setSystemPrompt(v: string) { this._state.systemPrompt = v; } setModel(m: typeof this._state.model) { this._state.model = m; } setThinkingLevel(l: ThinkingLevel) { this._state.thinkingLevel = l; } setQueueMode(mode: "all" | "one-at-a-time") { this.queueMode = mode; } getQueueMode(): "all" | "one-at-a-time" { return this.queueMode; } setTools(t: typeof this._state.tools) { this._state.tools = t; } replaceMessages(ms: AppMessage[]) { this._state.messages = ms.slice(); } appendMessage(m: AppMessage) { this._state.messages = [...this._state.messages, m]; } async queueMessage(m: AppMessage) { // Transform message and queue it for injection at next turn const transformed = await this.messageTransformer([m]); this.messageQueue.push({ original: m, llm: transformed[0], // undefined if filtered out }); } clearMessageQueue() { this.messageQueue = []; } clearMessages() { this._state.messages = []; } abort() { this.abortController?.abort(); } /** * Returns a promise that resolves when the current prompt completes. * Returns immediately resolved promise if no prompt is running. */ waitForIdle(): Promise { return this.runningPrompt ?? Promise.resolve(); } /** * Clear all messages and state. Call abort() first if a prompt is in flight. */ reset() { this._state.messages = []; this._state.isStreaming = false; this._state.streamMessage = null; this._state.pendingToolCalls = new Set(); this._state.error = undefined; this.messageQueue = []; } async prompt(input: string, attachments?: Attachment[]) { const model = this._state.model; if (!model) { throw new Error("No model configured"); } // Build user message with attachments const content: Array = [{ type: "text", text: input }]; if (attachments?.length) { for (const a of attachments) { if (a.type === "image") { content.push({ type: "image", data: a.content, mimeType: a.mimeType }); } else if (a.type === "document" && a.extractedText) { content.push({ type: "text", text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`, isDocument: true, } as TextContent); } } } const userMessage: AppMessage = { role: "user", content, attachments: attachments?.length ? attachments : undefined, timestamp: Date.now(), }; await this._runAgentLoop(userMessage); } /** * Continue from the current context without adding a new user message. * Used for retry after overflow recovery when context already has user message or tool results. */ async continue() { const messages = this._state.messages; if (messages.length === 0) { throw new Error("No messages to continue from"); } const lastMessage = messages[messages.length - 1]; if (lastMessage.role !== "user" && lastMessage.role !== "toolResult") { throw new Error(`Cannot continue from message role: ${lastMessage.role}`); } await this._runAgentLoopContinue(); } /** * Internal: Run the agent loop with a new user message. */ private async _runAgentLoop(userMessage: AppMessage) { const { llmMessages, cfg } = await this._prepareRun(); const events = this.transport.run(llmMessages, userMessage as Message, cfg, this.abortController!.signal); await this._processEvents(events); } /** * Internal: Continue the agent loop from current context. */ private async _runAgentLoopContinue() { const { llmMessages, cfg } = await this._prepareRun(); const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal); await this._processEvents(events); } /** * Prepare for running the agent loop. */ private async _prepareRun() { 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: ReasoningEffort | undefined = this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel === "minimal" ? "low" : this._state.thinkingLevel; const cfg = { systemPrompt: this._state.systemPrompt, tools: this._state.tools, model, reasoning, getQueuedMessages: async () => { if (this.queueMode === "one-at-a-time") { if (this.messageQueue.length > 0) { const first = this.messageQueue[0]; this.messageQueue = this.messageQueue.slice(1); return [first] as QueuedMessage[]; } return []; } else { const queued = this.messageQueue.slice(); this.messageQueue = []; return queued as QueuedMessage[]; } }, }; const llmMessages = await this.messageTransformer(this._state.messages); return { llmMessages, cfg, model }; } /** * Process events from the transport. */ private async _processEvents(events: AsyncIterable) { const model = this._state.model!; const generatedMessages: AppMessage[] = []; let partial: AppMessage | null = null; try { for await (const ev of events) { switch (ev.type) { case "message_start": { partial = ev.message as AppMessage; this._state.streamMessage = ev.message as Message; break; } case "message_update": { partial = ev.message as AppMessage; this._state.streamMessage = ev.message as Message; break; } case "message_end": { partial = null; this._state.streamMessage = null; this.appendMessage(ev.message as AppMessage); generatedMessages.push(ev.message as AppMessage); break; } case "tool_execution_start": { const s = new Set(this._state.pendingToolCalls); s.add(ev.toolCallId); this._state.pendingToolCalls = s; break; } case "tool_execution_end": { const s = new Set(this._state.pendingToolCalls); s.delete(ev.toolCallId); this._state.pendingToolCalls = s; break; } case "turn_end": { if (ev.message.role === "assistant" && ev.message.errorMessage) { this._state.error = ev.message.errorMessage; } break; } case "agent_end": { this._state.streamMessage = null; break; } } this.emit(ev as AgentEvent); } // Handle any remaining partial message if (partial && partial.role === "assistant" && partial.content.length > 0) { const onlyEmpty = !partial.content.some( (c) => (c.type === "thinking" && c.thinking.trim().length > 0) || (c.type === "text" && c.text.trim().length > 0) || (c.type === "toolCall" && c.name.trim().length > 0), ); if (!onlyEmpty) { this.appendMessage(partial as AppMessage); generatedMessages.push(partial as AppMessage); } else { if (this.abortController?.signal.aborted) { throw new Error("Request was aborted"); } } } } catch (err: any) { const msg: Message = { 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(), }; this.appendMessage(msg as AppMessage); generatedMessages.push(msg as AppMessage); this._state.error = err?.message || String(err); } 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); } } }