diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 4535d82..da65203 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -1,12 +1,13 @@ -import type { - AssistantMessage, - Model, - ModelContext, - NonSystemMessage, - Tool, - ToolMessage, - ToolUseContent, - UserMessage, +import { + createToolMessage, + type AssistantMessage, + type Model, + type ModelContext, + type NonSystemMessage, + type Tool, + type ToolMessage, + type ToolUseContent, + type UserMessage, } from "@/foundation"; import type { AgentEvent } from "./agent-event"; @@ -256,16 +257,7 @@ export class Agent { : Promise.race(candidates)))!; remaining.delete(resolved.index); - const toolMessage: ToolMessage = { - role: "tool", - content: [ - { - type: "tool_result", - tool_use_id: resolved.toolUseId, - content: formatToolResultForMessage({ toolName: resolved.toolName, result: resolved.result }), - }, - ], - }; + const toolMessage: ToolMessage = createToolMessage(resolved.toolUseId, formatToolResultForMessage({ toolName: resolved.toolName, result: resolved.result })); this._appendMessage(toolMessage); yield { type: "message", message: toolMessage }; } diff --git a/src/cli/tui/hooks/use-agent-loop.ts b/src/cli/tui/hooks/use-agent-loop.ts index 28dcddd..0609fc6 100644 --- a/src/cli/tui/hooks/use-agent-loop.ts +++ b/src/cli/tui/hooks/use-agent-loop.ts @@ -2,7 +2,7 @@ import { createContext, createElement, useCallback, useContext, useEffect, useMe import type { ReactNode } from "react"; import type { Agent } from "@/agent"; -import type { AssistantMessage, NonSystemMessage, UserMessage } from "@/foundation"; +import { createAssistantMessage, createUserMessage, type AssistantMessage, type NonSystemMessage, type UserMessage } from "@/foundation"; import type { PromptSubmission, SlashCommand } from "../command-registry"; import { formatHelp, resolveBuiltinCommand } from "../command-registry"; @@ -101,16 +101,8 @@ export function AgentLoopProvider({ if (invocation?.name === "help") { flushPendingMessages(); - const userMessage: UserMessage = { role: "user", content: [{ type: "text", text }] }; - const helpMessage: AssistantMessage = { - role: "assistant", - content: [ - { - type: "text", - text: formatHelp(commands, invocation.args || undefined), - }, - ], - }; + const userMessage: UserMessage = createUserMessage(text); + const helpMessage: AssistantMessage = createAssistantMessage(formatHelp(commands, invocation.args || undefined)); setMessages((prev) => [...prev, userMessage, helpMessage]); return; } @@ -119,7 +111,7 @@ export function AgentLoopProvider({ try { agent.setRequestedSkillName(requestedSkillName); - const userMessage: UserMessage = { role: "user", content: [{ type: "text", text }] }; + const userMessage: UserMessage = createUserMessage(text); setMessages((prev) => [...prev, userMessage]); const stream = agent.stream(userMessage); diff --git a/src/coding/agents/lead-agent.ts b/src/coding/agents/lead-agent.ts index 736b4f5..6dfb1eb 100644 --- a/src/coding/agents/lead-agent.ts +++ b/src/coding/agents/lead-agent.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { Agent } from "@/agent"; import { createSkillsMiddleware } from "@/agent/skills/skills-middleware"; import { createTodoSystem } from "@/agent/todos/todos"; -import type { Model, NonSystemMessage, ToolUseContent } from "@/foundation"; +import { createUserMessage, type Model, type NonSystemMessage, type ToolUseContent } from "@/foundation"; import { type ApprovalDecision, @@ -49,15 +49,9 @@ export async function createCodingAgent({ const messages: NonSystemMessage[] = []; if (await agentsFile.exists()) { const agentsFileContent = await agentsFile.text(); - messages.push({ - role: "user", - content: [ - { - type: "text", - text: "> The `AGENTS.md` file has been automatically loaded. Here is the content:\n\n" + agentsFileContent, - }, - ], - }); + messages.push( + createUserMessage("> The `AGENTS.md` file has been automatically loaded. Here is the content:\n\n" + agentsFileContent), + ); } const { tool: todoTool, middleware: todoMiddleware } = createTodoSystem(); diff --git a/src/community/anthropic/stream-utils.ts b/src/community/anthropic/stream-utils.ts index dba42ba..fa0e400 100644 --- a/src/community/anthropic/stream-utils.ts +++ b/src/community/anthropic/stream-utils.ts @@ -1,6 +1,6 @@ import type Anthropic from "@anthropic-ai/sdk"; -import type { AssistantMessage, AssistantMessageContent, TokenUsage } from "@/foundation"; +import { createAssistantMessageWithContent, createTextContent, createThinkingContent, createToolUseContent, type AssistantMessage, type AssistantMessageContent, type TokenUsage } from "@/foundation"; /** * Accumulated state for a single content block while streaming. @@ -59,12 +59,10 @@ export class StreamAccumulator { if (item) content.push(item); } - return { - role: "assistant", - content, + return createAssistantMessageWithContent(content, { usage: this.hasFinalUsage ? this._buildUsage() : undefined, - ...(this.hasFinalUsage ? {} : { streaming: true }), - }; + streaming: !this.hasFinalUsage, + }); } private _handleBlockStart(event: Anthropic.RawContentBlockStartEvent): void { @@ -129,21 +127,16 @@ export class StreamAccumulator { */ function blockToContent(block: BlockState): AssistantMessageContent[number] | null { if (block.type === "text") { - return block.text ? { type: "text", text: block.text } : null; + return block.text ? createTextContent(block.text) : null; } if (block.type === "thinking") { - const thinkingContent: Record = { - type: "thinking", - thinking: block.thinking, - }; - if (block.signature) { - // Preserve the signature so it can be sent back in multi-turn conversations. - thinkingContent._anthropicSignature = block.signature; - } - return thinkingContent as { type: "thinking"; thinking: string }; + return createThinkingContent( + block.thinking, + block.signature ? { _anthropicSignature: block.signature } : undefined, + ); } // tool_use - return { type: "tool_use", id: block.id, name: block.name, input: parseToolInput(block.partialJson) }; + return createToolUseContent(block.id, block.name, parseToolInput(block.partialJson)); } function parseToolInput(partialJson: string): Record { diff --git a/src/community/anthropic/utils.ts b/src/community/anthropic/utils.ts index c54d8e8..c6d0b70 100644 --- a/src/community/anthropic/utils.ts +++ b/src/community/anthropic/utils.ts @@ -1,6 +1,52 @@ import type Anthropic from "@anthropic-ai/sdk"; -import type { AssistantMessage, Message, TokenUsage, Tool } from "@/foundation"; +import { + createAssistantMessageWithContent, + createTextContent, + createThinkingContent, + createToolUseContent, + type AssistantMessage, + type AssistantMessageContent, + type Message, + type TokenUsage, + type Tool, +} from "@/foundation"; + +// ============================================================================ +// Anthropic SDK Content Part Helpers +// ============================================================================ + +function anthropicTextBlock(text: string): Anthropic.TextBlockParam { + return { type: "text", text }; +} + +function anthropicImageBlock(url: string): Anthropic.ImageBlockParam { + return { type: "image", source: { type: "url", url } }; +} + +function anthropicThinkingBlock(thinking: string, signature?: string): Anthropic.ThinkingBlockParam { + return { type: "thinking", thinking, signature: signature ?? "" }; +} + +function anthropicToolUseBlock(id: string, name: string, input: Record): Anthropic.ToolUseBlockParam { + return { type: "tool_use", id, name, input }; +} + +function anthropicToolResultBlock(toolUseId: string, content: string): Anthropic.ToolResultBlockParam { + return { type: "tool_result", tool_use_id: toolUseId, content }; +} + +function anthropicUserMessage(content: Anthropic.ContentBlockParam[]): Anthropic.MessageParam { + return { role: "user", content }; +} + +function anthropicAssistantMessage(content: Anthropic.ContentBlockParam[]): Anthropic.MessageParam { + return { role: "assistant", content }; +} + +// ============================================================================ +// Conversion Functions +// ============================================================================ /** * Extracts the system prompt from helixent messages. @@ -42,58 +88,33 @@ export function convertToAnthropicMessages( const content: Anthropic.ContentBlockParam[] = []; for (const part of message.content) { if (part.type === "text") { - content.push({ type: "text", text: part.text }); + content.push(anthropicTextBlock(part.text)); } else if (part.type === "image_url") { - // Anthropic uses base64 or URL-based image sources. - // For URL-based images, we use the url type. - content.push({ - type: "image", - source: { - type: "url", - url: part.image_url.url, - }, - }); + content.push(anthropicImageBlock(part.image_url.url)); } } - result.push({ role: "user", content }); + result.push(anthropicUserMessage(content)); } else if (message.role === "assistant") { const content: Anthropic.ContentBlockParam[] = []; for (const part of message.content) { if (part.type === "text") { - content.push({ type: "text", text: part.text }); + content.push(anthropicTextBlock(part.text)); } else if (part.type === "thinking") { - // Retrieve the preserved signature if available (set during parseAssistantMessage). - // Anthropic requires a valid signature for thinking blocks in multi-turn conversations. - const signature = - (part as unknown as Record)._anthropicSignature as string | undefined; - content.push({ - type: "thinking", - thinking: part.thinking, - signature: signature ?? "", - }); + const signature = part.providerData?._anthropicSignature as string | undefined; + content.push(anthropicThinkingBlock(part.thinking, signature)); } else if (part.type === "tool_use") { - content.push({ - type: "tool_use", - id: part.id, - name: part.name, - input: part.input, - }); + content.push(anthropicToolUseBlock(part.id, part.name, part.input)); } } - result.push({ role: "assistant", content }); + result.push(anthropicAssistantMessage(content)); } else if (message.role === "tool") { - // Anthropic expects tool results as user messages with tool_result content blocks. const content: Anthropic.ToolResultBlockParam[] = []; for (const part of message.content) { if (part.type === "tool_result") { - content.push({ - type: "tool_result", - tool_use_id: part.tool_use_id, - content: part.content, - }); + content.push(anthropicToolResultBlock(part.tool_use_id, part.content)); } } - result.push({ role: "user", content }); + result.push(anthropicUserMessage(content)); } } @@ -110,37 +131,24 @@ export function parseAssistantMessage( response: Anthropic.Message, usage?: TokenUsage, ): AssistantMessage { - const result: AssistantMessage = { - role: "assistant", - content: [], - usage, - }; + const content: AssistantMessageContent = []; for (const block of response.content) { if (block.type === "text") { - result.content.push({ type: "text", text: block.text }); + content.push(createTextContent(block.text)); } else if (block.type === "thinking") { - // Preserve the signature so it can be sent back in multi-turn conversations. - // The signature is stored as an extra runtime property on the content object. - const thinkingContent: Record = { - type: "thinking", - thinking: block.thinking, - }; - if (block.signature) { - thinkingContent._anthropicSignature = block.signature; - } - result.content.push(thinkingContent as { type: "thinking"; thinking: string }); + content.push( + createThinkingContent( + block.thinking, + block.signature ? { _anthropicSignature: block.signature } : undefined, + ), + ); } else if (block.type === "tool_use") { - result.content.push({ - type: "tool_use", - id: block.id, - name: block.name, - input: block.input as Record, - }); + content.push(createToolUseContent(block.id, block.name, block.input as Record)); } } - return result; + return createAssistantMessageWithContent(content, { usage }); } /** diff --git a/src/community/openai/stream-utils.ts b/src/community/openai/stream-utils.ts index ddbf21f..719d341 100644 --- a/src/community/openai/stream-utils.ts +++ b/src/community/openai/stream-utils.ts @@ -1,6 +1,6 @@ import type { ChatCompletionChunk } from "openai/resources"; -import type { AssistantMessage, AssistantMessageContent, TokenUsage } from "@/foundation"; +import { createAssistantMessageWithContent, createTextContent, createThinkingContent, createToolUseContent, type AssistantMessage, type AssistantMessageContent, type TokenUsage } from "@/foundation"; function toTokenUsage(usage?: { prompt_tokens?: number; @@ -61,10 +61,10 @@ export class StreamAccumulator { const content: AssistantMessageContent = []; if (this.reasoningContent) { - content.push({ type: "thinking", thinking: this.reasoningContent }); + content.push(createThinkingContent(this.reasoningContent)); } if (this.textContent) { - content.push({ type: "text", text: this.textContent }); + content.push(createTextContent(this.textContent)); } // Sort by index to preserve order @@ -85,14 +85,12 @@ export class StreamAccumulator { // a half-formed payload. On the final snapshot we fall back to the // best-effort empty object to preserve the previous contract. if (!parsed && !isFinal) continue; - content.push({ type: "tool_use", id: tc.id, name: tc.name, input }); + content.push(createToolUseContent(tc.id, tc.name, input)); } - return { - role: "assistant", - content, + return createAssistantMessageWithContent(content, { usage: this.usage, - ...(this.usage ? {} : { streaming: true }), - }; + streaming: !this.usage, + }); } } diff --git a/src/community/openai/utils.ts b/src/community/openai/utils.ts index 081b74a..6e94096 100644 --- a/src/community/openai/utils.ts +++ b/src/community/openai/utils.ts @@ -6,7 +6,37 @@ import type { ChatCompletionTool, } from "openai/resources"; -import type { AssistantMessage, Message, TokenUsage, Tool } from "@/foundation"; +import { + createAssistantMessageWithContent, + createTextContent, + createThinkingContent, + createToolUseContent, + type AssistantMessage, + type AssistantMessageContent, + type Message, + type TokenUsage, + type Tool, +} from "@/foundation"; + +// ============================================================================ +// OpenAI SDK Content Part Helpers +// ============================================================================ + +function openaiToolCall(id: string, name: string, argumentsJson: string) { + return { type: "function" as const, id, function: { name, arguments: argumentsJson } }; +} + +function openaiAssistantMessage(): ChatCompletionAssistantMessageParam { + return { role: "assistant", content: [] }; +} + +function openaiToolMessage(toolCallId: string, content: string): Extract { + return { role: "tool" as const, tool_call_id: toolCallId, content }; +} + +// ============================================================================ +// Conversion Functions +// ============================================================================ /** * Converts the messages to OpenAI ChatCompletionMessageParam messages. @@ -19,10 +49,7 @@ export function convertToOpenAIMessages(messages: Message[]): ChatCompletionMess if (message.role === "system" || message.role === "user") { openaiMessages.push(message); } else if (message.role === "assistant") { - const assistantMessage: ChatCompletionAssistantMessageParam = { - role: "assistant", - content: [], - }; + const assistantMessage = openaiAssistantMessage(); for (const content of message.content) { if (content.type === "thinking") { continue; @@ -30,14 +57,7 @@ export function convertToOpenAIMessages(messages: Message[]): ChatCompletionMess if (!assistantMessage.tool_calls) { assistantMessage.tool_calls = []; } - assistantMessage.tool_calls.push({ - type: "function", - id: content.id, - function: { - name: content.name, - arguments: JSON.stringify(content.input), - }, - }); + assistantMessage.tool_calls.push(openaiToolCall(content.id, content.name, JSON.stringify(content.input))); } else { (assistantMessage.content as ChatCompletionContentPart[]).push(content); } @@ -49,11 +69,7 @@ export function convertToOpenAIMessages(messages: Message[]): ChatCompletionMess } else if (message.role === "tool") { for (const content of message.content) { if (content.type === "tool_result") { - openaiMessages.push({ - role: "tool", - tool_call_id: content.tool_use_id, - content: content.content, - }); + openaiMessages.push(openaiToolMessage(content.tool_use_id, content.content)); } } } @@ -70,30 +86,21 @@ export function parseAssistantMessage( message: ChatCompletionMessage & { reasoning_content?: string }, usage?: TokenUsage, ): AssistantMessage { - const result: AssistantMessage = { - role: "assistant", - content: [], - usage, - }; + const content: AssistantMessageContent = []; if (typeof message.reasoning_content === "string") { - result.content.push({ type: "thinking", thinking: message.reasoning_content }); + content.push(createThinkingContent(message.reasoning_content)); } if (typeof message.content === "string") { - result.content.push({ type: "text", text: message.content }); + content.push(createTextContent(message.content)); } if (message.tool_calls) { for (const tool_call of message.tool_calls) { if (tool_call.type === "function") { - result.content.push({ - type: "tool_use", - id: tool_call.id, - name: tool_call.function.name, - input: JSON.parse(tool_call.function.arguments), - }); + content.push(createToolUseContent(tool_call.id, tool_call.function.name, JSON.parse(tool_call.function.arguments))); } } } - return result; + return createAssistantMessageWithContent(content, { usage }); } /** diff --git a/src/foundation/messages/index.ts b/src/foundation/messages/index.ts index eea524d..9e03490 100644 --- a/src/foundation/messages/index.ts +++ b/src/foundation/messages/index.ts @@ -1 +1,2 @@ export * from "./types"; +export * from "./utils"; diff --git a/src/foundation/messages/types/content.ts b/src/foundation/messages/types/content.ts index 51582e4..4bfd2ef 100644 --- a/src/foundation/messages/types/content.ts +++ b/src/foundation/messages/types/content.ts @@ -35,6 +35,8 @@ export interface ThinkingContent { type: "thinking"; /** Opaque reasoning or chain-of-thought string from the model. */ thinking: string; + /** Provider-specific metadata (e.g. Anthropic signature for multi-turn). */ + providerData?: Record; } /** diff --git a/src/foundation/messages/utils.ts b/src/foundation/messages/utils.ts new file mode 100644 index 0000000..4ba969b --- /dev/null +++ b/src/foundation/messages/utils.ts @@ -0,0 +1,243 @@ +import type { + AssistantMessage, + AssistantMessageContent, + ImageURLContent, + SystemMessage, + SystemMessageContent, + TextContent, + ThinkingContent, + TokenUsage, + ToolMessage, + ToolMessageContent, + ToolResultContent, + ToolUseContent, + UserMessage, + UserMessageContent, +} from "./types"; + +// ============================================================================ +// Content Part Creators +// ============================================================================ + +/** + * Creates a text content part. + * @param text - The text content. + * @returns A TextContent object. + */ +export function createTextContent(text: string): TextContent { + return { type: "text", text }; +} + +/** + * Creates an image URL content part. + * @param url - The image URL. + * @param detail - Optional detail level for vision models. + * @returns An ImageURLContent object. + */ +export function createImageURLContent( + url: string, + detail?: "auto" | "high" | "low", +): ImageURLContent { + return { + type: "image_url", + image_url: { + url: url as "https://example.com", + ...(detail && { detail }), + } as ImageURLContent["image_url"], + }; +} + +/** + * Creates a thinking content part (for models that expose reasoning). + * @param thinking - The reasoning text. + * @param providerData - Optional provider-specific metadata. + * @returns A ThinkingContent object. + */ +export function createThinkingContent(thinking: string, providerData?: Record): ThinkingContent { + return { type: "thinking", thinking, ...(providerData && { providerData }) }; +} + +/** + * Creates a tool use content part. + * @param id - The unique tool call ID. + * @param name - The tool name. + * @param input - The tool arguments. + * @returns A ToolUseContent object. + */ +export function createToolUseContent = Record>( + id: string, + name: string, + input: T, +): ToolUseContent { + return { type: "tool_use", id, name, input }; +} + +/** + * Creates a tool result content part. + * @param toolUseId - The ID of the corresponding tool_use. + * @param content - The result content (often JSON string). + * @returns A ToolResultContent object. + */ +export function createToolResultContent(toolUseId: string, content: string): ToolResultContent { + return { type: "tool_result", tool_use_id: toolUseId, content }; +} + +// ============================================================================ +// Message Creators +// ============================================================================ + +/** + * Creates a system message with the given text. + * @param text - The system prompt text. + * @returns A SystemMessage object. + */ +export function createSystemMessage(text: string): SystemMessage { + return { role: "system", content: [createTextContent(text)] }; +} + +/** + * Creates a system message with multiple content parts. + * @param content - Array of content parts (only text allowed for system messages). + * @returns A SystemMessage object. + */ +export function createSystemMessageWithContent(content: SystemMessageContent): SystemMessage { + return { role: "system", content }; +} + +/** + * Creates a user message with the given text. + * @param text - The user message text. + * @returns A UserMessage object. + */ +export function createUserMessage(text: string): UserMessage { + return { role: "user", content: [createTextContent(text)] }; +} + +/** + * Creates a user message with multiple content parts (text and/or images). + * @param content - Array of TextContent or ImageURLContent. + * @returns A UserMessage object. + */ +export function createUserMessageWithContent(content: UserMessageContent): UserMessage { + return { role: "user", content }; +} + +/** + * Creates a user message with text and an image URL. + * @param text - The accompanying text. + * @param imageUrl - The image URL. + * @param detail - Optional vision detail level. + * @returns A UserMessage object. + */ +export function createUserMessageWithImage( + text: string, + imageUrl: string, + detail?: "auto" | "high" | "low", +): UserMessage { + return { + role: "user", + content: [createTextContent(text), createImageURLContent(imageUrl, detail)], + }; +} + +/** + * Creates an assistant message with the given text. + * @param text - The assistant's response text. + * @returns An AssistantMessage object. + */ +export function createAssistantMessage(text: string): AssistantMessage { + return { role: "assistant", content: [createTextContent(text)] }; +} + +/** + * Creates an assistant message with multiple content parts. + * @param content - Array of TextContent, ThinkingContent, and/or ToolUseContent. + * @param options - Optional fields (usage, streaming). + * @returns An AssistantMessage object. + */ +export function createAssistantMessageWithContent( + content: AssistantMessageContent, + options?: { usage?: TokenUsage; streaming?: boolean }, +): AssistantMessage { + return { + role: "assistant", + content, + ...(options?.usage && { usage: options.usage }), + ...(options?.streaming && { streaming: true }), + }; +} + +/** + * Creates an assistant message with text and thinking content. + * @param text - The response text. + * @param thinking - The model's reasoning/thinking. + * @returns An AssistantMessage object. + */ +export function createAssistantMessageWithThinking(text: string, thinking: string): AssistantMessage { + return { + role: "assistant", + content: [createTextContent(text), createThinkingContent(thinking)], + }; +} + +/** + * Creates a tool message (tool execution result). + * @param toolUseId - The ID of the corresponding tool_use. + * @param content - The result content. + * @returns A ToolMessage object. + */ +export function createToolMessage(toolUseId: string, content: string): ToolMessage { + return { + role: "tool", + content: [createToolResultContent(toolUseId, content)], + }; +} + +/** + * Creates a tool message with multiple results. + * @param content - Array of ToolResultContent. + * @returns A ToolMessage object. + */ +export function createToolMessageWithContent(content: ToolMessageContent): ToolMessage { + return { role: "tool", content }; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Extracts all text content from a message, concatenated into a single string. + * For assistant messages, includes both text and thinking content. + * @param message - The message to extract text from. + * @returns The concatenated text content. + */ +export function extractTextFromMessage(message: { content: Array<{ type: string; text?: string; thinking?: string }> }): string { + return message.content + .map((c) => { + if (c.type === "text") return c.text ?? ""; + if (c.type === "thinking") return c.thinking ?? ""; + return ""; + }) + .join(""); +} + +/** + * Checks if a message contains any tool_use content. + * @param message - The message to check (typically an assistant message). + * @returns True if the message contains tool_use content. + */ +export function hasToolUse(message: { content: Array<{ type: string }> }): boolean { + return message.content.some((c) => c.type === "tool_use"); +} + +/** + * Extracts all tool_use content from a message. + * @param message - The message to extract from (typically an assistant message). + * @returns Array of ToolUseContent objects. + */ +export function extractToolUseContent = Record>( + message: { content: Array<{ type: string }> }, +): ToolUseContent[] { + return message.content.filter((c): c is ToolUseContent => c.type === "tool_use"); +} diff --git a/src/foundation/models/model.ts b/src/foundation/models/model.ts index b0187c0..472ba42 100644 --- a/src/foundation/models/model.ts +++ b/src/foundation/models/model.ts @@ -1,4 +1,4 @@ -import type { Message } from "../messages"; +import { createSystemMessage, type Message } from "../messages"; import type { ModelContext } from "./model-context"; import type { ModelProvider, ModelProviderInvokeParams } from "./model-provider"; @@ -50,7 +50,7 @@ export class Model { private _buildModelProviderParams(context: ModelContext): ModelProviderInvokeParams { const messages: Message[] = []; if (context.prompt) { - messages.push({ role: "system", content: [{ type: "text", text: context.prompt }] }); + messages.push(createSystemMessage(context.prompt)); } messages.push(...context.messages); return {