Previously, Agent.emit() was called before state was updated (e.g., appendMessage). This meant event handlers saw stale state - when message_end fired, agent.state.messages didn't include the message yet. Now state is updated first, then events are emitted, so handlers see consistent state that matches the event.
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
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<AgentState>;
|
|
transport: AgentTransport;
|
|
// Transform app messages to LLM-compatible messages before sending to transport
|
|
messageTransformer?: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
|
// 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<string>(),
|
|
error: undefined,
|
|
};
|
|
private listeners = new Set<(e: AgentEvent) => void>();
|
|
private abortController?: AbortController;
|
|
private transport: AgentTransport;
|
|
private messageTransformer: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
|
private messageQueue: Array<QueuedMessage<AppMessage>> = [];
|
|
private queueMode: "all" | "one-at-a-time";
|
|
private runningPrompt?: Promise<void>;
|
|
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<void> {
|
|
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<string>();
|
|
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");
|
|
}
|
|
|
|
// Set up running prompt tracking
|
|
this.runningPrompt = new Promise<void>((resolve) => {
|
|
this.resolveRunningPrompt = resolve;
|
|
});
|
|
|
|
// Build user message with attachments
|
|
const content: Array<TextContent | ImageContent> = [{ 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(),
|
|
};
|
|
|
|
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 <T>() => {
|
|
// Return queued messages based on queue mode
|
|
if (this.queueMode === "one-at-a-time") {
|
|
// Return only first message
|
|
if (this.messageQueue.length > 0) {
|
|
const first = this.messageQueue[0];
|
|
this.messageQueue = this.messageQueue.slice(1);
|
|
return [first] as QueuedMessage<T>[];
|
|
}
|
|
return [];
|
|
} else {
|
|
// Return all queued messages at once
|
|
const queued = this.messageQueue.slice();
|
|
this.messageQueue = [];
|
|
return queued as QueuedMessage<T>[];
|
|
}
|
|
},
|
|
};
|
|
|
|
// Track all messages generated in this prompt
|
|
const generatedMessages: AppMessage[] = [];
|
|
|
|
try {
|
|
let partial: Message | null = null;
|
|
|
|
// Transform app messages to LLM-compatible messages (initial set)
|
|
const llmMessages = await this.messageTransformer(this._state.messages);
|
|
|
|
for await (const ev of this.transport.run(
|
|
llmMessages,
|
|
userMessage as Message,
|
|
cfg,
|
|
this.abortController.signal,
|
|
)) {
|
|
// Update internal state BEFORE emitting events
|
|
// so handlers see consistent state
|
|
switch (ev.type) {
|
|
case "message_start": {
|
|
partial = ev.message;
|
|
this._state.streamMessage = ev.message;
|
|
break;
|
|
}
|
|
case "message_update": {
|
|
partial = ev.message;
|
|
this._state.streamMessage = ev.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;
|
|
}
|
|
}
|
|
|
|
// Emit after state is updated
|
|
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<string>();
|
|
this.abortController = undefined;
|
|
this.resolveRunningPrompt?.();
|
|
this.runningPrompt = undefined;
|
|
this.resolveRunningPrompt = undefined;
|
|
}
|
|
}
|
|
|
|
private emit(e: AgentEvent) {
|
|
for (const listener of this.listeners) {
|
|
listener(e);
|
|
}
|
|
}
|
|
}
|