UNPKG

@copilotkit/runtime

Version:

<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />

335 lines (293 loc) • 11.6 kB
/** * Copilot Runtime adapter for Anthropic. * * ## Example * * ```ts * import { CopilotRuntime, AnthropicAdapter } from "@copilotkit/runtime"; * import Anthropic from "@anthropic-ai/sdk"; * * const copilotKit = new CopilotRuntime(); * * const anthropic = new Anthropic({ * apiKey: "<your-api-key>", * }); * * return new AnthropicAdapter({ anthropic }); * ``` */ import Anthropic from "@anthropic-ai/sdk"; import { CopilotServiceAdapter, CopilotRuntimeChatCompletionRequest, CopilotRuntimeChatCompletionResponse, } from "../service-adapter"; import { convertActionInputToAnthropicTool, convertMessageToAnthropicMessage, limitMessagesToTokenCount, } from "./utils"; import { randomId, randomUUID } from "@copilotkit/shared"; import { convertServiceAdapterError } from "../shared"; const DEFAULT_MODEL = "claude-3-5-sonnet-latest"; export interface AnthropicAdapterParams { /** * An optional Anthropic instance to use. If not provided, a new instance will be * created. */ anthropic?: Anthropic; /** * The model to use. */ model?: string; } export class AnthropicAdapter implements CopilotServiceAdapter { private model: string = DEFAULT_MODEL; private _anthropic: Anthropic; public get anthropic(): Anthropic { return this._anthropic; } constructor(params?: AnthropicAdapterParams) { this._anthropic = params?.anthropic || new Anthropic({}); if (params?.model) { this.model = params.model; } } private shouldGenerateFallbackResponse(messages: Anthropic.Messages.MessageParam[]): boolean { if (messages.length === 0) return false; const lastMessage = messages[messages.length - 1]; // Check if the last message is a tool result const endsWithToolResult = lastMessage.role === "user" && Array.isArray(lastMessage.content) && lastMessage.content.some((content: any) => content.type === "tool_result"); // Also check if we have a recent pattern of user message -> assistant tool use -> user tool result // This indicates a completed action that might not need a response if (messages.length >= 3 && endsWithToolResult) { const lastThree = messages.slice(-3); const hasRecentToolPattern = lastThree[0]?.role === "user" && // Initial user message lastThree[1]?.role === "assistant" && // Assistant tool use Array.isArray(lastThree[1].content) && lastThree[1].content.some((content: any) => content.type === "tool_use") && lastThree[2]?.role === "user" && // Tool result Array.isArray(lastThree[2].content) && lastThree[2].content.some((content: any) => content.type === "tool_result"); return hasRecentToolPattern; } return endsWithToolResult; } async process( request: CopilotRuntimeChatCompletionRequest, ): Promise<CopilotRuntimeChatCompletionResponse> { const { threadId, model = this.model, messages: rawMessages, actions, eventSource, forwardedParameters, } = request; const tools = actions.map(convertActionInputToAnthropicTool); const messages = [...rawMessages]; // get the instruction message const instructionsMessage = messages.shift(); const instructions = instructionsMessage.isTextMessage() ? instructionsMessage.content : ""; // ALLOWLIST APPROACH: // 1. First, identify all valid tool_use calls (from assistant) // 2. Then, only keep tool_result blocks that correspond to these valid tool_use IDs // 3. Discard any other tool_result blocks // Step 1: Extract valid tool_use IDs const validToolUseIds = new Set<string>(); for (const message of messages) { if (message.isActionExecutionMessage()) { validToolUseIds.add(message.id); } } // Step 2: Map each message to an Anthropic message, eliminating invalid tool_results const processedToolResultIds = new Set<string>(); const anthropicMessages = messages .map((message) => { // For tool results, only include if they match a valid tool_use ID AND haven't been processed if (message.isResultMessage()) { // Skip if there's no corresponding tool_use if (!validToolUseIds.has(message.actionExecutionId)) { return null; // Will be filtered out later } // Skip if we've already processed a result for this tool_use ID if (processedToolResultIds.has(message.actionExecutionId)) { return null; // Will be filtered out later } // Mark this tool result as processed processedToolResultIds.add(message.actionExecutionId); return { role: "user", content: [ { type: "tool_result", content: message.result || "Action completed successfully", tool_use_id: message.actionExecutionId, }, ], }; } // For non-tool-result messages, convert normally return convertMessageToAnthropicMessage(message); }) .filter(Boolean) // Remove nulls .filter((msg) => { // Filter out assistant messages with empty text content if (msg.role === "assistant" && Array.isArray(msg.content)) { const hasEmptyTextOnly = msg.content.length === 1 && msg.content[0].type === "text" && (!(msg.content[0] as any).text || (msg.content[0] as any).text.trim() === ""); // Keep messages that have tool_use or non-empty text return !hasEmptyTextOnly; } return true; }) as Anthropic.Messages.MessageParam[]; // Apply token limits const limitedMessages = limitMessagesToTokenCount(anthropicMessages, tools, model); // We'll check if we need a fallback response after seeing what Anthropic returns // We skip grouping by role since we've already ensured uniqueness of tool_results let toolChoice: any = forwardedParameters?.toolChoice; if (forwardedParameters?.toolChoice === "function") { toolChoice = { type: "tool", name: forwardedParameters.toolChoiceFunctionName, }; } try { const createParams = { system: instructions, model: this.model, messages: limitedMessages, max_tokens: forwardedParameters?.maxTokens || 1024, ...(forwardedParameters?.temperature ? { temperature: forwardedParameters.temperature } : {}), ...(tools.length > 0 && { tools }), ...(toolChoice && { tool_choice: toolChoice }), stream: true, }; const stream = await this.anthropic.messages.create(createParams); eventSource.stream(async (eventStream$) => { let mode: "function" | "message" | null = null; let didOutputText = false; let currentMessageId = randomId(); let currentToolCallId = randomId(); let filterThinkingTextBuffer = new FilterThinkingTextBuffer(); let hasReceivedContent = false; try { for await (const chunk of stream as AsyncIterable<any>) { if (chunk.type === "message_start") { currentMessageId = chunk.message.id; } else if (chunk.type === "content_block_start") { hasReceivedContent = true; if (chunk.content_block.type === "text") { didOutputText = false; filterThinkingTextBuffer.reset(); mode = "message"; } else if (chunk.content_block.type === "tool_use") { currentToolCallId = chunk.content_block.id; eventStream$.sendActionExecutionStart({ actionExecutionId: currentToolCallId, actionName: chunk.content_block.name, parentMessageId: currentMessageId, }); mode = "function"; } } else if (chunk.type === "content_block_delta") { if (chunk.delta.type === "text_delta") { const text = filterThinkingTextBuffer.onTextChunk(chunk.delta.text); if (text.length > 0) { if (!didOutputText) { eventStream$.sendTextMessageStart({ messageId: currentMessageId }); didOutputText = true; } eventStream$.sendTextMessageContent({ messageId: currentMessageId, content: text, }); } } else if (chunk.delta.type === "input_json_delta") { eventStream$.sendActionExecutionArgs({ actionExecutionId: currentToolCallId, args: chunk.delta.partial_json, }); } } else if (chunk.type === "content_block_stop") { if (mode === "message") { if (didOutputText) { eventStream$.sendTextMessageEnd({ messageId: currentMessageId }); } } else if (mode === "function") { eventStream$.sendActionExecutionEnd({ actionExecutionId: currentToolCallId }); } } } } catch (error) { throw convertServiceAdapterError(error, "Anthropic"); } // Generate fallback response only if Anthropic produced no content if (!hasReceivedContent && this.shouldGenerateFallbackResponse(limitedMessages)) { // Extract the tool result content for a more contextual response let fallbackContent = "Task completed successfully."; const lastMessage = limitedMessages[limitedMessages.length - 1]; if (lastMessage?.role === "user" && Array.isArray(lastMessage.content)) { const toolResult = lastMessage.content.find((c: any) => c.type === "tool_result"); if (toolResult?.content && toolResult.content !== "Action completed successfully") { fallbackContent = toolResult.content; } } currentMessageId = randomId(); eventStream$.sendTextMessageStart({ messageId: currentMessageId }); eventStream$.sendTextMessageContent({ messageId: currentMessageId, content: fallbackContent, }); eventStream$.sendTextMessageEnd({ messageId: currentMessageId }); } eventStream$.complete(); }); } catch (error) { throw convertServiceAdapterError(error, "Anthropic"); } return { threadId: threadId || randomUUID(), }; } } const THINKING_TAG = "<thinking>"; const THINKING_TAG_END = "</thinking>"; class FilterThinkingTextBuffer { private buffer: string; private didFilterThinkingTag: boolean = false; constructor() { this.buffer = ""; } onTextChunk(text: string): string { this.buffer += text; if (this.didFilterThinkingTag) { return text; } const potentialTag = this.buffer.slice(0, THINKING_TAG.length); if (THINKING_TAG.startsWith(potentialTag)) { if (this.buffer.includes(THINKING_TAG_END)) { const end = this.buffer.indexOf(THINKING_TAG_END); const filteredText = this.buffer.slice(end + THINKING_TAG_END.length); this.buffer = filteredText; this.didFilterThinkingTag = true; return filteredText; } else { return ""; } } return text; } reset() { this.buffer = ""; this.didFilterThinkingTag = false; } }