From b4cd9831de68d8b415677f68423d983360b80863 Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin Date: Sat, 7 Mar 2026 02:33:18 +0800 Subject: [PATCH 1/3] feat: optimize client conversion performance --- packages/ema/src/agent.ts | 64 ++++++------ packages/ema/src/llm/base.ts | 126 +++++++++++++++++++++-- packages/ema/src/llm/client.ts | 106 ++++++++++++++++++-- packages/ema/src/llm/google_client.ts | 86 ++++++---------- packages/ema/src/llm/openai_client.ts | 70 ++++--------- packages/ema/src/llm/retry.ts | 138 -------------------------- 6 files changed, 295 insertions(+), 295 deletions(-) delete mode 100644 packages/ema/src/llm/retry.ts diff --git a/packages/ema/src/agent.ts b/packages/ema/src/agent.ts index 8c17c35..e02d86e 100644 --- a/packages/ema/src/agent.ts +++ b/packages/ema/src/agent.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { type LLMClient } from "./llm"; import { AgentConfig } from "./config"; import { Logger } from "./logger"; -import { RetryExhaustedError, isAbortError } from "./llm/retry"; +import { MessageHistory, RetryExhaustedError, isAbortError } from "./llm/base"; import type { LLMResponse, Message, Content, FunctionResponse } from "./schema"; import type { Tool, ToolResult, ToolContext } from "./tools/base"; import type { EmaReply } from "./tools/ema_reply_tool"; @@ -94,7 +94,7 @@ export class ContextManager { events: AgentEventsEmitter; logger: Logger; - state: AgentState = { + private _state: AgentState = { systemPrompt: "", messages: [], tools: [], @@ -104,50 +104,53 @@ export class ContextManager { llmClient: LLMClient, events: AgentEventsEmitter, logger: Logger, - tokenLimit: number = 80000, + public history: MessageHistory = llmClient.createHistory(), ) { this.llmClient = llmClient; this.events = events; this.logger = logger; } + get state(): AgentState { + return this._state; + } + + set state(v: AgentState) { + this._state = v; + // trigger the messages setter + this.messages = v.messages; + } + get systemPrompt(): string { - return this.state.systemPrompt; + return this._state.systemPrompt; } set systemPrompt(v: string) { - this.state.systemPrompt = v; + this._state.systemPrompt = v; } get messages(): Message[] { - return this.state.messages; + return this._state.messages; } set messages(v: Message[]) { - this.state.messages = v; + this._state.messages = v; + this.history = v.reduce( + (acc, msg) => acc.appendMessage(msg), + this.llmClient.createHistory(), + ); } get tools(): Tool[] { - return this.state.tools; + return this._state.tools; } set tools(v: Tool[]) { - this.state.tools = v; + this._state.tools = v; } - /** Add a user message to context. */ - addUserMessage(contents: Content[]): void { - this.messages.push({ role: "user", contents: contents }); - } - - /** Add an model message to context. */ - addModelMessage(response: LLMResponse): void { - this.messages.push(response.message); - } - - /** Add a tool result message to context. */ - addToolMessage(contents: FunctionResponse[]): void { - this.messages.push({ role: "user", contents: contents }); + get toolContext(): ToolContext | undefined { + return this._state.toolContext; } /** Get message history (shallow copy). */ @@ -189,7 +192,6 @@ export class Agent { this.llm, this.events, this.logger, - this.config.tokenLimit, ); } @@ -243,6 +245,10 @@ export class Agent { this.contextManager.messages, ); + const handler = this.llm.buildHandler( + this.contextManager.tools, + this.contextManager.systemPrompt, + ); while (step < maxSteps) { if (this.abortRequested) { this.finishAborted(); @@ -253,10 +259,8 @@ export class Agent { // Call LLM with context from context manager let response: LLMResponse; try { - response = await this.llm.generate( - this.contextManager.messages, - this.contextManager.tools, - this.contextManager.systemPrompt, + response = await handler.generate( + this.contextManager.history, this.abortController?.signal, ); this.logger.debug(`LLM response received.`, response); @@ -291,7 +295,7 @@ export class Agent { } // Add model message to context - this.contextManager.addModelMessage(response); + this.contextManager.history.addModelMessage(response); // Check if task is complete (no tool calls) if (checkCompleteMessages(this.contextManager.messages)) { @@ -341,7 +345,7 @@ export class Agent { try { result = await tool.execute( callArgs, - this.contextManager.state.toolContext, + this.contextManager.toolContext, ); } catch (err) { const errorDetail = `${(err as Error).name}: ${(err as Error).message}`; @@ -376,7 +380,7 @@ export class Agent { } // Add all function responses to context - this.contextManager.addToolMessage(functionResponses); + this.contextManager.history.addToolMessage(functionResponses); step += 1; } diff --git a/packages/ema/src/llm/base.ts b/packages/ema/src/llm/base.ts index 7c6eb88..90c8fcc 100644 --- a/packages/ema/src/llm/base.ts +++ b/packages/ema/src/llm/base.ts @@ -4,7 +4,77 @@ */ import type { Tool } from "../tools/base"; -import type { Message, LLMResponse } from "../schema"; +import type { + Message, + LLMResponse, + FunctionResponse, + Content, +} from "../schema"; + +export class RetryExhaustedError extends Error { + public lastException: Error; + public attempts: number; + + constructor(lastException: Error, attempts: number) { + super( + `Retry failed after ${attempts} attempts. Last error: ${lastException.message}`, + ); + this.name = "RetryExhaustedError"; + this.lastException = lastException; + this.attempts = attempts; + } +} + +/** + * Elegant retry mechanism module + * + * Provides decorators and utility functions to support retry logic for async functions. + * + * Features: + * - Supports exponential backoff strategy + * - Configurable retry count and intervals + * - Supports specifying retryable exception types + * - Detailed logging + * - Fully decoupled, non-invasive to business code + */ +export class RetryConfig { + constructor( + /** + * Whether to enable retry mechanism + */ + public readonly enabled: boolean = true, + /** + * Maximum number of retries + */ + public readonly max_retries: number = 3, + /** + * Initial delay time (seconds) + */ + public readonly initial_delay: number = 1.0, + /** + * Maximum delay time (seconds) + */ + public readonly max_delay: number = 60.0, + /** + * Exponential backoff base + */ + public readonly exponential_base: number = 2.0, + /** + * Retryable exception types + */ + // public readonly retryable_exceptions: Array = [Error], + ) {} +} + +export function isAbortError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + if (error.name === "AbortError") { + return true; + } + return error.message.toLowerCase().includes("abort"); +} /** * Abstract base class for LLM clients. @@ -12,25 +82,59 @@ import type { Message, LLMResponse } from "../schema"; * This class defines the interface that all LLM clients must implement, * regardless of the underlying API protocol (Anthropic, OpenAI, etc.). */ -export abstract class LLMClientBase { +export abstract class LLMClientBase { retryCallback: ((exception: Error, attempt: number) => void) | undefined = undefined; abstract adaptTools(tools: Tool[]): any[]; - abstract adaptMessages(messages: Message[]): any[]; + abstract appendMessage(history: M[], message: Message): M[]; abstract makeApiRequest( - apiMessages: any[], + history: MessageHistory, apiTools?: any[], systemPrompt?: string, signal?: AbortSignal, - ): Promise; - - abstract generate( - messages: Message[], - tools?: Tool[], - systemPrompt?: string, - signal?: AbortSignal, ): Promise; } + +/** + * Holds the messages for the specific LLM provider. + */ +export class MessageHistory { + messages: Message[] = []; + private apiMessages: M[] = []; + + constructor(private readonly client: LLMClientBase) {} + + getApiMessagesForClient(client: LLMClientBase): M[] { + if (client !== this.client) { + // this ensures that we always give correct message format to the client + throw new Error( + `Client mismatch: converted to ${this.client.constructor.name} while expected ${client.constructor.name}`, + ); + } + return this.apiMessages; + } + + /** Adds a user message to context. */ + addUserMessage(contents: Content[]): void { + this.appendMessage({ role: "user", contents: contents }); + } + + /** Adds an model message to context. */ + addModelMessage(response: LLMResponse): void { + this.appendMessage(response.message); + } + + /** Adds a tool result message to context. */ + addToolMessage(contents: FunctionResponse[]): void { + this.appendMessage({ role: "user", contents: contents }); + } + + appendMessage(message: Message): this { + this.messages.push(message); + this.apiMessages = this.client.appendMessage(this.apiMessages, message); + return this; + } +} diff --git a/packages/ema/src/llm/client.ts b/packages/ema/src/llm/client.ts index b635d1e..1740d65 100644 --- a/packages/ema/src/llm/client.ts +++ b/packages/ema/src/llm/client.ts @@ -1,4 +1,6 @@ +import { MessageHistory } from "./base"; import type { LLMClientBase } from "./base"; +import { isAbortError, RetryConfig, RetryExhaustedError } from "./base"; import { LLMConfig } from "../config"; import { GoogleClient } from "./google_client"; import { OpenAIClient } from "./openai_client"; @@ -16,7 +18,7 @@ export enum LLMProvider { export class LLMClient { private readonly client: LLMClientBase; - constructor(readonly config: LLMConfig) { + constructor(private readonly config: LLMConfig) { if (!this.config.chat_provider) { throw new Error("Missing LLM provider."); } @@ -49,17 +51,101 @@ export class LLMClient { } /** - * Proxy a generate request to the selected provider. - * @param messages Internal message array (EMA schema) + * Builds a message history. + */ + createHistory(): MessageHistory { + return new MessageHistory(this.client); + } + + /** + * Builds a generator request handler. * @param tools Optional tool definitions (EMA schema) * @param systemPrompt Optional system instruction text */ - generate( - messages: Message[], - tools?: Tool[], - systemPrompt?: string, - signal?: AbortSignal, - ): Promise { - return this.client.generate(messages, tools, systemPrompt, signal); + buildHandler(tools?: Tool[], systemPrompt?: string) { + const client = this.client; + const apiTools = tools ? client.adaptTools(tools) : undefined; + + const handler = withRetry( + client.makeApiRequest.bind(client), + this.config.retry, + client.retryCallback, + ); + return { + /** + * Proxies a generate request to the selected provider. + * @param messages Internal message array (schema compatible with the selected provider) + * @param signal Optional abort signal + */ + generate( + messages: MessageHistory, + signal?: AbortSignal, + ): Promise { + return handler(messages, apiTools, systemPrompt, signal); + }, + }; } } + +/** + * Wrap a standalone async function with retry logic (non-decorator usage). + * Useful when you want a callable instead of applying a class method decorator. + * + * WARN: the function `fn` must not modify arguments, otherwise the retry logic will not work as expected. + */ +export function withRetry Promise>( + fn: T, + /** + * Retry configuration + */ + config: RetryConfig = new RetryConfig(), + /** + * Callback function on retry, receives exception and current attempt number + */ + onRetry?: (exception: Error, attempt: number) => void, +): T { + return async function (...args: any[]) { + if (config.max_retries <= 0) { + throw new Error("Max retries must be greater than 0"); + } + if (!config.enabled) { + return await fn(...args); + } + + let lastException: Error | undefined; + for (let attempt = 0; attempt <= config.max_retries; attempt++) { + try { + return await fn(...args); + } catch (exception) { + lastException = exception as Error; + if (isAbortError(lastException)) { + throw lastException; + } + if (attempt >= config.max_retries) { + console.error( + `Function retry failed, reached maximum retry count ${config.max_retries}`, + ); + throw new RetryExhaustedError(lastException, attempt + 1); + } + // Calculates delay time (exponential backoff) + const delay = Math.min( + config.initial_delay * Math.pow(config.exponential_base, attempt), + config.max_delay, + ); + console.warn( + `Function call ${attempt + 1} failed: ${lastException.message}, retrying attempt ${attempt + 2} after ${delay.toFixed(2)} seconds`, + ); + // Calls callback function + if (onRetry) { + onRetry(lastException, attempt + 1); + } + // Waits before retry + await new Promise((resolve) => setTimeout(resolve, delay * 1000)); + } + } + if (lastException) { + throw lastException; + } + throw new Error("Unknown error"); + } as T; +} diff --git a/packages/ema/src/llm/google_client.ts b/packages/ema/src/llm/google_client.ts index cceba31..6bdaa3c 100644 --- a/packages/ema/src/llm/google_client.ts +++ b/packages/ema/src/llm/google_client.ts @@ -1,4 +1,4 @@ -import { LLMClientBase } from "./base"; +import { LLMClientBase, MessageHistory } from "./base"; import { isModelMessage, isUserMessage, @@ -8,7 +8,6 @@ import { } from "../schema"; import type { Content, LLMResponse, Message, SchemaAdapter } from "../schema"; import type { Tool } from "../tools"; -import { wrapWithRetry } from "./retry"; import { FetchWithProxy } from "./proxy"; import { GenerateContentResponse as GenAIResponse, @@ -52,7 +51,10 @@ export class GenAI extends GoogleGenAI { } /** Google Generative AI client that adapts EMA schema to the native Gemini API format. */ -export class GoogleClient extends LLMClientBase implements SchemaAdapter { +export class GoogleClient + extends LLMClientBase + implements SchemaAdapter +{ private readonly client: GoogleGenAI; private readonly thinkingLevelMap = new Map([ @@ -94,7 +96,7 @@ export class GoogleClient extends LLMClientBase implements SchemaAdapter { ); } - /** Map EMA message shape to Gemini request content. */ + /** Adapts a EMA message to a Gemini request content. */ adaptMessageToAPI(message: Message): GenAIMessage { /** Handle user messages by converting tool responses and contents to Gemini parts. */ if (isUserMessage(message)) { @@ -164,16 +166,13 @@ export class GoogleClient extends LLMClientBase implements SchemaAdapter { } /** Convert a batch of EMA messages. */ - adaptMessages(messages: Message[]): GenAIMessage[] { - const history: GenAIMessage[] = []; - for (const msg of messages) { - const converted = this.adaptMessageToAPI(msg); - const lastMsg = history[history.length - 1]; - if (lastMsg && lastMsg.role === converted.role) { - lastMsg.parts.push(...converted.parts); - } else { - history.push(converted); - } + appendMessage(history: GenAIMessage[], message: Message): GenAIMessage[] { + const converted = this.adaptMessageToAPI(message); + const lastMsg = history[history.length - 1]; + if (lastMsg && lastMsg.role === converted.role) { + lastMsg.parts.push(...converted.parts); + } else { + history.push(converted); } return history; } @@ -260,53 +259,26 @@ export class GoogleClient extends LLMClientBase implements SchemaAdapter { } /** Execute a Gemini content-generation request. */ - makeApiRequest( - apiMessages: GenAIMessage[], + async makeApiRequest( + history: MessageHistory, apiTools?: FunctionDeclaration[], systemPrompt?: string, signal?: AbortSignal, - ): Promise { - // console.log("API Request Messages:", JSON.stringify(apiMessages, null, 2)); - return this.client.models.generateContent({ - model: this.model, - contents: apiMessages, - config: { - candidateCount: 1, - systemInstruction: systemPrompt, - tools: [{ functionDeclarations: apiTools }], - abortSignal: signal, - thinkingConfig: { - thinkingLevel: this.thinkingLevelMap.get(this.model), - }, - }, - }); - } - - /** Public generate entrypoint matching LLMClientBase. */ - async generate( - messages: Message[], - tools?: Tool[], - systemPrompt?: string, - signal?: AbortSignal, ): Promise { - const apiMessages = this.adaptMessages(messages); - const apiTools = tools ? this.adaptTools(tools) : undefined; - - const executor = this.retryConfig.enabled - ? wrapWithRetry( - this.makeApiRequest.bind(this), - this.retryConfig, - this.retryCallback, - ) - : this.makeApiRequest.bind(this); - - const response = await executor( - apiMessages, - apiTools, - systemPrompt, - signal, + return this.adaptResponseFromAPI( + await this.client.models.generateContent({ + model: this.model, + contents: history.getApiMessagesForClient(this), + config: { + candidateCount: 1, + systemInstruction: systemPrompt, + tools: [{ functionDeclarations: apiTools }], + abortSignal: signal, + thinkingConfig: { + thinkingLevel: this.thinkingLevelMap.get(this.model), + }, + }, + }), ); - - return this.adaptResponseFromAPI(response); } } diff --git a/packages/ema/src/llm/openai_client.ts b/packages/ema/src/llm/openai_client.ts index 9adcdfd..0252971 100644 --- a/packages/ema/src/llm/openai_client.ts +++ b/packages/ema/src/llm/openai_client.ts @@ -7,7 +7,7 @@ import type { Response as OpenAIResponse, FunctionTool, } from "openai/resources/responses/responses"; -import { LLMClientBase } from "./base"; +import { LLMClientBase, MessageHistory } from "./base"; import { type SchemaAdapter, isModelMessage, @@ -16,9 +16,8 @@ import { isFunctionResponse, isTextItem, } from "../schema"; -import type { Content, LLMResponse, Message, ModelMessage } from "../schema"; +import type { Content, LLMResponse, Message } from "../schema"; import type { Tool } from "../tools/base"; -import { wrapWithRetry } from "./retry"; import type { LLMApiConfig, RetryConfig } from "../config"; import { FetchWithProxy } from "./proxy"; @@ -28,7 +27,10 @@ type OpenAIMessage = | EasyInputMessage; /** OpenAI-compatible client that adapts EMA schema to Responses API. */ -export class OpenAIClient extends LLMClientBase implements SchemaAdapter { +export class OpenAIClient + extends LLMClientBase + implements SchemaAdapter +{ private readonly client: OpenAI; constructor( @@ -141,12 +143,9 @@ export class OpenAIClient extends LLMClientBase implements SchemaAdapter { }; } - /** Convert a batch of EMA messages. */ - adaptMessages(messages: Message[]): OpenAIMessage[] { - const history: OpenAIMessage[] = []; - for (const message of messages) { - history.push(...this.adaptMessageToAPI(message)); - } + /** Converts a EMA message to a OpenAI Responses input item. */ + appendMessage(history: OpenAIMessage[], message: Message): OpenAIMessage[] { + history.push(...this.adaptMessageToAPI(message)); return history; } @@ -236,49 +235,22 @@ export class OpenAIClient extends LLMClientBase implements SchemaAdapter { } /** Execute a Responses API request. */ - makeApiRequest( - apiMessages: OpenAIMessage[], + async makeApiRequest( + history: MessageHistory, apiTools?: FunctionTool[], systemPrompt?: string, signal?: AbortSignal, - ): Promise { - console.log("API Request Messages:", JSON.stringify(apiMessages, null, 2)); - return this.client.responses.create( - { - model: this.model, - input: apiMessages, - tools: apiTools, - instructions: systemPrompt, - }, - { signal }, - ); - } - - /** Public generate entrypoint matching LLMClientBase. */ - async generate( - messages: Message[], - tools?: Tool[], - systemPrompt?: string, - signal?: AbortSignal, ): Promise { - const apiMessages = this.adaptMessages(messages); - const apiTools = tools ? this.adaptTools(tools) : undefined; - - const executor = this.retryConfig.enabled - ? wrapWithRetry( - this.makeApiRequest.bind(this), - this.retryConfig, - this.retryCallback, - ) - : this.makeApiRequest.bind(this); - - const response = await executor( - apiMessages, - apiTools, - systemPrompt, - signal, + return this.adaptResponseFromAPI( + await this.client.responses.create( + { + model: this.model, + input: history.getApiMessagesForClient(this), + tools: apiTools, + instructions: systemPrompt, + }, + { signal }, + ), ); - - return this.adaptResponseFromAPI(response); } } diff --git a/packages/ema/src/llm/retry.ts b/packages/ema/src/llm/retry.ts deleted file mode 100644 index 994a1b0..0000000 --- a/packages/ema/src/llm/retry.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Elegant retry mechanism module - * - * Provides decorators and utility functions to support retry logic for async functions. - * - * Features: - * - Supports exponential backoff strategy - * - Configurable retry count and intervals - * - Supports specifying retryable exception types - * - Detailed logging - * - Fully decoupled, non-invasive to business code - */ -export class RetryConfig { - constructor( - /** - * Whether to enable retry mechanism - */ - public readonly enabled: boolean = true, - /** - * Maximum number of retries - */ - public readonly max_retries: number = 3, - /** - * Initial delay time (seconds) - */ - public readonly initial_delay: number = 1.0, - /** - * Maximum delay time (seconds) - */ - public readonly max_delay: number = 60.0, - /** - * Exponential backoff base - */ - public readonly exponential_base: number = 2.0, - /** - * Retryable exception types - */ - // public readonly retryable_exceptions: Array = [Error], - ) {} -} - -/** - * Calculate delay time (exponential backoff) - * - * @param attempt - Current attempt number (starting from 0) - * @returns Delay time (seconds) - */ -function calculateDelay( - attempt: number, - initial_delay: number, - exponential_base: number, - max_delay: number, -): number { - const delay = initial_delay * Math.pow(exponential_base, attempt); - return Math.min(delay, max_delay); -} - -export class RetryExhaustedError extends Error { - public lastException: Error; - public attempts: number; - - constructor(lastException: Error, attempts: number) { - super( - `Retry failed after ${attempts} attempts. Last error: ${lastException.message}`, - ); - this.name = "RetryExhaustedError"; - this.lastException = lastException; - this.attempts = attempts; - } -} - -export function isAbortError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - if (error.name === "AbortError") { - return true; - } - return error.message.toLowerCase().includes("abort"); -} - -/** - * Wrap a standalone async function with retry logic (non-decorator usage). - * Useful when you want a callable instead of applying a class method decorator. - */ -export function wrapWithRetry Promise>( - originalMethod: T, - /** - * Retry configuration - */ - config: RetryConfig = new RetryConfig(), - /** - * Callback function on retry, receives exception and current attempt number - */ - onRetry?: (exception: Error, attempt: number) => void, -): T { - if (config.max_retries <= 0) { - throw new Error("Max retries must be greater than 0"); - } - return async function (...args: any[]) { - let lastException: Error | undefined; - for (let attempt = 0; attempt <= config.max_retries; attempt++) { - try { - return await originalMethod(...args); - } catch (exception) { - lastException = exception as Error; - if (isAbortError(lastException)) { - throw lastException; - } - if (attempt >= config.max_retries) { - console.error( - `Function retry failed, reached maximum retry count ${config.max_retries}`, - ); - throw new RetryExhaustedError(lastException, attempt + 1); - } - const delay = calculateDelay( - attempt, - config.initial_delay, - config.exponential_base, - config.max_delay, - ); - console.warn( - `Function call ${attempt + 1} failed: ${lastException.message}, retrying attempt ${attempt + 2} after ${delay.toFixed(2)} seconds`, - ); - // Call callback function - if (onRetry) { - onRetry(lastException, attempt + 1); - } - // Wait before retry - await new Promise((resolve) => setTimeout(resolve, delay * 1000)); - } - } - if (lastException) { - throw lastException; - } - throw new Error("Unknown error"); - } as T; -} From a938c775173fe3f6b217b0f50f10a72c230666ad Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin Date: Sat, 7 Mar 2026 02:48:37 +0800 Subject: [PATCH 2/3] feat: simplify adaptMessageToAPI --- packages/ema/src/llm/google_client.ts | 96 ++++++++++----------------- packages/ema/src/llm/openai_client.ts | 89 ++++++++----------------- packages/ema/src/schema.ts | 27 -------- 3 files changed, 62 insertions(+), 150 deletions(-) diff --git a/packages/ema/src/llm/google_client.ts b/packages/ema/src/llm/google_client.ts index 6bdaa3c..b53b539 100644 --- a/packages/ema/src/llm/google_client.ts +++ b/packages/ema/src/llm/google_client.ts @@ -1,11 +1,4 @@ import { LLMClientBase, MessageHistory } from "./base"; -import { - isModelMessage, - isUserMessage, - isFunctionCall, - isFunctionResponse, - isTextItem, -} from "../schema"; import type { Content, LLMResponse, Message, SchemaAdapter } from "../schema"; import type { Tool } from "../tools"; import { FetchWithProxy } from "./proxy"; @@ -96,64 +89,43 @@ export class GoogleClient ); } - /** Adapts a EMA message to a Gemini request content. */ + /** Adapts an EMA message to a Gemini request content. */ adaptMessageToAPI(message: Message): GenAIMessage { - /** Handle user messages by converting tool responses and contents to Gemini parts. */ - if (isUserMessage(message)) { - const contents: GenAIContent[] = []; - for (const content of message.contents) { - if (isFunctionResponse(content)) { - contents.push({ - functionResponse: { - name: content.name, - response: content.result, - }, - }); - continue; - } - if (isTextItem(content)) { - contents.push({ - text: content.text, - thoughtSignature: content.thoughtSignature, - }); - continue; - } - /** Additional content types can be handled here. */ - console.warn( - `Unsupported content type in user message: ${JSON.stringify(content)}`, - ); - } - return { role: "user", parts: contents }; + if (message.role !== "user" && message.role !== "model") { + throw new Error(`Unsupported message role: ${(message as Message).role}`); } - /** Handle model messages by converting contents and tool calls to Gemini parts. */ - if (isModelMessage(message)) { - const contents: GenAIContent[] = []; - for (const content of message.contents) { - if (isFunctionCall(content)) { - contents.push({ - functionCall: { - name: content.name, - args: content.args, - }, - thoughtSignature: content.thoughtSignature, - }); - continue; - } - if (isTextItem(content)) { - contents.push({ - text: content.text, - thoughtSignature: content.thoughtSignature, - }); - continue; + return { + role: message.role, + parts: message.contents.map((content): GenAIContent => { + switch (content.type) { + case "function_response": + return { + functionResponse: { + name: content.name, + response: content.result, + }, + }; + case "function_call": + return { + functionCall: { + name: content.name, + args: content.args, + }, + }; + case "text": + return { + text: content.text, + thoughtSignature: content.thoughtSignature, + }; + default: + /** Additional content types can be handled here. */ + console.warn( + `Unsupported content type in message: ${JSON.stringify(content)}`, + ); + return {}; } - /** Additional content types can be handled here. */ - console.warn( - `Unsupported content type in model message: ${JSON.stringify(content)}`, - ); - } - return { role: "model", parts: contents }; - } - throw new Error(`Unsupported message role: ${(message as Message).role}`); + }), + }; } /** Map tool definition to Gemini function declaration. */ diff --git a/packages/ema/src/llm/openai_client.ts b/packages/ema/src/llm/openai_client.ts index 0252971..19db4bf 100644 --- a/packages/ema/src/llm/openai_client.ts +++ b/packages/ema/src/llm/openai_client.ts @@ -8,15 +8,7 @@ import type { FunctionTool, } from "openai/resources/responses/responses"; import { LLMClientBase, MessageHistory } from "./base"; -import { - type SchemaAdapter, - isModelMessage, - isUserMessage, - isFunctionCall, - isFunctionResponse, - isTextItem, -} from "../schema"; -import type { Content, LLMResponse, Message } from "../schema"; +import type { SchemaAdapter, Content, LLMResponse, Message } from "../schema"; import type { Tool } from "../tools/base"; import type { LLMApiConfig, RetryConfig } from "../config"; import { FetchWithProxy } from "./proxy"; @@ -49,64 +41,38 @@ export class OpenAIClient this.client = new OpenAI(options); } - /** Map EMA message shape to OpenAI Responses input items. */ + /** Adapts an EMA message to OpenAI Responses input items. */ adaptMessageToAPI(message: Message): OpenAIMessage[] { + if (message.role !== "user" && message.role !== "model") { + throw new Error(`Unsupported message role: ${(message as Message).role}`); + } const items: OpenAIMessage[] = []; - if (isUserMessage(message)) { - for (const content of message.contents) { - if (isFunctionResponse(content)) { + for (const content of message.contents) { + switch (content.type) { + case "function_call": { items.push({ - type: "function_call_output", + type: "function_call", call_id: content.id!, - output: JSON.stringify(content.result), + name: content.name, + arguments: JSON.stringify(content.args), }); - continue; - } - if (isTextItem(content)) { - const lastItem = items[items.length - 1]; - if ( - lastItem && - lastItem.type === "message" && - lastItem.role === "user" && - Array.isArray(lastItem.content) - ) { - lastItem.content.push({ - type: "input_text", - text: content.text, - }); - } else { - items.push({ - type: "message", - role: "user", - content: [{ type: "input_text", text: content.text }], - }); - } - continue; + break; } - /** Additional content types can be handled here. */ - console.warn( - `Unsupported content type in user message: ${JSON.stringify(content)}`, - ); - } - return items; - } - if (isModelMessage(message)) { - for (const content of message.contents) { - if (isFunctionCall(content)) { + case "function_response": { items.push({ - type: "function_call", + type: "function_call_output", call_id: content.id!, - name: content.name, - arguments: JSON.stringify(content.args), + output: JSON.stringify(content.result), }); - continue; + break; } - if (isTextItem(content)) { + case "text": { + const expectedRole = message.role === "user" ? "user" : "assistant"; const lastItem = items[items.length - 1]; if ( lastItem && lastItem.type === "message" && - lastItem.role === "assistant" && + lastItem.role === expectedRole && Array.isArray(lastItem.content) ) { lastItem.content.push({ @@ -116,20 +82,21 @@ export class OpenAIClient } else { items.push({ type: "message", - role: "assistant", + role: expectedRole, content: [{ type: "input_text", text: content.text }], }); } - continue; + break; + } + default: { + /** Additional content types can be handled here. */ + console.warn( + `Unsupported content type in message: ${JSON.stringify(content)}`, + ); } - /** Additional content types can be handled here. */ - console.warn( - `Unsupported content type in model message: ${JSON.stringify(content)}`, - ); } - return items; } - throw new Error(`Unsupported message role: ${(message as Message).role}`); + return items; } /** Map tool definition to OpenAI Responses tool schema. */ diff --git a/packages/ema/src/schema.ts b/packages/ema/src/schema.ts index 14538aa..5a6194e 100644 --- a/packages/ema/src/schema.ts +++ b/packages/ema/src/schema.ts @@ -76,30 +76,3 @@ export interface SchemaAdapter { /** Converts a provider response back to the EMA schema. */ adaptResponseFromAPI(response: any): LLMResponse; } - -/** Type guard for model messages. */ -export function isModelMessage(message: Message): message is ModelMessage { - return message.role === "model"; -} - -/** Type guard for user messages. */ -export function isUserMessage(message: Message): message is UserMessage { - return message.role === "user"; -} - -/** Type guard for tool response content. */ -export function isTextItem(content: Content): content is TextItem { - return content.type === "text"; -} - -/** Type guard for function call content. */ -export function isFunctionCall(content: Content): content is FunctionCall { - return content.type === "function_call"; -} - -/** Type guard for function response content. */ -export function isFunctionResponse( - content: Content, -): content is FunctionResponse { - return content.type === "function_response"; -} From 560f354a35bad963bb7e99a96bd59ca3b86d6cf1 Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin Date: Sat, 7 Mar 2026 02:57:23 +0800 Subject: [PATCH 3/3] dev: optimize performance on build first message --- packages/ema/src/llm/google_client.ts | 5 +---- packages/ema/src/llm/openai_client.ts | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/ema/src/llm/google_client.ts b/packages/ema/src/llm/google_client.ts index b53b539..4da0d45 100644 --- a/packages/ema/src/llm/google_client.ts +++ b/packages/ema/src/llm/google_client.ts @@ -107,10 +107,7 @@ export class GoogleClient }; case "function_call": return { - functionCall: { - name: content.name, - args: content.args, - }, + functionCall: { name: content.name, args: content.args }, }; case "text": return { diff --git a/packages/ema/src/llm/openai_client.ts b/packages/ema/src/llm/openai_client.ts index 19db4bf..f57cb71 100644 --- a/packages/ema/src/llm/openai_client.ts +++ b/packages/ema/src/llm/openai_client.ts @@ -68,17 +68,14 @@ export class OpenAIClient } case "text": { const expectedRole = message.role === "user" ? "user" : "assistant"; - const lastItem = items[items.length - 1]; + const lastItem = items.at(-1); if ( lastItem && lastItem.type === "message" && lastItem.role === expectedRole && Array.isArray(lastItem.content) ) { - lastItem.content.push({ - type: "input_text", - text: content.text, - }); + lastItem.content.push({ type: "input_text", text: content.text }); } else { items.push({ type: "message",