mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 18:02:11 +00:00
WIP: Refactor agent package - not compiling
- Renamed AppMessage to AgentMessage throughout - New agent-loop.ts with AgentLoopContext, AgentLoopConfig - Removed transport abstraction, Agent now takes streamFn directly - Extracted streamProxy to proxy.ts utility - Removed agent-loop from pi-ai (now in agent package) - Updated consumers (coding-agent, mom) for AgentMessage rename - Tests updated but some consumers still need migration Known issues: - AgentTool, AgentToolResult not exported from pi-ai - Attachment not exported from pi-agent-core - ProviderTransport removed but still referenced - messageTransformer -> convertToLlm migration incomplete - CustomMessages declaration merging not working properly
This commit is contained in:
parent
f7ef44dc38
commit
a055fd4481
32 changed files with 1312 additions and 2009 deletions
|
|
@ -1,64 +1,66 @@
|
|||
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";
|
||||
/**
|
||||
* Agent class that uses the agent-loop directly.
|
||||
* No transport abstraction - calls streamSimple via the loop.
|
||||
*/
|
||||
|
||||
import {
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type Message,
|
||||
type Model,
|
||||
type ReasoningEffort,
|
||||
streamSimple,
|
||||
type TextContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { agentLoop, agentLoopContinue } from "./agent-loop.js";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentLoopConfig,
|
||||
AgentMessage,
|
||||
AgentState,
|
||||
AgentTool,
|
||||
StreamFn,
|
||||
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).
|
||||
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
||||
*/
|
||||
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;
|
||||
});
|
||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
|
||||
}
|
||||
|
||||
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[]>;
|
||||
// Called before each LLM call inside the agent loop - can modify messages (e.g., for pruning)
|
||||
preprocessor?: (messages: Message[]) => Promise<Message[]>;
|
||||
// Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn
|
||||
|
||||
/**
|
||||
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
|
||||
* Default filters to user/assistant/toolResult and converts attachments.
|
||||
*/
|
||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Optional transform applied to context before convertToLlm.
|
||||
* Use for context pruning, injecting external context, etc.
|
||||
*/
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
|
||||
/**
|
||||
* Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn
|
||||
*/
|
||||
queueMode?: "all" | "one-at-a-time";
|
||||
|
||||
/**
|
||||
* Custom stream function (for proxy backends, etc.). Default uses streamSimple.
|
||||
*/
|
||||
streamFn?: StreamFn;
|
||||
|
||||
/**
|
||||
* Resolves an API key dynamically for each LLM call.
|
||||
* Useful for expiring tokens (e.g., GitHub Copilot OAuth).
|
||||
*/
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
|
|
@ -73,22 +75,25 @@ export class Agent {
|
|||
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 preprocessor?: (messages: Message[]) => Promise<Message[]>;
|
||||
private messageQueue: Array<QueuedMessage<AppMessage>> = [];
|
||||
private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
private messageQueue: AgentMessage[] = [];
|
||||
private queueMode: "all" | "one-at-a-time";
|
||||
private streamFn: StreamFn;
|
||||
private getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
private runningPrompt?: Promise<void>;
|
||||
private resolveRunningPrompt?: () => void;
|
||||
|
||||
constructor(opts: AgentOptions) {
|
||||
constructor(opts: AgentOptions = {}) {
|
||||
this._state = { ...this._state, ...opts.initialState };
|
||||
this.transport = opts.transport;
|
||||
this.messageTransformer = opts.messageTransformer || defaultMessageTransformer;
|
||||
this.preprocessor = opts.preprocessor;
|
||||
this.convertToLlm = opts.convertToLlm || defaultConvertToLlm;
|
||||
this.transformContext = opts.transformContext;
|
||||
this.queueMode = opts.queueMode || "one-at-a-time";
|
||||
this.streamFn = opts.streamFn || streamSimple;
|
||||
this.getApiKey = opts.getApiKey;
|
||||
}
|
||||
|
||||
get state(): AgentState {
|
||||
|
|
@ -100,12 +105,12 @@ export class Agent {
|
|||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
// State mutators - update internal state without emitting events
|
||||
// State mutators
|
||||
setSystemPrompt(v: string) {
|
||||
this._state.systemPrompt = v;
|
||||
}
|
||||
|
||||
setModel(m: typeof this._state.model) {
|
||||
setModel(m: Model<any>) {
|
||||
this._state.model = m;
|
||||
}
|
||||
|
||||
|
|
@ -121,25 +126,20 @@ export class Agent {
|
|||
return this.queueMode;
|
||||
}
|
||||
|
||||
setTools(t: typeof this._state.tools) {
|
||||
setTools(t: AgentTool<any>[]) {
|
||||
this._state.tools = t;
|
||||
}
|
||||
|
||||
replaceMessages(ms: AppMessage[]) {
|
||||
replaceMessages(ms: AgentMessage[]) {
|
||||
this._state.messages = ms.slice();
|
||||
}
|
||||
|
||||
appendMessage(m: AppMessage) {
|
||||
appendMessage(m: AgentMessage) {
|
||||
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
|
||||
});
|
||||
queueMessage(m: AgentMessage) {
|
||||
this.messageQueue.push(m);
|
||||
}
|
||||
|
||||
clearMessageQueue() {
|
||||
|
|
@ -154,17 +154,10 @@ export class Agent {
|
|||
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;
|
||||
|
|
@ -174,99 +167,53 @@ export class Agent {
|
|||
this.messageQueue = [];
|
||||
}
|
||||
|
||||
/** Send a prompt to the agent with an AppMessage. */
|
||||
async prompt(message: AppMessage): Promise<void>;
|
||||
/** Send a prompt to the agent with text and optional attachments. */
|
||||
async prompt(input: string, attachments?: Attachment[]): Promise<void>;
|
||||
async prompt(input: string | AppMessage, attachments?: Attachment[]) {
|
||||
/** Send a prompt with an AgentMessage */
|
||||
async prompt(message: AgentMessage): Promise<void>;
|
||||
async prompt(input: string, images?: ImageContent[]): Promise<void>;
|
||||
async prompt(input: string | AgentMessage, images?: ImageContent[]) {
|
||||
const model = this._state.model;
|
||||
if (!model) {
|
||||
throw new Error("No model configured");
|
||||
}
|
||||
if (!model) throw new Error("No model configured");
|
||||
|
||||
let userMessage: AppMessage;
|
||||
let userMessage: AgentMessage;
|
||||
|
||||
if (typeof input === "string") {
|
||||
// Build user message from text + 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);
|
||||
}
|
||||
}
|
||||
if (images && images.length > 0) {
|
||||
content.push(...images);
|
||||
}
|
||||
userMessage = {
|
||||
role: "user",
|
||||
content,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} else {
|
||||
// Use provided AppMessage directly
|
||||
userMessage = input;
|
||||
}
|
||||
|
||||
await this._runAgentLoop(userMessage);
|
||||
await this._runLoop(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.
|
||||
*/
|
||||
/** Continue from current context (for retry after overflow) */
|
||||
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}`);
|
||||
if (messages[messages.length - 1].role === "assistant") {
|
||||
throw new Error("Cannot continue from message role: assistant");
|
||||
}
|
||||
|
||||
await this._runAgentLoopContinue();
|
||||
await this._runLoop(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Run the agent loop with a new user message.
|
||||
* Run the agent loop.
|
||||
* If userMessage is provided, starts a new conversation turn.
|
||||
* Otherwise, continues from existing context.
|
||||
*/
|
||||
private async _runAgentLoop(userMessage: AppMessage) {
|
||||
const { llmMessages, cfg } = await this._prepareRun();
|
||||
|
||||
// Transform user message (e.g., HookMessage -> user message)
|
||||
const [transformedUserMessage] = await this.messageTransformer([userMessage]);
|
||||
|
||||
const events = this.transport.run(llmMessages, transformedUserMessage, 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() {
|
||||
private async _runLoop(userMessage?: AgentMessage) {
|
||||
const model = this._state.model;
|
||||
if (!model) {
|
||||
throw new Error("No model configured");
|
||||
}
|
||||
if (!model) throw new Error("No model configured");
|
||||
|
||||
this.runningPrompt = new Promise<void>((resolve) => {
|
||||
this.resolveRunningPrompt = resolve;
|
||||
|
|
@ -282,88 +229,89 @@ export class Agent {
|
|||
? undefined
|
||||
: this._state.thinkingLevel === "minimal"
|
||||
? "low"
|
||||
: this._state.thinkingLevel;
|
||||
: (this._state.thinkingLevel as ReasoningEffort);
|
||||
|
||||
const cfg = {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: this._state.systemPrompt,
|
||||
messages: this._state.messages.slice(),
|
||||
tools: this._state.tools,
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model,
|
||||
reasoning,
|
||||
preprocessor: this.preprocessor,
|
||||
getQueuedMessages: async <T>() => {
|
||||
convertToLlm: this.convertToLlm,
|
||||
transformContext: this.transformContext,
|
||||
getApiKey: this.getApiKey,
|
||||
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<T>[];
|
||||
return [first];
|
||||
}
|
||||
return [];
|
||||
} else {
|
||||
const queued = this.messageQueue.slice();
|
||||
this.messageQueue = [];
|
||||
return queued as QueuedMessage<T>[];
|
||||
return queued;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const llmMessages = await this.messageTransformer(this._state.messages);
|
||||
|
||||
return { llmMessages, cfg, model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process events from the transport.
|
||||
*/
|
||||
private async _processEvents(events: AsyncIterable<AgentEvent>) {
|
||||
const model = this._state.model!;
|
||||
const generatedMessages: AppMessage[] = [];
|
||||
let partial: AppMessage | null = null;
|
||||
let partial: AgentMessage | 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;
|
||||
const stream = userMessage
|
||||
? agentLoop(userMessage, context, config, this.abortController.signal, this.streamFn)
|
||||
: agentLoopContinue(context, config, this.abortController.signal, this.streamFn);
|
||||
|
||||
for await (const event of stream) {
|
||||
// Update internal state based on events
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
partial = event.message;
|
||||
this._state.streamMessage = event.message;
|
||||
break;
|
||||
}
|
||||
case "message_update": {
|
||||
partial = ev.message;
|
||||
this._state.streamMessage = ev.message;
|
||||
|
||||
case "message_update":
|
||||
partial = event.message;
|
||||
this._state.streamMessage = event.message;
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
|
||||
case "message_end":
|
||||
partial = null;
|
||||
this._state.streamMessage = null;
|
||||
this.appendMessage(ev.message);
|
||||
generatedMessages.push(ev.message);
|
||||
this.appendMessage(event.message);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_start": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.add(ev.toolCallId);
|
||||
s.add(event.toolCallId);
|
||||
this._state.pendingToolCalls = s;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_end": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.delete(ev.toolCallId);
|
||||
s.delete(event.toolCallId);
|
||||
this._state.pendingToolCalls = s;
|
||||
break;
|
||||
}
|
||||
case "turn_end": {
|
||||
if (ev.message.role === "assistant" && ev.message.errorMessage) {
|
||||
this._state.error = ev.message.errorMessage;
|
||||
|
||||
case "turn_end":
|
||||
if (event.message.role === "assistant" && (event.message as any).errorMessage) {
|
||||
this._state.error = (event.message as any).errorMessage;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_end": {
|
||||
|
||||
case "agent_end":
|
||||
this._state.streamMessage = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(ev as AgentEvent);
|
||||
// Emit to listeners
|
||||
this.emit(event);
|
||||
}
|
||||
|
||||
// Handle any remaining partial message
|
||||
|
|
@ -375,8 +323,7 @@ export class Agent {
|
|||
(c.type === "toolCall" && c.name.trim().length > 0),
|
||||
);
|
||||
if (!onlyEmpty) {
|
||||
this.appendMessage(partial as AppMessage);
|
||||
generatedMessages.push(partial as AppMessage);
|
||||
this.appendMessage(partial);
|
||||
} else {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
|
|
@ -384,7 +331,7 @@ export class Agent {
|
|||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg: Message = {
|
||||
const errorMsg: AgentMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
|
|
@ -401,10 +348,11 @@ export class Agent {
|
|||
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);
|
||||
} 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue