Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 11 additions & 19 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
}
Expand Down
16 changes: 4 additions & 12 deletions src/cli/tui/hooks/use-agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
14 changes: 4 additions & 10 deletions src/coding/agents/lead-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down
27 changes: 10 additions & 17 deletions src/community/anthropic/stream-utils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown> = {
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<string, unknown> {
Expand Down
126 changes: 67 additions & 59 deletions src/community/anthropic/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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.
Expand Down Expand Up @@ -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<string, unknown>)._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));
}
}

Expand All @@ -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<string, unknown> = {
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<string, unknown>,
});
content.push(createToolUseContent(block.id, block.name, block.input as Record<string, unknown>));
}
}

return result;
return createAssistantMessageWithContent(content, { usage });
}

/**
Expand Down
16 changes: 7 additions & 9 deletions src/community/openai/stream-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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,
});
}
}
Loading
Loading