はじめに
前回の記事の続きです。今回は、Tool の結果を戻して終わるまで繰り返す Agent Loop にします。
前回は getSchedule、getWeather、getRoute を順に呼び、18:20 に出ると答えました。繰り返しは、5 回までの for でした。
Agentic AIを学びたい(Step4:getRouteで出発時刻を決める)
今回変えたのは、回数を先に決めるのをやめたことです。while (true) で LLM を呼び、応答を2種類に分けます。
tool_call Tool を実行し、結果を会話に足して続きを聞く
final 本文が返ったので、それを回答にして終わる
入力も Tool も前回と同じです。今回の実行は 4 回で、final が返って終わりました。8 回を超えても止まります。
成果物
npm start
> start
> tsx src/index.ts
モデル: openai/gpt-4o-mini
--- User Prompt ---
今日どう動けばいい?
--- LLM Response ---
{
"role": "assistant",
"content": null,
"refusal": null,
"reasoning": null,
"tool_calls": [
{
"type": "function",
"index": 0,
"id": "call_nYOUKmOp4shDuL7z1I1wJomj",
"function": {
"name": "getSchedule",
"arguments": "{\"date\":\"今日\"}"
}
}
]
}
--- Tool Calls ---
[
{
"type": "function",
"index": 0,
"id": "call_nYOUKmOp4shDuL7z1I1wJomj",
"function": {
"name": "getSchedule",
"arguments": "{\"date\":\"今日\"}"
}
}
]
--- 1回目のLLM ---
取り出し元: choices[0].message.tool_calls
getSchedule({"date":"今日"})
--- Tool Result ---
{
"date": "今日",
"events": [
{
"time": "19:00",
"location": "梅田",
"title": "食事"
}
]
}
--- LLM Response ---
{
"role": "assistant",
"content": null,
"refusal": null,
"reasoning": null,
"tool_calls": [
{
"type": "function",
"index": 0,
"id": "call_ozMaR40NmvdEmTPWqpJx9gCc",
"function": {
"name": "getWeather",
"arguments": "{\"location\":\"梅田\",\"time\":\"19:00\"}"
}
}
]
}
--- Tool Calls ---
[
{
"type": "function",
"index": 0,
"id": "call_ozMaR40NmvdEmTPWqpJx9gCc",
"function": {
"name": "getWeather",
"arguments": "{\"location\":\"梅田\",\"time\":\"19:00\"}"
}
}
]
--- 2回目のLLM ---
取り出し元: choices[0].message.tool_calls
getWeather({"location":"梅田","time":"19:00"})
--- Tool Result ---
{
"location": "梅田",
"time": "19:00",
"forecast": "18:00から雨"
}
--- LLM Response ---
{
"role": "assistant",
"content": null,
"refusal": null,
"reasoning": null,
"tool_calls": [
{
"type": "function",
"index": 0,
"id": "call_8OO8gsIpUtBCV4bct4DCgYoC",
"function": {
"name": "getRoute",
"arguments": "{\"origin\":\"自宅\",\"destination\":\"梅田\"}"
}
}
]
}
--- Tool Calls ---
[
{
"type": "function",
"index": 0,
"id": "call_8OO8gsIpUtBCV4bct4DCgYoC",
"function": {
"name": "getRoute",
"arguments": "{\"origin\":\"自宅\",\"destination\":\"梅田\"}"
}
}
]
--- 3回目のLLM ---
取り出し元: choices[0].message.tool_calls
getRoute({"origin":"自宅","destination":"梅田"})
--- Tool Result ---
{
"origin": "自宅",
"destination": "梅田",
"duration": "30分"
}
--- LLM Response ---
{
"role": "assistant",
"content": "予定は19:00に梅田での食事です。雨が予想されるため、傘が必要です。家を出る時刻は18:20です。",
"refusal": null,
"reasoning": null
}
--- Tool Calls ---
[]
--- 回答 ---
終了条件: tool_calls がなく、本文が返った
取り出し元: choices[0].message.content
予定は19:00に梅田での食事です。雨が予想されるため、傘が必要です。家を出る時刻は18:20です。
シーケンス図
while (true)
LLMを呼ぶ
↓
tool_call ?
はい → Tool実行 → 結果を会話に足す → 先頭へ
いいえ → final。本文を表示して return
終了条件
1. type が final
2. 8回を超えた
今回は 1 で止まっています。予定、天気、移動、回答の 4 回です。
ディレクトリ構成
新しいファイルはありません。変わったのは src/agent/runtime.ts です。
agentic_ai_study/
├─ src/
│ ├─ index.ts
│ ├─ env.ts
│ ├─ agent/
│ │ ├─ prompts.ts
│ │ └─ runtime.ts while と final / tool_call
│ ├─ llm/
│ │ ├─ openrouter.ts
│ │ └─ types.ts
│ └─ tools/
│ ├─ schedule.ts
│ ├─ weather.ts
│ ├─ route.ts
│ └─ execute.ts
├─ .env
├─ package.json
└─ tsconfig.json
npm start
└─ tsx src/index.ts
└─ agent/runtime.ts
└─ while (true)
コード
変わった src/agent/runtime.ts だけ載せます。
import { requireEnv } from "../env.js";
import { callOpenRouter } from "../llm/openrouter.js";
import type { AssistantReply, ChatMessage, ChatResponse, ToolCall } from "../llm/types.js";
import { executeTool, tools } from "../tools/execute.js";
import { SYSTEM_PROMPT, USER_PROMPT } from "./prompts.js";
const MAX_TURNS = 8;
type AgentResponse =
| { type: "final"; content: string }
| { type: "tool_call"; calls: ToolCall[] };
function readMessage(response: ChatResponse): AssistantReply {
const message = response.choices?.[0]?.message;
if (!message) {
throw new Error("応答に choices[0].message がありません");
}
return message;
}
function toAgentResponse(message: AssistantReply): AgentResponse {
const calls = message.tool_calls ?? [];
if (calls.length > 0) {
return { type: "tool_call", calls };
}
if (!message.content) {
throw new Error("応答に tool_calls も content もありません");
}
return { type: "final", content: message.content };
}
export async function run(): Promise<void> {
console.log("モデル:", requireEnv("MODEL_NAME"));
console.log("\n--- User Prompt ---\n");
console.log(USER_PROMPT);
const messages: ChatMessage[] = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: USER_PROMPT },
];
let turn = 0;
while (true) {
turn += 1;
if (turn > MAX_TURNS) {
throw new Error(`終了条件: ${MAX_TURNS}回を超えたため終了しました`);
}
const message = readMessage(await callOpenRouter(messages, tools));
console.log("\n--- LLM Response ---");
console.log(JSON.stringify(message, null, 2));
console.log("\n--- Tool Calls ---");
console.log(JSON.stringify(message.tool_calls ?? [], null, 2));
const response = toAgentResponse(message);
if (response.type === "final") {
console.log("\n--- 回答 ---");
console.log("終了条件: tool_calls がなく、本文が返った");
console.log("取り出し元: choices[0].message.content\n");
console.log(response.content);
return;
}
console.log(`\n--- ${turn}回目のLLM ---`);
console.log("取り出し元: choices[0].message.tool_calls\n");
messages.push({
role: "assistant",
content: message.content ?? null,
tool_calls: response.calls,
});
for (const call of response.calls) {
const args = JSON.parse(call.function.arguments) as unknown;
const result = executeTool(call);
console.log(`${call.function.name}(${JSON.stringify(args)})`);
console.log("\n--- Tool Result ---");
console.log(JSON.stringify(result, null, 2));
messages.push({
role: "tool",
tool_call_id: call.id,
name: call.function.name,
content: JSON.stringify(result),
});
}
}
}