はじめに
前回の記事の続きです。今回は、取得済みの情報を Agent の State に持たせます。
前回は while (true) で、tool_call なら Tool を実行し、final なら本文で終わっていました。
Agentic AIを学びたい(Step5:Agent Loopで終わるまで繰り返す)
本文が返っただけでは終わらせません。予定・天気・移動の三つが State 上で取得済みのときだけ、回答として終了します。取得済みの Tool は、もう一度呼ばれても実行しません。
毎ターン、その時点の State を System Prompt のうしろに足して LLM に渡します。古い State は会話に残しません。
今回の実行では、State が次のように進みました。
予定は未取得 / 天気は未取得 / 移動は未取得
予定は取得済み / 天気は未取得 / 移動は未取得
予定は取得済み / 天気は取得済み / 移動は未取得
予定は取得済み / 天気は取得済み / 移動は取得済み
三つが取得済みになってから、傘と 18:20 出発の本文が返って終了しました。
成果物
npm start
> start
> tsx src/index.ts
モデル: openai/gpt-4o-mini
--- User Prompt ---
今日どう動けばいい?
--- State ---
予定は未取得 / 天気は未取得 / 移動は未取得
{
"scheduleChecked": false,
"weatherChecked": false,
"routeChecked": false,
"destination": null,
"eventTime": null,
"weather": null,
"duration": null
}
--- 1回目のLLM ---
getSchedule({"date":"今日"})
--- Tool Result ---
19:00 梅田で食事
--- State ---
予定は取得済み / 天気は未取得 / 移動は未取得
{
"scheduleChecked": true,
"weatherChecked": false,
"routeChecked": false,
"destination": "梅田",
"eventTime": "19:00",
"weather": null,
"duration": null
}
--- 2回目のLLM ---
getWeather({"location":"梅田","time":"19:00"})
--- Tool Result ---
18:00から雨
--- State ---
予定は取得済み / 天気は取得済み / 移動は未取得
{
"scheduleChecked": true,
"weatherChecked": true,
"routeChecked": false,
"destination": "梅田",
"eventTime": "19:00",
"weather": "18:00から雨",
"duration": null
}
--- 3回目のLLM ---
getRoute({"origin":"自宅","destination":"梅田"})
--- Tool Result ---
30分
--- State ---
予定は取得済み / 天気は取得済み / 移動は取得済み
{
"scheduleChecked": true,
"weatherChecked": true,
"routeChecked": true,
"destination": "梅田",
"eventTime": "19:00",
"weather": "18:00から雨",
"duration": "30分"
}
--- 回答 ---
終了条件: 予定・天気・移動が揃い、本文が返った
予定は19:00に梅田で食事です。18:00から雨が予想されているので、傘が必要です。家を出る時刻は18:20です。
シーケンス図
1回目の前
予定は未取得 / 天気は未取得 / 移動は未取得
getSchedule
destination: 梅田
eventTime: 19:00
2回目の前
予定は取得済み / 天気は未取得 / 移動は未取得
getWeather
weather: 18:00から雨
3回目の前
予定は取得済み / 天気は取得済み / 移動は未取得
getRoute
duration: 30分
4回目
三つとも取得済み
final
傘と 18:20 出発
終了条件は次の2つです。
1. 予定・天気・移動が取得済みで、本文が返った
2. 8回を超えた
本文だけが先に返っても、State が揃っていなければ終了しません。
ディレクトリ構成
増えたのは src/agent/state.ts です。
agentic_ai_study/
├─ src/
│ ├─ index.ts
│ ├─ env.ts
│ ├─ agent/
│ │ ├─ prompts.ts 未取得の Tool だけ呼ぶ指示
│ │ ├─ state.ts 取得済みと、場所・時刻・天気・移動時間
│ │ └─ runtime.ts State を渡す、再実行防止、終了判定
│ ├─ 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
├─ agent/state.ts
└─ while (true)
コード
変わったファイルを載せます。
src/agent/state.ts
import type { RouteResult } from "../tools/route.js";
import type { ScheduleResult } from "../tools/schedule.js";
import type { WeatherResult } from "../tools/weather.js";
export type AgentState = {
scheduleChecked: boolean;
weatherChecked: boolean;
routeChecked: boolean;
destination: string | null;
eventTime: string | null;
weather: string | null;
duration: string | null;
};
export function createState(): AgentState {
return {
scheduleChecked: false,
weatherChecked: false,
routeChecked: false,
destination: null,
eventTime: null,
weather: null,
duration: null,
};
}
export function isReadyToAnswer(state: AgentState): boolean {
return state.scheduleChecked && state.weatherChecked && state.routeChecked;
}
export function alreadyChecked(state: AgentState, name: string): boolean {
switch (name) {
case "getSchedule":
return state.scheduleChecked;
case "getWeather":
return state.weatherChecked;
case "getRoute":
return state.routeChecked;
default:
return false;
}
}
export function describeState(state: AgentState): string {
const mark = (checked: boolean) => (checked ? "取得済み" : "未取得");
return [
`予定は${mark(state.scheduleChecked)}`,
`天気は${mark(state.weatherChecked)}`,
`移動は${mark(state.routeChecked)}`,
].join(" / ");
}
export function applyToolResult(state: AgentState, name: string, result: unknown): void {
if (name === "getSchedule") {
const schedule = result as ScheduleResult;
const event = schedule.events[0];
state.scheduleChecked = true;
state.eventTime = event?.time ?? null;
state.destination = event?.location ?? null;
return;
}
if (name === "getWeather") {
const weather = result as WeatherResult;
state.weatherChecked = true;
state.weather = weather.forecast;
return;
}
if (name === "getRoute") {
const route = result as RouteResult;
state.routeChecked = true;
state.duration = route.duration;
}
}
export function skippedResult(state: AgentState, name: string): unknown {
return {
skipped: true,
reason: "取得済みのため再実行しない",
tool: name,
state,
};
}
src/agent/prompts.ts
export const USER_PROMPT = "今日どう動けばいい?";
export const SYSTEM_PROMPT = `あなたは外出前の準備を手伝うアシスタントです。
まず予定を getSchedule で確認してください。
予定に外出先と時刻がある場合は、次に getWeather でその場所と時刻の天気を確認してください。
出発地は自宅です。目的地が分かったら getRoute で移動時間を確認してください。
一度の応答では Tool を1つだけ呼んでください。
各ターンで渡す State を見て、未取得の Tool だけ呼んでください。取得済みの Tool は呼ばないでください。
予定も天気も移動時間も、Tool の結果が出るまで推測してはいけません。
結果が揃ったら、予定、傘の要否、家を出る時刻を日本語で短く答えてください。
出発時刻は、予定の時刻から移動時間を引き、さらに10分早く出てください。`;
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";
import {
alreadyChecked,
applyToolResult,
createState,
describeState,
isReadyToAnswer,
skippedResult,
} from "./state.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 },
];
const state = createState();
let turn = 0;
while (true) {
turn += 1;
if (turn > MAX_TURNS) {
throw new Error(`終了条件: ${MAX_TURNS}回を超えたため終了しました`);
}
console.log("\n--- State ---");
console.log(describeState(state));
console.log(JSON.stringify(state, null, 2));
const [system, ...history] = messages;
const message = readMessage(
await callOpenRouter(
[
{
role: "system",
content: `${system?.content ?? ""}\n\n現在のState:\n${JSON.stringify(state, null, 2)}\ntrue の項目は取得済みです。未取得のものだけ呼び、三つとも true なら回答してください。`,
},
...history,
],
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") {
if (!isReadyToAnswer(state)) {
console.log("\n終了しない: State がまだ揃っていない");
messages.push({ role: "assistant", content: response.content });
messages.push({
role: "user",
content: "State を見て、未取得の情報を Tool で取得してください。",
});
continue;
}
console.log("\n--- 回答 ---");
console.log("終了条件: 予定・天気・移動が揃い、本文が返った");
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 skipped = alreadyChecked(state, call.function.name);
const result = skipped ? skippedResult(state, call.function.name) : executeTool(call);
if (skipped) {
console.log(`再実行をスキップ: ${call.function.name}`);
} else {
applyToolResult(state, call.function.name, result);
console.log(`${call.function.name}(${JSON.stringify(args)})`);
}
console.log("\n--- Tool Result ---");
console.log(JSON.stringify(result, null, 2));
console.log("\n--- State ---");
console.log(describeState(state));
console.log(JSON.stringify(state, null, 2));
messages.push({
role: "tool",
tool_call_id: call.id,
name: call.function.name,
content: JSON.stringify(result),
});
}
}
}