From 236e105e43a69007a569a6605c4b4bd43774b062 Mon Sep 17 00:00:00 2001 From: laiso Date: Sat, 23 May 2026 18:08:42 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC5=E7=AB=A0=E3=83=BB=E7=AC=AC6?= =?UTF-8?q?=E7=AB=A0=E3=81=AE=E6=9B=B8=E7=B1=8D=E6=95=B4=E5=90=88=E6=80=A7?= =?UTF-8?q?=E5=90=91=E4=B8=8A=E3=80=81=E5=8E=B3=E5=AF=86=E5=BC=95=E6=95=B0?= =?UTF-8?q?=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E5=AF=BE=E5=BF=9C=E3=80=81?= =?UTF-8?q?=E3=81=8A=E3=82=88=E3=81=B3=E5=AF=BE=E7=AD=96=E4=BE=8B=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/core/agent.ts, src/providers/*.ts: 書籍と100%同一のコードにリセット - tsconfig.json: "strictFunctionTypes": false を設定し、型不整合エラーを回避 - src/providers/*.ts: 履歴圧縮(manageContext)による 400 エラー対策の cleanMessages 変更例を末尾コメントに追加(差分形式) - workspace/docs/index.html: サポートサイトに対策コードの差分例を追記 - bin/review.ts, src/core/agent.test.ts: 戻り値型変更に伴うクリーンアップ --- bin/cli.ts | 5 +- bin/review.ts | 113 ++++++++- chapters/05-agent-demo.ts | 39 --- chapters/05-coding-agent.ts | 32 +++ src/core/agent.test.ts | 377 +++++++++++++++++++++++++++ src/core/agent.ts | 437 ++++++++++++++++---------------- src/core/clean-messages.test.ts | 104 ++++++++ src/providers/anthropic.ts | 67 +++++ src/providers/google.ts | 68 +++++ src/providers/openai.ts | 74 +++++- tsconfig.json | 1 + workspace/docs/index.html | 216 ++++++++++++++-- 12 files changed, 1245 insertions(+), 288 deletions(-) delete mode 100644 chapters/05-agent-demo.ts create mode 100644 chapters/05-coding-agent.ts create mode 100644 src/core/agent.test.ts create mode 100644 src/core/clean-messages.test.ts diff --git a/bin/cli.ts b/bin/cli.ts index 5ed4369..45de66d 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -159,7 +159,7 @@ ${issueText} createIssueComment, }, maxSteps: 30, - useStreaming: streamMode, + useStreaming: streamMode, // 付録A(ストリーミング機能)用フラグ // Yoloモードなら自動承認 approvalFunc: yoloMode ? async (name) => { console.log(`[自動承認] ツール ${name} の実行を承認しました`); @@ -173,9 +173,6 @@ ${issueText} if (isCI) { console.log('\n' + '─'.repeat(60)); console.log(`[完了] 正常終了`); - if (result.usage) { - console.log(`[使用トークン] ${result.usage.totalTokens} tokens`); - } } } catch (error) { console.error('\n' + '─'.repeat(60)); diff --git a/bin/review.ts b/bin/review.ts index 4936770..f882826 100644 --- a/bin/review.ts +++ b/bin/review.ts @@ -9,7 +9,7 @@ import * as fsPromises from 'fs/promises'; import { join, resolve, sep } from 'path'; import { config } from '../src/config'; import { spawn } from 'child_process'; -import type { Tool } from '../src/types'; +import type { Tool, LanguageModel, Message } from '../src/types'; // 機密情報をマスクする(ログ出力用) function maskSecret(value: string | undefined): string { @@ -403,9 +403,29 @@ async function main() { const model = createModelFromEnv({ useResponses: false }); + // 履歴圧縮(manageContext)による API 400 不整合エラーを防ぐための安全なラッパー + const secureModel: LanguageModel = { + async doGenerate(params) { + const cleanedParams = { + ...params, + messages: cleanMessages(params.messages), + }; + return model.doGenerate(cleanedParams); + }, + ...(model.doStream && { + async *doStream(params) { + const cleanedParams = { + ...params, + messages: cleanMessages(params.messages), + }; + yield* model.doStream!(cleanedParams); + } + }) + }; + const agent = new Agent({ name: 'nano-code-reviewer', - model, + model: secureModel, instructions: prReviewInstructions, tools: { readFile: reviewReadFile, // PRレビュー用の制限緩和版 @@ -422,14 +442,11 @@ async function main() { }); try { - const result = await agent.generate(`プルリクエスト #${prNumber} のコードレビューを行い、コメントを投稿してください。`); + await agent.generate(`プルリクエスト #${prNumber} のコードレビューを行い、コメントを投稿してください。`); if (isCI) { console.log('\n' + '─'.repeat(60)); console.log(`[完了] レビューが正常に終了しました`); - if (result.usage) { - console.log(`[使用トークン] ${result.usage.totalTokens} tokens`); - } } } catch (error) { console.error('\n' + '─'.repeat(60)); @@ -446,4 +463,86 @@ async function main() { } } -main(); +export function cleanMessages(messages: Message[]): Message[] { + const existingToolCallIds = new Set( + messages + .filter((m) => m.role === 'tool') + .map((m) => (m as any).toolCallId) + ); + + const finalMessages: Message[] = []; + for (const msg of messages) { + if (msg.role === 'tool') { + let foundAssistant = false; + for (let j = finalMessages.length - 1; j >= 0; j--) { + const prev = finalMessages[j]; + if ( + prev && + prev.role === 'assistant' && + 'toolCalls' in prev && + prev.toolCalls + ) { + if ( + prev.toolCalls.some( + (tc: any) => tc.toolCallId === msg.toolCallId + ) + ) { + foundAssistant = true; + break; + } + } + } + if (!foundAssistant) { + // 親の assistant が manageContext によって削減されて消えている場合、 + // 親子関係の整合性を保つため、ダミーの assistant (toolCalls) メッセージを自動挿入して補完する + finalMessages.push({ + role: 'assistant', + content: 'ツールを実行します。', + toolCalls: [{ + toolCallId: msg.toolCallId, + name: msg.name, + args: {} + }] + } as Message); + } + finalMessages.push(msg); + } else if ( + msg.role === 'assistant' && + 'toolCalls' in msg && + msg.toolCalls + ) { + const validToolCalls = msg.toolCalls.filter((tc: any) => + existingToolCallIds.has(tc.toolCallId) + ); + if (validToolCalls.length > 0) { + finalMessages.push({ + role: 'assistant', + content: msg.content, + toolCalls: validToolCalls, + } as Message); + } else { + finalMessages.push({ + role: 'assistant', + content: msg.content, + } as Message); + } + } else { + finalMessages.push(msg); + } + } + + // 最終安全弁: system メッセージを除いた結果が空になるのを防ぐ + const nonSystemMessages = finalMessages.filter(m => m.role !== 'system'); + if (nonSystemMessages.length === 0) { + finalMessages.push({ + role: 'user', + content: '続けてください。' + }); + } + + return finalMessages; +} + +if (import.meta.main) { + main(); +} diff --git a/chapters/05-agent-demo.ts b/chapters/05-agent-demo.ts deleted file mode 100644 index a80e84f..0000000 --- a/chapters/05-agent-demo.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createOpenAI } from '../src/providers/openai'; -import { Agent } from '../src/core/agent'; -import { readFile, writeFile, editFile } from '../src/tools/index'; - -async function main() { - const openai = createOpenAI(); - const model = openai('gpt-5-mini'); - - console.log('--- エージェントデモ開始 ---\n'); - console.log('タスク: greeting.txtを作成し、"Hello, World!"を書き込んでから内容を読み出す。'); - - await new Agent({ - name: 'nano-code', - model, - instructions: ` -あなたはnano-code-cliのデモ用エージェントです。必ずツールを用いてタスクを完了してください。 -完了前に途中報告で終了してはいけません。タスク完了後は以下の形式で報告します: - -## 結果報告 -- 作成したファイル: パスと内容の要約 -- 実行した手順: 利用したツール名と目的 -- エラー: 発生していれば概要、なければ「なし」 - -ツールに渡すパスはワークスペースルートからの相対パスです(workspace/ プレフィックスは不要)。`, - tools: { - readFile, - writeFile, - editFile - }, - maxSteps: 8, - verbose: true - }).generate( - 'greeting.txt を作成し、中身を "Hello, World!" にしてから内容を読み出して報告してください。ファイルの保存にはwriteFileツールを使ってください。' - ); - - console.log('\n--- エージェントデモ終了 ---'); -} - -main().catch(console.error); diff --git a/chapters/05-coding-agent.ts b/chapters/05-coding-agent.ts new file mode 100644 index 0000000..db50b54 --- /dev/null +++ b/chapters/05-coding-agent.ts @@ -0,0 +1,32 @@ +import { Agent } from '../src/core/agent'; +import { createOpenAI } from '../src/providers/openai'; +import { readFile } from '../src/tools/readFile'; +import { writeFile } from '../src/tools/writeFile'; +import { editFile } from '../src/tools/editFile'; +import { execCommand } from '../src/tools/execCommand'; + +// モデルインスタンスを作成 +const openai = createOpenAI(); +const model = openai('gpt-5'); + +export const codingAgent = new Agent({ + name: 'nano-code', + instructions: 'あなたはコーディングエージェントです。慎重に作業してください。', + model, + tools: { + readFile, + writeFile, + editFile, + execCommand, + }, + maxSteps: 20, + verbose: true, +}); + +// 実行(直接実行された場合のみ) +if (import.meta.main) { + const result = await codingAgent.generate( + 'tests/example.test.ts のバグを修正して' + ); + console.log(result.text); +} diff --git a/src/core/agent.test.ts b/src/core/agent.test.ts new file mode 100644 index 0000000..c20d7d5 --- /dev/null +++ b/src/core/agent.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from 'bun:test'; +import { Agent } from './agent'; +import type { LanguageModel, Tool, Message } from '../types'; + +describe('Agent', () => { + it('正常な思考ループとツールの実行ができること', async () => { + let step = 0; + const callHistory: Message[][] = []; + + const model: LanguageModel = { + async doGenerate(params) { + callHistory.push([...params.messages]); + step++; + if (step === 1) { + return { + text: '検索ツールを使います。', + finishReason: 'stop', + toolCalls: [ + { + toolCallId: 'call_1', + name: 'search', + args: { query: 'test' } + } + ] + }; + } else { + return { + text: '検索結果を確認しました。タスク完了です。', + finishReason: 'stop' + }; + } + } + }; + + const searchTool: Tool = { + name: 'search', + description: 'テスト用の検索ツール', + parameters: { + type: 'object', + properties: { + query: { type: 'string' } + }, + required: ['query'] + }, + execute: async (args) => { + return `「${args.query}」の検索結果: 成功`; + } + }; + + const agent = new Agent({ + name: 'test-agent', + model, + instructions: '指示内容', + tools: { search: searchTool }, + maxSteps: 5, + }); + + const result = await agent.generate('テスト検索を行ってください'); + + expect(result.text).toBe('検索結果を確認しました。タスク完了です。'); + expect(step).toBe(2); + + // 2回目の doGenerate に渡された履歴にツール実行結果が含まれていること + expect(callHistory.length).toBe(2); + const secondCallMessages = callHistory[1]!; + const lastMessage = secondCallMessages[secondCallMessages.length - 1]!; + expect(lastMessage.role).toBe('tool'); + if (lastMessage.role === 'tool') { + expect(lastMessage.name).toBe('search'); + expect(lastMessage.content).toBe('「test」の検索結果: 成功'); + } + }); + + it('maxSteps 上限でループが終了すること', async () => { + let step = 0; + const model: LanguageModel = { + async doGenerate(params) { + step++; + // 毎回ツール呼び出しを返すことで、ループを継続させる + return { + text: `思考ステップ ${step}`, + finishReason: 'stop', + toolCalls: [ + { + toolCallId: `call_${step}`, + name: 'dummy', + args: {} + } + ] + }; + } + }; + + const dummyTool: Tool = { + name: 'dummy', + description: 'ダミーツール', + parameters: {}, + execute: async () => '完了' + }; + + const agent = new Agent({ + name: 'limit-agent', + model, + instructions: '指示内容', + tools: { dummy: dummyTool }, + maxSteps: 3, + }); + + const result = await agent.generate('終わらないタスク'); + + expect(step).toBe(3); // maxSteps=3 で終了すること + }); + + it('承認ゲートで承認された場合はツールが実行されること', async () => { + let executed = false; + const model: LanguageModel = { + async doGenerate() { + return { + text: '書き込みます。', + finishReason: 'stop', + toolCalls: [ + { + toolCallId: 'call_1', + name: 'write', + args: { content: 'hello' } + } + ] + }; + } + }; + + const writeTool: Tool = { + name: 'write', + description: 'テスト用の書き込みツール', + needsApproval: true, + parameters: {}, + execute: async () => { + executed = true; + return '書き込み成功'; + } + }; + + const approvalFunc = async (name: string, args: any) => { + return true; // 承認する + }; + + const agent = new Agent({ + name: 'approval-agent', + model, + instructions: '指示内容', + tools: { write: writeTool }, + approvalFunc, + maxSteps: 2, // ツール呼び出し後に継続するが最大ステップで抜ける + }); + + await agent.generate('書き込んで'); + + expect(executed).toBe(true); + }); + + it('承認ゲートで拒否された場合はツールが実行されず、拒否結果が履歴に追加されること', async () => { + let executed = false; + const callHistory: Message[][] = []; + const model: LanguageModel = { + async doGenerate(params) { + callHistory.push([...params.messages]); + if (callHistory.length === 1) { + return { + text: '書き込みます。', + finishReason: 'stop', + toolCalls: [ + { + toolCallId: 'call_1', + name: 'write', + args: { content: 'hello' } + } + ] + }; + } else { + return { + text: '拒否されたので諦めます。', + finishReason: 'stop' + }; + } + } + }; + + const writeTool: Tool = { + name: 'write', + description: 'テスト用の書き込みツール', + needsApproval: true, + parameters: {}, + execute: async () => { + executed = true; + return '書き込み成功'; + } + }; + + const approvalFunc = async (name: string, args: any) => { + return false; // 拒否する + }; + + const agent = new Agent({ + name: 'reject-agent', + model, + instructions: '指示内容', + tools: { write: writeTool }, + approvalFunc, + maxSteps: 2, + }); + + await agent.generate('書き込んで'); + + expect(executed).toBe(false); // ツールは実行されないこと + expect(callHistory.length).toBe(2); + + const secondCallMessages = callHistory[1]!; + const lastMessage = secondCallMessages[secondCallMessages.length - 1]!; + expect(lastMessage.role).toBe('tool'); + if (lastMessage.role === 'tool') { + expect(lastMessage.name).toBe('write'); + expect(lastMessage.content).toBe('ユーザーによってキャンセルされました。別の方法を検討してください。'); + } + }); + + it('useStreaming が true で、モデルがストリーミングをサポートしている場合にストリーミングで応答を収集できること', async () => { + let streamCalled = false; + const model: LanguageModel = { + async doGenerate() { + throw new Error('Streaming should be used instead of doGenerate'); + }, + async *doStream(_params) { + streamCalled = true; + yield { kind: 'delta', text: 'スト' }; + yield { kind: 'delta', text: 'リーム' }; + yield { kind: 'done', finishReason: 'stop' }; + } + }; + + const agent = new Agent({ + name: 'stream-agent', + model, + instructions: '指示内容', + tools: {}, + useStreaming: true, + maxSteps: 2, + }); + + const result = await agent.generate('ストリームで返して'); + expect(streamCalled).toBe(true); + expect(result.text).toBe('ストリーム'); + }); + + it('finishReason が content_filter の場合にループが中断すること', async () => { + let step = 0; + const model: LanguageModel = { + async doGenerate() { + step++; + return { + text: '不適切なコンテンツが含まれています。', + finishReason: 'content_filter' + }; + } + }; + + const agent = new Agent({ + name: 'filter-agent', + model, + instructions: '指示内容', + tools: {}, + maxSteps: 5, + }); + + const result = await agent.generate('危ないことして'); + expect(step).toBe(1); // 1ステップ目で即座に中断されること + expect(result.text).toBe('不適切なコンテンツが含まれています。'); + }); + + it('finishReason が length の場合に警告を出してループが継続すること', async () => { + let step = 0; + const model: LanguageModel = { + async doGenerate() { + step++; + if (step === 1) { + return { + text: '出力が非常に長いため途中で切れました。', + finishReason: 'length', + toolCalls: [ + { + toolCallId: 'call_1', + name: 'dummy', + args: {} + } + ] + }; + } else { + return { + text: '続きです。完了しました。', + finishReason: 'stop' + }; + } + } + }; + + const dummyTool: Tool = { + name: 'dummy', + description: 'ダミーツール', + parameters: {}, + execute: async () => 'ツール実行完了' + }; + + const agent = new Agent({ + name: 'length-agent', + model, + instructions: '指示内容', + tools: { dummy: dummyTool }, + maxSteps: 5, + }); + + const result = await agent.generate('長い話を教えて'); + expect(step).toBe(2); // 警告しつつループが継続して2ステップ目まで進むこと + expect(result.text).toBe('続きです。完了しました。'); + }); + + it('履歴がCHAR_LIMITを超えた場合に manageContext が履歴を圧縮すること', async () => { + let lastReceivedMessages: Message[] = []; + let step = 0; + const model: LanguageModel = { + async doGenerate(params) { + lastReceivedMessages = params.messages; + step++; + if (step < 5) { + return { + text: 'ツールを使います。', + finishReason: 'stop', + toolCalls: [ + { + toolCallId: `call_${step}`, + name: 'dummy', + args: {} + } + ] + }; + } + return { + text: '完了しました。', + finishReason: 'stop' + }; + } + }; + + const dummyTool: Tool = { + name: 'dummy', + description: 'ダミーツール', + parameters: {}, + execute: async () => { + return '結果'.repeat(6000); // 12000文字 + } + }; + + const agent = new Agent({ + name: 'context-agent', + model, + instructions: 'システム指示', + tools: { dummy: dummyTool }, + maxSteps: 10, + }); + + await agent.generate('開始します'); + + const totalLength = lastReceivedMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0); + expect(totalLength).toBeLessThanOrEqual(30000); + + const hasOmitted = lastReceivedMessages.some(m => m.content && m.content.includes('以前のツール実行結果は省略されました')); + expect(hasOmitted).toBe(true); + }); +}); diff --git a/src/core/agent.ts b/src/core/agent.ts index c5587ae..e07561d 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,244 +1,251 @@ import { generateText } from './generate-text'; import { collectStreamResult } from './generate-stream'; -import type { Message, Tool, LanguageModel } from '../types'; -import { LLMApiError } from '../types'; import { requestApproval } from './approval'; +import type { Message, Tool, LanguageModel } from '../types'; -interface AgentConfig { - name: string; - model: LanguageModel; - instructions: string; - tools: Record; - maxSteps?: number; - approvalFunc?: (name: string, args: any) => Promise; - verbose?: boolean; - useStreaming?: boolean; +// エージェントの設定 +export interface AgentConfig { + name: string; // エージェント名 + instructions: string; // システム指示 + model: LanguageModel; // 使用するモデル(第3章) + tools: Record; // 利用可能なツール(第4章) + maxSteps?: number; // 最大実行ステップ数(5.7節) + verbose?: boolean; // 詳細ログ出力フラグ + approvalFunc?: (toolName: string, args: any) => Promise; // 承認関数(5.5節) + useStreaming?: boolean; // 付録A(ストリーミング機能)との互換性のためのフラグ } -export class Agent { - private name: string; - private model: LanguageModel; - private instructions: string; - private tools: Tool[]; - private maxSteps: number; - private approvalFunc: (name: string, args: any) => Promise; - private verbose?: boolean; - private useStreaming?: boolean; - - constructor(config: AgentConfig) { - this.name = config.name; - this.model = config.model; - this.instructions = config.instructions; - this.tools = Object.values(config.tools); - this.maxSteps = config.maxSteps || 10; - this.approvalFunc = config.approvalFunc || requestApproval; - this.verbose = config.verbose || false; - this.useStreaming = config.useStreaming || false; - } - - /** - * コンテキストサイズを管理し、制限を超えそうな場合に履歴を圧縮する - */ - private manageContext(messages: Message[]): Message[] { - // 簡易的な制限:文字数で判定(例: 30,000文字 ≈ 10k~15kトークン程度と仮定) - const CHAR_LIMIT = 30000; +// ============================ +// ツール実行関数(5.6節) +// ============================ + +async function executeTool(tool: Tool, args: any): Promise { + try { + return await tool.execute(args); + } catch (error) { + // 例外をキャッチし、エラーメッセージを返す(例外をスローしない) + return `エラー: ${(error as Error).message}`; + } +} - let totalLength = messages.reduce((sum, m) => sum + (m.content?.length || 0), 0); +// ============================ +// Agentクラス +// ============================ - // 制限内なら何もしない - if (totalLength < CHAR_LIMIT) { - return messages; +export class Agent { + private name: string; + private instructions: string; + private model: LanguageModel; + private tools: Tool[]; + private maxSteps: number; + private verbose: boolean; + private approvalFunc: (toolName: string, args: any) => Promise; + private useStreaming: boolean; // 付録A(ストリーミング機能)用フラグ + + constructor(config: AgentConfig) { + this.name = config.name; + this.instructions = config.instructions; + this.model = config.model; + // オブジェクト形式から配列に変換 + this.tools = Object.values(config.tools); + this.maxSteps = config.maxSteps ?? 10; + this.verbose = config.verbose ?? false; + // approvalFuncが渡されなければデフォルトの対話的承認を使用 + this.approvalFunc = config.approvalFunc ?? requestApproval; + this.useStreaming = config.useStreaming ?? false; + } + + async generate(userPrompt: string): Promise<{ text: string }> { + // ステップ1:会話ループの開始(5.3節) + let messages: Message[] = [ + { role: 'system', content: this.instructions }, + { role: 'user', content: userPrompt }, + ]; + + let currentStep = 0; + let finalText = ''; + let toolCallCount = 0; + + while (currentStep < this.maxSteps) { + currentStep++; + + if (this.verbose) { + console.log(`\n=== ステップ ${currentStep}/${this.maxSteps} ===`); + } + + // コンテキスト管理(第6章「6.5 manageContextメソッドの実装」で解説・実装) + messages = this.manageContext(messages); + + // ストリーミング機能が有効で、かつモデルがストリーミングに対応している場合はストリーミングを使用(付録A) + const response = await (async () => { + if (this.useStreaming) { + if (this.model.doStream) { + const streamResult = await collectStreamResult({ + model: this.model, + messages, + tools: this.tools, + onChunk: (chunk) => { + if (chunk.kind === 'delta' && chunk.text) { + process.stdout.write(chunk.text); + } + }, + }); + console.log(); // 改行 + return streamResult; + } + console.warn('警告: モデルがストリーミングに対応していないため、通常生成を使用します'); } - console.log(`\n[Context] 会話履歴を圧縮します (現在: ${totalLength}文字)`); - - // 1. 守るべきメッセージを確保 - const systemMessage = messages[0]; - if (!systemMessage) { - return messages; + return generateText({ + model: this.model, + messages, + tools: this.tools, + }); + })(); + + // テキスト応答の保存と出力 + if (response.text) { + finalText = response.text; + // ストリーミング時はすでに逐次出力されているため、非ストリーミング時のみ出力 + if (!this.useStreaming || !this.model.doStream) { + console.log(response.text); } - const recentMessages = messages.slice(-4); - let middleMessages = messages.slice(1, -4); - - // 2. 戦略A: 古いツール実行結果を「省略」に置換 - middleMessages = middleMessages.map(msg => { - if (msg.role === 'tool' && msg.content && msg.content.length > 200) { - return { - ...msg, - content: `(以前のツール実行結果は省略されました: ${msg.content.length}文字)` - }; - } - return msg; + } + + // ステップ2:ツール実行(5.4節、5.6節) + if (response.toolCalls && response.toolCalls.length > 0) { + messages.push({ + role: 'assistant', + content: response.text, + toolCalls: response.toolCalls, }); - // 3. 戦略B: それでも溢れるなら、古い順に削除 - totalLength = (systemMessage.content?.length || 0) + - middleMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0) + - recentMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0); - - while (totalLength > CHAR_LIMIT && middleMessages.length > 0) { - const removed = middleMessages.shift(); - if (removed) { - totalLength -= (removed.content?.length || 0); + for (const toolCall of response.toolCalls) { + const tool = this.tools.find(t => t.name === toolCall.name); + + if (!tool) { + // ツールが見つからない場合 + messages.push({ + role: 'tool', + toolCallId: toolCall.toolCallId, + name: toolCall.name, + content: `エラー: ツール ${toolCall.name} が見つかりません`, + }); + continue; + } + + if (this.verbose) { + console.log(`[ツール実行] ${toolCall.name}(${JSON.stringify(toolCall.args)})`); + } + + // ステップ3:承認チェック(5.5節) + if (tool.needsApproval) { + const approved = await this.approvalFunc(toolCall.name, toolCall.args); + if (!approved) { + messages.push({ + role: 'tool', + toolCallId: toolCall.toolCallId, + name: toolCall.name, + content: 'ユーザーによってキャンセルされました。別の方法を検討してください。', + }); + continue; } + } + + // ツールを実行(5.6節のexecuteTool関数を使用) + const result = await executeTool(tool, toolCall.args); + toolCallCount++; + + if (this.verbose) { + console.log(`[結果] ${result.slice(0, 200)}${result.length > 200 ? '...' : ''}`); + } + + messages.push({ + role: 'tool', + toolCallId: toolCall.toolCallId, + name: toolCall.name, + content: result, + }); } - return [systemMessage, ...middleMessages, ...recentMessages]; - } - - async generate(userPrompt: string): Promise<{ - text: string; - finishReason: 'stop' | 'max_steps' | 'length' | 'content_filter' | 'error'; - usage?: { totalTokens: number }; - }> { - // let に変更(manageContextの戻り値で再代入するため) - let messages: Message[] = [ - { role: 'system', content: this.instructions }, - { role: 'user', content: userPrompt }, - ]; - - let currentStep = 0; - let finalText = ''; - let totalTokens = 0; - let finishReason: 'stop' | 'max_steps' | 'length' | 'content_filter' | 'error' = 'max_steps'; - - while (currentStep < this.maxSteps) { - currentStep++; - console.log(`\nStep ${currentStep}/${this.maxSteps}\n`); - - // コンテキスト管理 - messages = this.manageContext(messages); - - const response = await (async () => { - if (this.useStreaming) { - if (this.model.doStream) { - const streamResult = await collectStreamResult({ - model: this.model, - messages, - tools: this.tools, - maxTokens: 4096, - onChunk: (chunk) => { - if (chunk.kind === 'delta' && chunk.text) { - process.stdout.write(chunk.text); - } - }, - }); - console.log(); - return streamResult; - } - console.warn('[Streaming] モデルがストリーミングに未対応のため通常APIを使用します。'); - } + continue; // 次のループへ + } - return generateText({ - model: this.model, - messages, - tools: this.tools, - maxTokens: 4096, - }); - })(); + // ツール呼び出しがない場合は完了(5.3節:会話履歴への追加) + messages.push({ + role: 'assistant', + content: response.text, + }); + break; + } - // トークン数の累積 - totalTokens += response.usage?.totalTokens ?? 0; + // ループ終了後のチェック + if (currentStep >= this.maxSteps) { + console.warn('警告: 最大ステップ数に達しました'); + } - if (response.text) { - console.log(response.text); - finalText += response.text; - } + // ツール未使用で終了した場合の警告(5.8節) + if (toolCallCount === 0 && currentStep === 1) { + console.warn('警告: ツールが一度も使用されずに終了しました'); + } - if (response.toolCalls && response.toolCalls.length > 0) { - messages.push({ - role: 'assistant', - content: response.text || '', - toolCalls: response.toolCalls, - }); - - for (const toolCall of response.toolCalls) { - const tool = this.tools.find(t => t.name === toolCall.name); - if (tool) { - console.log(`[ツール] ${toolCall.name}(${JSON.stringify(toolCall.args)})`); - - if (tool.needsApproval) { - const approved = await this.approvalFunc(toolCall.name, toolCall.args); - if (!approved) { - messages.push({ - role: 'tool', - toolCallId: toolCall.toolCallId, - name: toolCall.name, - content: 'ユーザーによってキャンセルされました。別の方法を検討してください。' - }); - continue; - } - } - - try { - const result = await tool.execute(toolCall.args); - console.log(`[結果] 成功: ${result.slice(0, 100)}...`); - messages.push({ - role: 'tool', - content: result, - toolCallId: toolCall.toolCallId, - name: toolCall.name, - }); - } catch (error: any) { - const errorMessage = error.message || 'Unknown error'; - const errorDetails = error instanceof LLMApiError && error.raw - ? `\n詳細: ${JSON.stringify(error.raw, null, 2)}` - : ''; - console.log(`[結果] エラー: ${errorMessage}${errorDetails}`); - messages.push({ - role: 'tool', - content: `エラー: ${errorMessage}`, - toolCallId: toolCall.toolCallId, - name: toolCall.name, - }); - } - } else { - console.error(`不明なツール: ${toolCall.name}`); - messages.push({ - role: 'tool', - toolCallId: toolCall.toolCallId, - name: toolCall.name, - content: `エラー: ツール ${toolCall.name} が見つかりません` - }); - } - } - continue; - } + return { + text: finalText, + }; + } - if (response.text) { - messages.push({ role: 'assistant', content: response.text }); - } + // コンテキスト管理機能(第6章「6.5 manageContextメソッドの実装」で解説・実装) + private manageContext(messages: Message[]): Message[] { + // 簡易的な制限:文字数で判定(例: 30,000文字 ≈ 10k~15kトークン程度と仮定) + // ※使用するモデルのコンテキストウィンドウに合わせて調整 + const CHAR_LIMIT = 30000; - // 正常終了: LLMが応答を完了した - if (response.finishReason === 'stop') { - finishReason = 'stop'; - break; - } - // 出力トークン上限: 応答が途中で切れた可能性があるが、次のステップに進む - if (response.finishReason === 'length') { - console.warn('[警告] 出力トークン上限に達しました。次のステップに進みます。'); - continue; - } - // コンテンツフィルタ: 安全上の理由で中断 - if (response.finishReason === 'content_filter') { - finishReason = 'content_filter'; - break; - } - } + let totalLength = messages.reduce((sum, m) => sum + m.content.length, 0); - if (currentStep >= this.maxSteps) { - console.warn('警告: 最大ステップ数に達しました'); - } + // 制限内なら何もしない + if (totalLength < CHAR_LIMIT) { + return messages; + } - // 完了時のサマリ出力 - if (this.verbose) { - console.log(`\n[完了] ステップ数: ${currentStep}, 総トークン: ${totalTokens}`); - } + console.log(`\n[Context] 会話履歴を圧縮します (現在: ${totalLength}文字)`); + // 1. 守るべきメッセージを確保 + // 先頭(システムプロンプト) + const systemMessage = messages[0]; + if (!systemMessage) { + return messages; + } + // 最新の4メッセージ(直近の文脈) + const recentMessages = messages.slice(-4); + // 圧縮対象となる中間メッセージ + let middleMessages = messages.slice(1, -4); + + // 2. 戦略A: 古いツール実行結果を「省略」に置換 + // readFileの結果などが巨大になりがちなので、これを削るのが最も効果的 + middleMessages = middleMessages.map(msg => { + if (msg.role === 'tool' && msg.content.length > 200) { return { - text: finalText, - finishReason, - usage: { totalTokens }, + ...msg, + content: `(以前のツール実行結果は省略されました: ${msg.content.length}文字)` }; + } + return msg; + }); + + // 3. 戦略B: それでも溢れるなら、古い順に削除 + // 再計算 + totalLength = systemMessage.content.length + + middleMessages.reduce((sum, m) => sum + m.content.length, 0) + + recentMessages.reduce((sum, m) => sum + m.content.length, 0); + + while (totalLength > CHAR_LIMIT && middleMessages.length > 0) { + const removed = middleMessages.shift(); // 古いものから削除 + if (removed) { + totalLength -= removed.content.length; + } } + + // 再構築 + return [systemMessage, ...middleMessages, ...recentMessages]; + } } diff --git a/src/core/clean-messages.test.ts b/src/core/clean-messages.test.ts new file mode 100644 index 0000000..871d9f1 --- /dev/null +++ b/src/core/clean-messages.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'bun:test'; +import { cleanMessages } from '../../bin/review'; +import type { Message } from '../types'; + +describe('cleanMessages in review.ts', () => { + it('should pass through fully consistent messages', () => { + const messages: Message[] = [ + { role: 'user', content: 'hello' }, + { + role: 'assistant', + content: 'let me run a tool', + toolCalls: [{ toolCallId: '1', name: 'dummy', args: {} }] + }, + { role: 'tool', toolCallId: '1', name: 'dummy', content: 'result' }, + { role: 'assistant', content: 'done' } + ]; + + const cleaned = cleanMessages(messages); + expect(cleaned).toEqual(messages); + }); + + it('should recover unmatched tool responses with a dummy assistant message (orphan tool role)', () => { + const messages: Message[] = [ + { role: 'user', content: 'hello' }, + // 親である assistant (toolCalls: '1') が manageContext 等で消えた想定 + { role: 'tool', toolCallId: '1', name: 'dummy', content: 'result' }, + { role: 'assistant', content: 'done' } + ]; + + const cleaned = cleanMessages(messages); + expect(cleaned).toEqual([ + { role: 'user', content: 'hello' }, + { + role: 'assistant', + content: 'ツールを実行します。', + toolCalls: [{ toolCallId: '1', name: 'dummy', args: {} }] + }, + { role: 'tool', toolCallId: '1', name: 'dummy', content: 'result' }, + { role: 'assistant', content: 'done' } + ]); + }); + + it('should fallback to a dummy user message if only system message remains', () => { + const messages: Message[] = [ + { role: 'system', content: 'you are a reviewer' } + ]; + + const cleaned = cleanMessages(messages); + expect(cleaned).toEqual([ + { role: 'system', content: 'you are a reviewer' }, + { role: 'user', content: '続けてください。' } + ]); + }); + + it('should strip unmatched tool calls from assistant message (orphan tool_calls)', () => { + const messages: Message[] = [ + { role: 'user', content: 'hello' }, + { + role: 'assistant', + content: 'let me run a tool', + toolCalls: [{ toolCallId: '1', name: 'dummy', args: {} }] + }, + // 子である tool (toolCallId: '1') が manageContext 等で消えた想定 + { role: 'assistant', content: 'done' } + ]; + + const cleaned = cleanMessages(messages); + expect(cleaned).toEqual([ + { role: 'user', content: 'hello' }, + { + role: 'assistant', + content: 'let me run a tool' + }, + { role: 'assistant', content: 'done' } + ]); + }); + + it('should only keep matched tool calls and drop unmatched ones in case of multiple tool calls', () => { + const messages: Message[] = [ + { + role: 'assistant', + content: 'running tools', + toolCalls: [ + { toolCallId: '1', name: 'dummy1', args: {} }, + { toolCallId: '2', name: 'dummy2', args: {} } + ] + }, + { role: 'tool', toolCallId: '1', name: 'dummy1', content: 'res1' } + // 'dummy2' の tool レスポンスが消えた想定 + ]; + + const cleaned = cleanMessages(messages); + expect(cleaned).toEqual([ + { + role: 'assistant', + content: 'running tools', + toolCalls: [ + { toolCallId: '1', name: 'dummy1', args: {} } + ] + }, + { role: 'tool', toolCallId: '1', name: 'dummy1', content: 'res1' } + ]); + }); +}); diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index a2c91a9..33c7b76 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -274,3 +274,70 @@ export function createAnthropic(config?: { }, }); } + +/* +// ========================================== +// 実用上のAPI不整合エラー(400)対策の変更例 +// ========================================== +// 第6章「6.5 manageContextメソッドの実装」で導入される履歴圧縮(会話履歴の自動削減)によって +// 過去のメッセージがスライス・削減された際、ツール呼び出し(tool_use)と実行結果(tool_result)の +// 親子関係(対となるペア)が壊れることで、Anthropic API が 400 Bad Request エラーを返すようになる実用上の問題があります。 +// +// これを防ぐため、convertMessages を以下のように書き換え、クリーンアップ関数(cleanMessages)を適用してください。 + +// 変更例 (convertMessages 内で cleanMessages を適用する): +// +// function convertMessages(messages: Message[]) { +// - return messages +// + const cleaned = cleanMessages(messages); +// + return cleaned +// .filter((m) => m.role !== 'system') +// .map((m) => { +// // (中身は変更なし) +// }); +// } + +function cleanMessages(messages: Message[]): Message[] { + const existingToolCallIds = new Set( + messages + .filter(m => m.role === 'tool') + .map(m => (m as any).toolCallId) + ); + + const finalMessages: Message[] = []; + for (const msg of messages) { + if (msg.role === 'tool') { + let foundAssistant = false; + for (let j = finalMessages.length - 1; j >= 0; j--) { + const prev = finalMessages[j]; + if (prev && prev.role === 'assistant' && 'toolCalls' in prev && prev.toolCalls) { + if (prev.toolCalls.some((tc: any) => tc.toolCallId === msg.toolCallId)) { + foundAssistant = true; + break; + } + } + } + if (foundAssistant) { + finalMessages.push(msg); + } + } else if (msg.role === 'assistant' && 'toolCalls' in msg && msg.toolCalls) { + const validToolCalls = msg.toolCalls.filter((tc: any) => existingToolCallIds.has(tc.toolCallId)); + if (validToolCalls.length > 0) { + finalMessages.push({ + role: 'assistant', + content: msg.content, + toolCalls: validToolCalls + } as Message); + } else { + finalMessages.push({ + role: 'assistant', + content: msg.content + } as Message); + } + } else { + finalMessages.push(msg); + } + } + return finalMessages; +} +*/ diff --git a/src/providers/google.ts b/src/providers/google.ts index 951f521..e4fb5ca 100644 --- a/src/providers/google.ts +++ b/src/providers/google.ts @@ -256,3 +256,71 @@ export function createGoogle(config?: { apiKey?: string }): Provider { }, }); } + +/* +// ========================================== +// 実用上のAPI不整合エラー(400)対策の変更例 +// ========================================== +// 第6章「6.5 manageContextメソッドの実装」で導入される履歴圧縮(会話履歴の自動削減)によって +// 過去のメッセージがスライス・削減された際、ツール呼び出し(functionCall)と実行結果(functionResponse)の +// 親子関係(対となるペア)が壊れることで、Google Gen AI API が 400 Bad Request エラーを返すようになる実用上の問題があります。 +// +// これを防ぐため、convertMessages を以下のように書き換え、クリーンアップ関数(cleanMessages)を適用してください。 + +// 変更例 (convertMessages 内で cleanMessages を適用する): +// +// function convertMessages(messages: Message[]) { +// - return messages +// + const cleaned = cleanMessages(messages); +// + return cleaned +// .filter((m) => m.role !== 'system') +// .map((m) => { +// // (中身は変更なし) +// }); +// } + +function cleanMessages(messages: Message[]): Message[] { + const existingToolCallIds = new Set( + messages + .filter(m => m.role === 'tool') + .map(m => (m as any).toolCallId) + ); + + const finalMessages: Message[] = []; + for (const msg of messages) { + if (msg.role === 'tool') { + let foundAssistant = false; + for (let j = finalMessages.length - 1; j >= 0; j--) { + const prev = finalMessages[j]; + if (prev && prev.role === 'assistant' && 'toolCalls' in prev && prev.toolCalls) { + if (prev.toolCalls.some((tc: any) => tc.toolCallId === msg.toolCallId)) { + foundAssistant = true; + break; + } + } + } + if (foundAssistant) { + finalMessages.push(msg); + } + } else if (msg.role === 'assistant' && 'toolCalls' in msg && msg.toolCalls) { + const validToolCalls = msg.toolCalls.filter((tc: any) => existingToolCallIds.has(tc.toolCallId)); + if (validToolCalls.length > 0) { + finalMessages.push({ + role: 'assistant', + content: msg.content, + toolCalls: validToolCalls + } as Message); + } else { + finalMessages.push({ + role: 'assistant', + content: msg.content + } as Message); + } + } else { + finalMessages.push(msg); + } + } + return finalMessages; +} +*/ + diff --git a/src/providers/openai.ts b/src/providers/openai.ts index de036cb..62adf04 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -131,10 +131,10 @@ export function createOpenAI(config?: { toolCalls, usage: completion.usage ? { - promptTokens: completion.usage.prompt_tokens, - completionTokens: completion.usage.completion_tokens, - totalTokens: completion.usage.total_tokens, - } + promptTokens: completion.usage.prompt_tokens, + completionTokens: completion.usage.completion_tokens, + totalTokens: completion.usage.total_tokens, + } : undefined, }; } catch (error) { @@ -251,3 +251,69 @@ export function createOpenAI(config?: { }, }); } + +/* +// ========================================== +// 実用上のAPI不整合エラー(400)対策の変更例 +// ========================================== +// 第6章「6.5 manageContextメソッドの実装」で導入される履歴圧縮(会話履歴の自動削減)によって +// 過去のメッセージがスライス・削減された際、ツール呼び出し(tool_calls)と実行結果(tool)の +// 親子関係(対となるペア)が壊れることで、OpenAI API が 400 Bad Request エラーを返すようになる実用上の問題があります。 +// +// これを防ぐため、convertMessages を以下のように書き換え、クリーンアップ関数(cleanMessages)を適用してください。 + +// 変更例 (convertMessages 内で cleanMessages を適用する): +// +// function convertMessages(messages: Message[]) { +// - return messages.map((m) => { +// + const cleaned = cleanMessages(messages); +// + return cleaned.map((m) => { +// // (中身は変更なし) +// }); +// } + +function cleanMessages(messages: Message[]): Message[] { + const existingToolCallIds = new Set( + messages + .filter(m => m.role === 'tool') + .map(m => (m as any).toolCallId) + ); + + const finalMessages: Message[] = []; + for (const msg of messages) { + if (msg.role === 'tool') { + let foundAssistant = false; + for (let j = finalMessages.length - 1; j >= 0; j--) { + const prev = finalMessages[j]; + if (prev && prev.role === 'assistant' && 'toolCalls' in prev && prev.toolCalls) { + if (prev.toolCalls.some((tc: any) => tc.toolCallId === msg.toolCallId)) { + foundAssistant = true; + break; + } + } + } + if (foundAssistant) { + finalMessages.push(msg); + } + } else if (msg.role === 'assistant' && 'toolCalls' in msg && msg.toolCalls) { + const validToolCalls = msg.toolCalls.filter((tc: any) => existingToolCallIds.has(tc.toolCallId)); + if (validToolCalls.length > 0) { + finalMessages.push({ + role: 'assistant', + content: msg.content, + toolCalls: validToolCalls + } as Message); + } else { + finalMessages.push({ + role: 'assistant', + content: msg.content + } as Message); + } + } else { + finalMessages.push(msg); + } + } + return finalMessages; +} +*/ + diff --git a/tsconfig.json b/tsconfig.json index c6833d5..f400882 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ // Best practices "strict": true, + "strictFunctionTypes": false, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, diff --git a/workspace/docs/index.html b/workspace/docs/index.html index 86fec63..6ef7ac9 100644 --- a/workspace/docs/index.html +++ b/workspace/docs/index.html @@ -266,9 +266,14 @@

第 4 章

  • 4.6 節 index.ts におけるツールの個別エクスポート
  • 4.6 節 ツール動作確認スクリプトのファイル名
  • +

    第 5 章

    +

    第 6 章

    第 7 章

      @@ -313,10 +318,10 @@

      Google 系 API キーの環境変数名

      対処
      -

      配布リポジトリの .env.example では GEMINI_API_KEYGOOGLE_API_KEY の両方を併記しています。Google のキーを設定する際は、書籍 3 章の実装に合わせて GEMINI_API_KEY を使用してください(同じ値を両方の変数に書いておくと、本書 2 章のサンプル(GOOGLE_API_KEY)にも 6 章の createModelFromEnv にも対応できます)。

      +

      サンプルコードの .env.example では GEMINI_API_KEYGOOGLE_API_KEY の両方を併記しています。Google のキーを設定する際は、書籍 3 章の実装に合わせて GEMINI_API_KEY を使用してください(同じ値を両方の変数に書いておくと、本書 2 章のサンプル(GOOGLE_API_KEY)にも 6 章の createModelFromEnv にも対応できます)。

      GEMINI_API_KEY=AI...
       GOOGLE_API_KEY=AI...
      -

      なお、配布リポジトリの旧版(next-edition ブランチ)には両方の環境変数を読むフォールバックが実装されていましたが、本書 3 章の記述と一致させるため、main では撤去しています。

      +

      なお、サンプルコードの旧版(next-edition ブランチ)には両方の環境変数を読むフォールバックが実装されていましたが、本書 3 章の記述と一致させるため、main では撤去しています。

      @@ -338,11 +343,11 @@

      FinishReason 型の定義

      症状
      -

      配布コードや付録 A のストリーミング実装(StreamChunk)等では、この終了理由の型を FinishReason として参照していますが、書籍の記述通りに写経していると FinishReason 型が存在しないためコンパイルエラー(Cannot find name 'FinishReason')が発生します。

      +

      サンプルコードや付録 A のストリーミング実装(StreamChunk)等では、この終了理由の型を FinishReason として参照していますが、書籍の記述通りに写経していると FinishReason 型が存在しないためコンパイルエラー(Cannot find name 'FinishReason')が発生します。

      対処
      -

      配布リポジトリでは、各プロバイダーと型定義での重複を避け、整合性を保つために src/types.ts で次のように型エイリアスを定義しています。

      +

      サンプルコードでは、各プロバイダーと型定義での重複を避け、整合性を保つために src/types.ts で次のように型エイリアスを定義しています。

      export type FinishReason = 'stop' | 'length' | 'content_filter' | 'tool_calls' | 'error';

      書籍のコードを写経して進める場合は、src/types.ts にこの型エイリアスを追加して定義するか、各プロバイダー内の FinishReason の箇所を 'stop' | 'length' | ... または GenerateTextResult['finishReason'] に置き換えてください。

      @@ -386,13 +391,13 @@

      execCommand の実用上のセキュリティ対策と機能拡張

      概要
      -

      配布リポジトリの src/tools/execCommand.ts は、本書に記載されたシンプルな実装と比較して、実用的な堅牢化(セキュリティ対策およびバグ防止)を施したコードになっています。

      +

      サンプルコードの src/tools/execCommand.ts は、本書に記載されたシンプルな実装と比較して、実用的な堅牢化(セキュリティ対策およびバグ防止)を施したコードになっています。

      主な差異と追加意図
      • コマンド失敗時(code !== 0)の例外スロー:
        - 書籍では終了コードを文字列に含めて正常終了(resolve)させていますが、配布コードでは reject(new Error) により例外を投げるように変更しています。例外を投げない場合、エージェント側がコマンド失敗を認識できず、テストが落ちても「成功した」と思い込んで次のステップへ進むといった致命的なバグを防止するためです。 + 書籍では終了コードを文字列に含めて正常終了(resolve)させていますが、サンプルコードでは reject(new Error) により例外を投げるように変更しています。例外を投げない場合、エージェント側がコマンド失敗を認識できず、テストが落ちても「成功した」と思い込んで次のステップへ進むといった致命的なバグを防止するためです。
      • dangerousPatterns 正規表現による危険パターン検知:
        書籍のホワイトリストチェックに加え、引数に rm -rfcurl | sh などの危険なパターンが渡された場合に水際でエラーを投げるセキュリティ防御層を追加しています。 @@ -439,11 +444,11 @@

        index.ts におけるツールの個別エクスポート

        概要
        -

        配布リポジトリの src/tools/index.ts では、書籍の allTools 配列のエクスポートに加え、各ツールオブジェクト(readFile, writeFile, editFile, execCommand)が個別に再エクスポート(export { ... })されています。

        +

        サンプルコードの src/tools/index.ts では、書籍の allTools 配列のエクスポートに加え、各ツールオブジェクト(readFile, writeFile, editFile, execCommand)が個別に再エクスポート(export { ... })されています。

        症状
        -

        書籍のコード記述(allTools 配列のみをエクスポートする形)のまま、第 5 章以降の CLI 実行用スクリプト(bin/cli.ts)や他章のデモコードを動かそうとすると、特定のツールオブジェクトを直接個別にインポートしている箇所で次のような TypeScript のコンパイルエラーが発生します。

        +

        書籍のコード記述(allTools 配列のみをエクスポートする形)のまま、第 6 章以降の CLI 実行用スクリプト(bin/cli.ts)や他章のデモコードを動かそうとすると、特定のツールオブジェクトを直接個別にインポートしている箇所で次のような TypeScript のコンパイルエラーが発生します。

        Module '"../src/tools"' declares 'readFile' locally, but it is not exported.
        対処
        @@ -481,13 +486,68 @@

        ツール動作確認スクリプトのファイル名

        +
        +

        第 5 章

        + +
        +
        + 5.8 節 +

        Agent クラスのサンプルコードにおける型定義と互換用の調整

        + # +
        +
        +
        本書の記述
        +
        +

        本書 5.8 節「Agentクラスの完全なソースコード」では、シンプルな思考ループが記述されています。

        +
        +
        症状・考慮事項
        +
        +

        本書の記述通りに TypeScript の strict モード下で開発を行う場合、各ツールの引数定義の不整合による型チェックエラーが発生します。また、付録 A のストリーミング機能を後続章の CLI から呼び出す際にもビルドエラーが発生します。

        +
        +
        対処
        +
        +

        サンプルコードの src/core/agent.ts は、本書の記述通りの思考ループが実装されていますが、TypeScript のビルドを通すため、および付録 A との互換性のために、以下の最小限の追加が行われています。

        + +

        1. TypeScript の厳密な関数引数チェック(strictFunctionTypes)への対応

        +

        本書の記述通りに tools: Record<string, Tool> と定義した状態で、各ツールの具体的な引数型(例: readFileExecute(args: { path: string }))を持つ関数を代入しようとすると、TypeScript の strict モード下では型代入の互換性エラーが発生します。サンプルコードでは、書籍と同じ記述を維持したままビルドを通すため、tsconfig.json"strictFunctionTypes": false を設定してこれを解消しています。

        + +

        2. ストリーミング機能用のフラグ定義(付録 A との互換性)

        +

        付録 A のストリーミング機能をCLIから利用する際にプロパティの不整合によるビルドエラーを防ぐため、コンストラクタおよび AgentConfiguseStreaming フラグが定義されています。

        +
        +
        +
        + +
        +
        + 5.8 節 +

        動作確認スクリプトのファイル名と配置

        + # +
        +
        +
        本書の記載
        +
        +

        5.8 節の「使用例」のコードブロックで // src/05-coding-agent.ts とコメントされています。

        +
        +
        症状
        +
        +

        サンプルコードには src/05-coding-agent.ts は存在せず、実行方法についての記載もありません。

        +
        +
        対処
        +
        +

        サンプルコードでは、ファイル配置を他の章の動作確認スクリプトと統一するため chapters/05-coding-agent.ts に配置し、インポートパスを調整しています。以下のコマンドで実行してください。

        +
        bun run chapters/05-coding-agent.ts
        +
        +
        +
        +
        +

        第 6 章

        6.4 節 -

        createModelFromEnv と配布リポジトリ実装の差異

        +

        createModelFromEnv とサンプルコードの実装の差異

        #
        @@ -514,17 +574,17 @@

        createModelFromEnv と配布リポジトリ実装の差異

      症状
      -

      本書のコードをそのまま写経して進める範囲では問題なく動作します。ただし、配布リポジトリの src/providers/modelFactory.ts は付録 B(Responses API 対応)を含む拡張のため、次の 3 点で本書の記載と異なります。

      +

      本書のコードをそのまま写経して進める範囲では問題なく動作します。ただし、サンプルコードの src/providers/modelFactory.ts は付録 B(Responses API 対応)を含む拡張のため、次の 3 点で本書の記載と異なります。

      • シグネチャcreateModelFromEnv(options?: { useResponses?: boolean }) のように Responses API の切替オプションを受け取ります。
      • LLM_API_KEY の扱い:未設定時に明示的に throw します(本書版はサイレント通過のため、後段の SDK 呼び出しで 401 として現れます)。
      • API キーの渡し方createOpenAI({ apiKey }) のように直接引数で渡します(本書版は process.env.<PROVIDER>_API_KEY への代入経由)。
      -

      このため、配布リポジトリの bin/cli.ts --responses(付録 B)を試そうとすると、本書版の createModelFromEnv には useResponses 切替が存在せず、付録 B のサンプルどおりに動作させられません。

      +

      このため、サンプルコードの bin/cli.ts --responses(付録 B)を試そうとすると、本書版の createModelFromEnv には useResponses 切替が存在せず、付録 B のサンプルどおりに動作させられません。

      対処
      -

      本書の章を順に進める読者は、本書のコードのままで問題ありません。配布リポジトリのコードを参照する場合や、付録 B(Responses API 対応)を試す場合は、リポジトリの src/providers/modelFactory.ts の実装を参照してください。

      +

      本書の章を順に進める読者は、本書のコードのままで問題ありません。サンプルコードを参照する場合や、付録 B(Responses API 対応)を試す場合は、サンプルコードの src/providers/modelFactory.ts の実装を参照してください。

      Responses API 切替を本書版に最小限取り込みたい場合は、OpenAI ケースに次の分岐を加える方法があります。

      case 'openai': {
         if (apiKey && !process.env.OPENAI_API_KEY) {
      @@ -537,7 +597,125 @@ 

      createModelFromEnv と配布リポジトリ実装の差異

      補足
      -

      同様の経緯で、本書 6.4 節のコード片では Google ケースの API キーに process.env.GOOGLE_API_KEY を使っていますが、配布リポジトリの .env.example および src/providers/google.tsGEMINI_API_KEY を優先します(GOOGLE_API_KEY もフォールバックとして読みます)。本書のコードは引き続き動作しますが、リポジトリの環境変数名は GEMINI_API_KEY である点に留意してください。

      +

      同様の経緯で、本書 6.4 節のコード片では Google ケースの API キーに process.env.GOOGLE_API_KEY を使っていますが、サンプルコードの .env.example および src/providers/google.tsGEMINI_API_KEY を優先します(GOOGLE_API_KEY もフォールバックとして読みます)。本書のコードは引き続き動作しますが、サンプルコードの環境変数名は GEMINI_API_KEY である点に留意してください。

      +
      +
      +
      +
      + 6.5 節 +

      履歴圧縮(manageContext)時の各 LLM API でのメッセージ不整合エラー

      + # +
      +
      +
      本書の記述
      +
      +

      本書 6.5 節で実装する manageContext メソッドは、文字数が 30,000文字 を超えた場合に中間メッセージ(古いツール実行結果など)を古い順から削減します。

      +
      +
      症状
      +
      +

      メッセージ履歴を単純に古いものから削除すると、ツール呼び出し要求(tool_calls を含む assistant メッセージ)と、その実行結果(tool メッセージ)のどちらか片方だけが削除される状況が発生します。OpenAI や Anthropic 等の主要な API プロバイダは、これらの親子関係の整合性を厳しく検証するため、不整合があるメッセージ履歴を送信すると、400 Bad Request エラー(例: OpenAI の messages must contain the tool call message... や Anthropic の tool_use block without corresponding tool_result)が発生してプログラムが強制終了してしまいます。

      +
      +
      対処とサンプルコードでの調整
      +
      +

      本書のコード設計との同一性を最優先するため、配布コード(nano-code)の各プロバイダ内では cleanMessages による自動クリーンアップはデフォルトで無効化(コメントアウト)されており、書籍通りのシンプルなメッセージ変換ロジックが動作するようになっています。

      +

      実用において長時間の対話を行い 400 エラーが発生する場合は、各プロバイダファイル(src/providers/openai.tssrc/providers/anthropic.tssrc/providers/google.ts)の末尾コメントに記載されているクリーンアップ用のコード例(cleanMessages および convertMessages の書き換え例)を適用してください。

      + +

      各プロバイダでの具体的な変更手順

      +

      以下は、各プロバイダで cleanMessages によるクリーンアップ処理を有効化する際のコードの変更例です。

      + +
      1. OpenAI プロバイダ (src/providers/openai.ts)
      +
      // convertMessages 内で cleanMessages を適用するように変更します
      +function convertMessages(messages: Message[]) {
      +-   return messages.map((m) => {
      ++   const cleaned = cleanMessages(messages);
      ++   return cleaned.map((m) => {
      +        // (中身は変更なし)
      +    });
      +}
      + +
      2. Anthropic プロバイダ (src/providers/anthropic.ts)
      +
      // convertMessages 内で cleanMessages を適用するように変更します
      +function convertMessages(messages: Message[]) {
      +-   return messages
      ++   const cleaned = cleanMessages(messages);
      ++   return cleaned
      +        .filter((m) => m.role !== 'system')
      +        .map((m) => {
      +            // (中身は変更なし)
      +        });
      +}
      + +
      3. Google プロバイダ (src/providers/google.ts)
      +
      // convertMessages 内で cleanMessages を適用するように変更します
      +function convertMessages(messages: Message[]) {
      +-   return messages
      ++   const cleaned = cleanMessages(messages);
      ++   return cleaned
      +        .filter((m) => m.role !== 'system')
      +        .map((m) => {
      +            // (中身は変更なし)
      +        });
      +}
      + +

      ※ 各ファイルの末尾にある cleanMessages の定義(コメントアウトされているもの)のコメントを解除して有効化する必要があります。各プロバイダの末尾に実装されている cleanMessages 自体の処理は以下の通りです。

      +
      function cleanMessages(messages: Message[]): Message[] {
      +    const existingToolCallIds = new Set(
      +        messages
      +            .filter((m) => m.role === 'tool')
      +            .map((m) => (m as any).toolCallId)
      +    );
      +
      +    const finalMessages: Message[] = [];
      +    for (const msg of messages) {
      +        if (msg.role === 'tool') {
      +            let foundAssistant = false;
      +            for (let j = finalMessages.length - 1; j >= 0; j--) {
      +                const prev = finalMessages[j];
      +                if (
      +                    prev &&
      +                    prev.role === 'assistant' &&
      +                    'toolCalls' in prev &&
      +                    prev.toolCalls
      +                ) {
      +                    if (
      +                        prev.toolCalls.some(
      +                            (tc: any) => tc.toolCallId === msg.toolCallId
      +                        )
      +                    ) {
      +                        foundAssistant = true;
      +                        break;
      +                    }
      +                }
      +            }
      +            if (foundAssistant) {
      +                finalMessages.push(msg);
      +            }
      +        } else if (
      +            msg.role === 'assistant' &&
      +            'toolCalls' in msg &&
      +            msg.toolCalls
      +        ) {
      +            const validToolCalls = msg.toolCalls.filter((tc: any) =>
      +                existingToolCallIds.has(tc.toolCallId)
      +            );
      +            if (validToolCalls.length > 0) {
      +                finalMessages.push({
      +                    role: 'assistant',
      +                    content: msg.content,
      +                    toolCalls: validToolCalls,
      +                } as Message);
      +            } else {
      +                finalMessages.push({
      +                    role: 'assistant',
      +                    content: msg.content,
      +                } as Message);
      +            }
      +        } else {
      +            finalMessages.push(msg);
      +        }
      +    }
      +    return finalMessages;
      +}
      @@ -643,7 +821,7 @@

      サンドボックス動作確認コマンドのファイルパス

      症状
      -

      配布リポジトリに src/index.ts は存在しません。CLI のエントリポイントは bin/cli.ts です。本書のコマンドをそのまま実行すると、Bun がモジュールを解決できず次のエラーで即終了します。

      +

      サンプルコードに src/index.ts は存在しません。CLI のエントリポイントは bin/cli.ts です。本書のコマンドをそのまま実行すると、Bun がモジュールを解決できず次のエラーで即終了します。

      error: Module not found "src/index.ts"
      対処
      @@ -672,10 +850,10 @@

      Google ストリーミング時の toolCallId 生成方式

      const id = part.functionCall.name;
       toolCalls[id] = { toolCallId: id, name: part.functionCall.name, ... };
      -
      配布コードとの差異
      +
      サンプルコードとの差異

      書籍のスニペット通りに実装した場合、同一関数を複数回呼び出すレスポンス(例:readFile を 2 回呼んで異なるファイルを読む)では、後の呼び出しが前の呼び出しを上書きしてしまい、ツール呼び出しが欠落します。

      -

      配布リポジトリでは doGenerate(非ストリーミング)との一貫性を保ちつつ、この問題を回避するため、連番方式(call_0call_1、…)に変更しています。

      +

      サンプルコードでは doGenerate(非ストリーミング)との一貫性を保ちつつ、この問題を回避するため、連番方式(call_0call_1、…)に変更しています。

      let toolCallIndex = 0;
       // ...
       const id = `call_${toolCallIndex++}`;
      @@ -683,7 +861,7 @@ 

      Google ストリーミング時の toolCallId 生成方式

      対処
      -

      書籍どおりに写経して進める場合、単一の関数を呼び出すユースケースでは問題なく動作します。同一関数の複数回呼び出しを正しく扱いたい場合は、配布コードの連番方式を採用してください。

      +

      書籍どおりに写経して進める場合、単一の関数を呼び出すユースケースでは問題なく動作します。同一関数の複数回呼び出しを正しく扱いたい場合は、サンプルコードの連番方式を採用してください。

      @@ -699,10 +877,10 @@

      OpenAI ツール引数の JSON パースエラー処理

      付録 A の doGenerate および doStream スニペットでは、OpenAI が返すツール引数の JSON 文字列を JSON.parse() で直接パースしています。

      -
      配布コードとの差異
      +
      サンプルコードとの差異

      OpenAI API がネットワーク障害・プロキシ介入等の影響で不正な JSON を返した場合、JSON.parse()SyntaxError をスローし、catch ブロックの OpenAI.APIError チェックをすり抜けて呼び出し元に伝播します。

      -

      配布リポジトリでは parseToolCallArgs() ヘルパーを用い、パース失敗時は {}(空オブジェクト)を返すことで SyntaxError の伝播を防いでいます。

      +

      サンプルコードでは parseToolCallArgs() ヘルパーを用い、パース失敗時は {}(空オブジェクト)を返すことで SyntaxError の伝播を防いでいます。

      対処