logancyang_obsidian-copilot/docs/BEDROCK_TOOL_CALLING.md
Logan Yang 1e3e0786ef
Migrate to native tool call in plus and agent (#2123)
* Implement self-host mode
* Migrate from XML tool calls to native tool calls and reimplement agent mode
* Use ChatOpenRouter for copilot-plus-flash for native tool call sse support
* Fix QA exclusion for search v3
* Update plan and remove debug messages in agent
* Migrate chains off of xml and clean up related logic
* Implement agent reasoning block
* Update css for responsiveness
* Implement agent query pre-expansion and proper reasoning block display for search
* Update agent docs
* Refine agent reason block
* Refine agent reasoning UX
* Fallback to plus non-agent if native tool call is not supported by the model
* Fix time filter query
* Update self-host description
* Skip agent reasoning block and old tool call banners during chat save and load
* Fix projects mode switch to plus and chat auto saves as non-project bug
2026-01-26 18:35:27 -08:00

4.9 KiB

BedrockChatModel Tool Calling Implementation

Status: IMPLEMENTED

Native tool/function calling support has been added to the custom BedrockChatModel class for Agent mode.


Summary

The BedrockChatModel now supports LangChain's native tool calling via model.bindTools(tools), enabling it to work with Agent mode just like ChatOpenAI, ChatAnthropic, and ChatGoogleGenerativeAI.


Implementation Details

1. bindTools() Method

bindTools(tools: StructuredToolInterface[]): BedrockChatModel {
  const bound = Object.create(this) as BedrockChatModel;
  bound.boundTools = tools;
  return bound;
}

Creates a new instance with tools bound, following LangChain's pattern.

2. Tool Format Conversion

private convertToolsToClaude(tools: StructuredToolInterface[]): any[] {
  return tools.map((tool) => {
    let inputSchema: any = { type: "object", properties: {} };
    if (tool.schema) {
      inputSchema = isInteropZodSchema(tool.schema)
        ? toJsonSchema(tool.schema)
        : tool.schema;
    }
    return {
      name: tool.name,
      description: tool.description || "",
      input_schema: inputSchema,
    };
  });
}

Uses LangChain's isInteropZodSchema and toJsonSchema for proper schema conversion.

3. Request Body with Tools

Tools are included in the request payload when bound:

if (this.boundTools && this.boundTools.length > 0) {
  payload.tools = this.convertToolsToClaude(this.boundTools);
}

4. ToolMessage Handling

buildRequestBody handles ToolMessage (tool results) as tool_result content blocks:

if (messageType === "tool") {
  const toolMessage = message as ToolMessage;
  conversation.push({
    role: "user",
    content: [
      {
        type: "tool_result",
        tool_use_id: toolMessage.tool_call_id,
        content: toolResultContent,
      },
    ],
  });
}

5. AIMessage with Tool Calls

buildRequestBody handles AIMessage with tool_calls as tool_use content blocks:

if (toolCalls && toolCalls.length > 0) {
  const contentBlocks: ContentBlock[] = [];
  // Add text if present
  // Add tool_use blocks for each tool call
  for (const tc of toolCalls) {
    contentBlocks.push({
      type: "tool_use",
      id: tc.id || `tool_${Date.now()}`,
      name: tc.name,
      input: tc.args as Record<string, unknown>,
    });
  }
}

6. Non-Streaming Tool Call Extraction

_generate extracts tool calls from Claude's response:

private extractToolCalls(data: any): any[] | undefined {
  if (!Array.isArray(data?.content)) return undefined;
  const toolUseBlocks = data.content.filter(
    (block: any) => block.type === "tool_use"
  );
  if (toolUseBlocks.length === 0) return undefined;
  return toolUseBlocks.map((block: any) => ({
    id: block.id,
    name: block.name,
    args: block.input || {},
    type: "tool_call" as const,
  }));
}

7. Streaming Tool Call Chunks

processStreamEvent emits tool_call_chunks for LangChain's concat mechanism:

private extractToolCallChunk(event: any): { id?: string; index: number; name?: string; args?: string } | null {
  // content_block_start with tool_use - initial tool call info
  if (event.type === "content_block_start" && event.content_block?.type === "tool_use") {
    return {
      id: event.content_block.id,
      index: event.index ?? 0,
      name: event.content_block.name,
      args: "",
    };
  }
  // content_block_delta with input_json_delta - partial tool args
  if (event.type === "content_block_delta" && event.delta?.type === "input_json_delta") {
    return {
      index: event.index ?? 0,
      args: event.delta.partial_json || "",
    };
  }
  return null;
}

Tool call chunks are emitted as AIMessageChunk with tool_call_chunks:

const toolCallChunk = this.extractToolCallChunk(innerEvent);
if (toolCallChunk) {
  const messageChunk = new AIMessageChunk({
    content: "",
    response_metadata: chunkMetadata,
    tool_call_chunks: [toolCallChunk],
  });
  deltaChunks.push(new ChatGenerationChunk({ message: messageChunk, text: "" }));
}

Testing

const model = new BedrockChatModel({
  modelId: "us.anthropic.claude-3-5-sonnet-20241022-v2:0",
  apiKey: "...",
  endpoint: "...",
  streamEndpoint: "...",
});

const tools = [
  {
    name: "get_weather",
    description: "Get weather for a location",
    schema: z.object({ location: z.string() }),
  },
];

const boundModel = model.bindTools(tools);
const response = await boundModel.invoke([new HumanMessage("What's the weather in Tokyo?")]);

console.log(response.tool_calls); // Should have tool call

Reference