Open7

AnthropicAI Tools を使ってみる

mizchimizchi

OpenAI API でいう Function Calling
https://docs.anthropic.com/claude/docs/tool-use

AI が使用可能な JSON APIの定義を与えおき、Chat AIがクエリに沿ってどのAPIを呼ぶかを分類する。
要は自然言語を関数呼び出しに変換する機能

ドキュメントを読みながらまず Deno でスッとコードを書いた。

import AnthropicAI from "npm:@anthropic-ai/sdk@0.20.7";
export type ChatMessage = {
  role: string,
  content: string;
};

function handleTool(content: AnthropicAI.Beta.Tools.Messages.ToolUseBlock) {
  if (content.name === "get_weather") {
    const payload = content.input as { location: string };
    console.log(`TODO: Get weather in ${payload.location}`);
  }
}

async function runAnthropicAITools(options: {
  apiKey: string,
  model: string,
  system: string,
  messages: ChatMessage[]
}): Promise<AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage> {
  const client = new AnthropicAI({
    apiKey: options.apiKey,
  });

  const res = await client.beta.tools.messages.create({
    model: "claude-3-opus-20240229",
    max_tokens: 1024,
    tools: [
      {
        name: "get_weather",
        description: "Get the current weather in a given location",
        input_schema: {
          type: "object",
          properties: {
            location: {
              type: "string",
              description: "The city and state, e.g. San Francisco, CA"
            }
          },
          required: ["location"]
        }
      }
    ],
    messages: [
      {
        role: "user",
        content: "What is the weather like in San Francisco?"
      }
    ]
  });
  return res;
}

const res = await runAnthropicAITools({ 
  apiKey: Deno.env.get("ANTHROPIC_API_KEY")!,
  model: "claude-3-opus-20240229",
  system: "tools",
  messages: [
    { role: "user", content: "What is the weather like in San Francisco?" }
  ]
});

/// mock response 用
// const { default: res }: { default: AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage } = await import("./mock.json", { with: { type: "json" } }) as any;

// res.content
for (const content of res.content) {
  if (content.type === "text") {
    console.log(`Assistant: ${content.text}`);
  }
  if (content.type === "tool_use") {
    console.log(`tool: ${content.name} input: ${JSON.stringify(content.input, null, 2)}`);
    handleTool(content);
  }
}

mizchimizchi

レスポンスの例

{
  "id": "msg_01Egc4ZovRNic9RNfJFTyfRW",
  "type": "message",
  "role": "assistant",
  "model": "claude-3-opus-20240229",
  "stop_sequence": null,
  "usage": {
    "input_tokens": 488,
    "output_tokens": 176
  },
  "content": [
    {
      "type": "text",
      "text": "<thinking>\nThe relevant tool for this request is get_weather, since the user is asking about the current weather in a specific location.\n\nget_weather requires a single parameter:\n- location: The user has provided the location \"San Francisco\". While they did not specify the state, it is reasonable to infer they are referring to San Francisco, California since that is by far the most well-known city with that name.\n\nAll of the required parameters have been provided or can be reasonably inferred, so I can proceed with the get_weather tool call.\n</thinking>"
    },
    {
      "type": "tool_use",
      "id": "toolu_014RrruiWj6jMY2Nxi8K7CQS",
      "name": "get_weather",
      "input": {
        "location": "San Francisco, CA"
      }
    }
  ],
  "stop_reason": "tool_use"
}

get_weather の tool 定義を与えて、最初の content で AIはそれを参照してどういう理由でそのツールを呼び出すかの理由を述べる。
次に tool の呼び出し引数が来る。

mizchimizchi

こちら側で Tool を AI に返すには、type: tool_resultで与えられた対応する ID に対してレスポンスする

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
      "content": "15 degrees"
    }
  ]
}

今日の天気を聞いて、それに合わせた挨拶を返して、とプロンプトを修正した。
一旦は rainy day とハードコードして、 tool_result 型でそれを返却する。
そのステップをリファクタしたコードがこちら。

import AnthropicAI from "npm:@anthropic-ai/sdk@0.20.7";

async function runAnthropicAITools(
  options:
    | Partial<AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming>
    & Pick<AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming, "messages" | "tools">
): Promise<AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage> {
  const apiKey = Deno.env.get("ANTHROPIC_API_KEY")!;
  const client = new AnthropicAI({ apiKey });
  const res = await client.beta.tools.messages.create({
    model: "claude-3-opus-20240229",
    max_tokens: 1024,
    ...options,
  });
  return res;
}

function createMessageHandler(
  initialMessages: AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming["messages"],
  handler: (content: AnthropicAI.Beta.Tools.Messages.ToolUseBlock) => Promise<AnthropicAI.Beta.Tools.Messages.ToolResultBlockParam>
) {
  const messages = initialMessages;
  return {
    current: () => messages.slice(),
    async handleResponse(res: AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage) {
      messages.push({ role: 'assistant', content: res.content });
      for (const content of res.content) {
        if (content.type === "text") {
          console.log(`Assistant: ${content.text}`);
        }
        if (content.type === "tool_use") {
          console.log(`tool: ${content.name} input: ${JSON.stringify(content.input, null, 2)}`);
          const result = await handler(content);
          messages.push({ role: 'user', content: [result] });
        }
      }
      return messages;
    }
  }
}

const TOOLS: AnthropicAI.Beta.Tools.Messages.Tool[] = [
  {
    name: "get_weather",
    description: "Get the current weather in a given location",
    input_schema: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "The city and state, e.g. San Francisco, CA"
        }
      },
      required: ["location"]
    }
  }
];

async function handleTool(
  content: AnthropicAI.Beta.Tools.Messages.ToolUseBlock
): Promise<AnthropicAI.Beta.Tools.Messages.ToolResultBlockParam> {
  if (content.name === "get_weather") {
    const payload = content.input as { location: string };
    // TODO: Get weather in ${payload.location}
    const is_error = false;
    return {
      tool_use_id: content.id,
      type: 'tool_result',
      content: [
        { type: 'text', text: 'rainy day' }
      ],
      is_error
    }
  }
  throw new Error(`Unknown tool: ${content.name}`);
}

const handler = createMessageHandler([
  { role: "user", content: "What is the weather like in San Francisco?" }
], handleTool);

const res = await runAnthropicAITools({
  tools: TOOLS,
  messages: handler.current()
});

await handler.handleResponse(res);
const _ = await runAnthropicAITools({
  tools: TOOLS,
  messages: handler.current()
});
mizchimizchi

実行結果

$ deno run -A scratch.ts
Assistant: <thinking>
The tool get_weather seems relevant for answering the user's question about the weather in San Francisco. The function takes one required parameter:
- location: a string containing the city and state
The user provided the location "San Francisco" in their query. So we have the required location parameter to make the API call.
</thinking>
Assistant: It's a rainy day in San Francisco today! Make sure to grab your umbrella if you're heading out. Despite the gloomy weather, I hope you have a wonderful day in the city by the bay.
mizchimizchi

折角なので JSONSchema から推論してハンドラを書けるようにしてみた。

lib.ts
import type AnthropicAI from "npm:@anthropic-ai/sdk@0.20.7";

export type JSONSchema = {
  type: 'string' | 'number' | 'boolean' | 'object' | 'array';
  properties?: {
    [key: string]: JSONSchema;
  };
  items?: JSONSchema;
  required?: readonly string[];
};

type ToTsType<T> = T extends { type: 'string' }
  ? string
  : T extends { type: 'number' }
  ? number
  : T extends { type: 'boolean' }
  ? boolean
  : T extends { type: 'array'; items: infer U }
  ? ToTsType<U>[]
  : T extends { type: 'object'; properties: infer P; required: infer R extends readonly string[] }
  ? { [K in keyof P]-?: K extends R[number] ? ToTsType<P[K]> : ToTsType<P[K]> | undefined }
  : never;

export type SchemaToType<T extends JSONSchema> = ToTsType<T>;

type ToolName<T> = T extends { name: infer N } ? N : never;

type ToolSchema<T> = T extends { input_schema: infer S } ? S : never;

type ToolInput<S extends JSONSchema> = SchemaToType<S>;

type ToolHandler<S extends JSONSchema> = (
  input: ToolInput<S>,
  content: AnthropicAI.Beta.Tools.Messages.ToolUseBlock
) => Promise<AnthropicAI.Beta.Tools.Messages.ToolResultBlockParam>;

export function createToolsHandler<T extends { name: string; input_schema: JSONSchema }>(
  _tools: readonly T[],
  handlers: { [K in ToolName<T>]: ToolHandler<ToolSchema<Extract<T, { name: K }>>> }
  // handlers: { [K in ToolName<T>]: ToolHandler<ToolSchema<Extract<T, { name: K }>>> }
): (content: AnthropicAI.Beta.Tools.Messages.ToolUseBlock) => Promise<AnthropicAI.Beta.Tools.Messages.ToolResultBlockParam> {
  return async (content) => {
    if (content.name in handlers) {
      const handler = handlers[content.name as ToolName<T>];
      const input = content.input as ToolInput<ToolSchema<Extract<T, { name: typeof content.name }>>>;
      return await handler(input as any, content);
    }
    throw new Error(`Unknown tool: ${content.name}`);
  };
}
run.ts
import AnthropicAI from "npm:@anthropic-ai/sdk@0.20.7";
import { createToolsHandler } from './lib.ts';

async function runAnthropicAITools(
  options:
    | Partial<AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming>
    & Pick<AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming, "messages" | "tools">
): Promise<AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage> {
  const apiKey = Deno.env.get("ANTHROPIC_API_KEY")!;
  const client = new AnthropicAI({ apiKey });
  const res = await client.beta.tools.messages.create({
    model: "claude-3-opus-20240229",
    max_tokens: 1024,
    ...options,
  });
  return res;
}

function createMessageHandler(
  initialMessages: AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming["messages"],
  handler: (content: AnthropicAI.Beta.Tools.Messages.ToolUseBlock) => Promise<AnthropicAI.Beta.Tools.Messages.ToolResultBlockParam>
) {
  const messages = initialMessages;
  return {
    current: () => messages.slice(),
    async handleResponse(res: AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage) {
      messages.push({ role: 'assistant', content: res.content });
      for (const content of res.content) {
        if (content.type === "text") {
          console.log(`Assistant: ${content.text}`);
        }
        if (content.type === "tool_use") {
          console.log(`tool: ${content.name} input: ${JSON.stringify(content.input, null, 2)}`);
          const result = await handler(content);
          messages.push({ role: 'user', content: [result] });
        }
      }
      return messages;
    }
  }
}

const TOOLS = [
  {
    name: "get_weather",
    description: "Get the current weather in a given location",
    input_schema: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "The city and state, e.g. San Francisco, CA"
        }
      },
      required: ["location"]
    }
  },
  {
    name: "get_degree",
    description: "Get the current degree in a given location",
    input_schema: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "The city and state, e.g. San Francisco, CA"
        }
      },
      required: ["location"]
    }
  }
] as const;

const toolHandler = createToolsHandler(TOOLS, {
  async get_weather(input, content) {
    const is_error = false;
    return {
      tool_use_id: content.id,
      type: 'tool_result',
      content: [
        { type: 'text', text: 'rainy day' }
      ],
      is_error
    };
  },
  async get_degree(input, content) {
    const is_error = false;
    return {
      tool_use_id: content.id,
      type: 'tool_result',
      content: [
        { type: 'text', text: 'rainy day' }
      ],
      is_error
    };
  }
});

const handler = createMessageHandler(
  [
    { role: "user", content: "What is the weather like in San Francisco? and " }
  ],
  toolHandler
);

const res = await runAnthropicAITools({
  tools: TOOLS as any as AnthropicAI.Beta.Tools.Messages.Tool[],
  messages: handler.current()
});

await handler.handleResponse(res);
const _ = await runAnthropicAITools({
  tools: TOOLS as any as AnthropicAI.Beta.Tools.Messages.Tool[],
  messages: handler.current()
});

やりたかったのはこの部分で、TOOLS の定義から推論してこのインターフェースを実装したら tool の実装が終わるようになっている

const TOOLS = [
  {
    name: "get_weather",
    description: "Get the current weather in a given location",
    input_schema: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "The city and state, e.g. San Francisco, CA"
        }
      },
      required: ["location"]
    }
  },
  {
    name: "get_degree",
    description: "Get the current degree in a given location",
    input_schema: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "The city and state, e.g. San Francisco, CA"
        }
      },
      required: ["location"]
    }
  }
] as const;

const toolHandler = createToolsHandler(TOOLS, {
  async get_weather(input, content) {
    const is_error = false;
    return {
      tool_use_id: content.id,
      type: 'tool_result',
      content: [
        { type: 'text', text: 'rainy day' }
      ],
      is_error
    };
  },
  async get_degree(input, content) {
    const is_error = false;
    return {
      tool_use_id: content.id,
      type: 'tool_result',
      content: [
        { type: 'text', text: 'rainy day' }
      ],
      is_error
    };
  }
});
mizchimizchi

複数のレスポンスがあるときの処理がおかしかったのを修正した

import AnthropicAI from "npm:@anthropic-ai/sdk@0.20.7";
import { createToolsHandler } from './lib.ts';

async function runAnthropicAITools(
  options:
    | Partial<AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming>
    & Pick<AnthropicAI.Beta.Tools.Messages.MessageCreateParamsNonStreaming, "messages" | "tools">
): Promise<AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage> {
  const apiKey = Deno.env.get("ANTHROPIC_API_KEY")!;
  const client = new AnthropicAI({ apiKey });
  const res = await client.beta.tools.messages.create({
    model: "claude-3-opus-20240229",
    max_tokens: 1024,
    ...options,
  });
  return res;
}

function createMessageHandler(
  tools: AnthropicAI.Beta.Tools.Messages.Tool[],
  handler: (content: AnthropicAI.Beta.Tools.Messages.ToolUseBlock) => Promise<AnthropicAI.Beta.Tools.Messages.ToolResultBlockParam>,
  initialMessages: AnthropicAI.Beta.Tools.Messages.ToolsBetaMessageParam[] = []
) {

  const messages = initialMessages;
  let stop_reason: "max_tokens" | "tool_use" | "end_turn" | "stop_sequence" | null = null;
  return {
    current: () => messages.slice(),
    isEnd: () => {
      // TODO: Implement the logic to determine if the conversation has ended
      if (stop_reason === 'max_tokens') return true;
      if (stop_reason === 'stop_sequence') return true;
      if (stop_reason === 'tool_use') return false;
      if (stop_reason === "end_turn") return true;
      return false;
    },
    async handleResponse(res: AnthropicAI.Beta.Tools.Messages.ToolsBetaMessage) {
      messages.push({ role: 'assistant', content: res.content });
      const toolResults: AnthropicAI.Beta.Tools.Messages.ToolResultBlockParam[] = [];
      for (const content of res.content) {
        if (content.type === "text") {
          console.log(`[Assistant] ${content.text}`);
        }
        if (content.type === "tool_use") {
          const result = await handler(content);
          console.log(`[Tool]`, result);
          toolResults.push(result);
        }
      }
      stop_reason = res.stop_reason;
      if (toolResults.length > 0) {
        messages.push({ role: 'user', content: toolResults });
      }
      return messages;
    },
  }
}

const TOOLS = [
  {
    name: "get_weather",
    description: "Get the current weather in a given location",
    input_schema: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "The city and state, e.g. San Francisco, CA"
        }
      },
      required: ["location"]
    }
  },
  {
    name: "get_degree",
    description: "Get the current degree in a given location",
    input_schema: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "The city and state, e.g. San Francisco, CA"
        }
      },
      required: ["location"]
    }
  }
] as const;

const toolHandler = createToolsHandler(TOOLS, {
  async get_weather(input, content) {
    const is_error = false;
    return {
      tool_use_id: content.id,
      type: 'tool_result',
      content: [
        { type: 'text', text: 'rainy day' }
      ],
      is_error
    };
  },
  async get_degree(input, content) {
    const is_error = false;
    return {
      tool_use_id: content.id,
      type: 'tool_result',
      content: [
        { type: 'text', text: '15 degree' }
      ],
      is_error
    };
  }
});

const handler = createMessageHandler(
  TOOLS as any as AnthropicAI.Beta.Tools.Messages.Tool[],
  toolHandler,
  [
    {
      role: 'user',
      content: "What's the weather and degree in San Francisco? Say greeting messsage by weather and degree."
    }
  ]
);

while (!handler.isEnd()) {
  const res = await runAnthropicAITools({
    tools: TOOLS as any,
    messages: handler.current()
  });
  await handler.handleResponse(res);
}