メインコンテンツまでスキップ

TinyElf Agent ループ詳解

TinyElf は Elftia の組み込み Agent エンジンであり、「LLM を呼び出す → ツールを実行する → 結果を観察する → ループ」という古典的なパターンを実装しています。この記事では、完全な Agent ループアルゴリズムを詳しく分析します。

全体アーキテクチャ

graph TB
Engine["TinyElfEngine"] --> SessionRunner["TinyElfSessionRunner<br/>runSession()"]

SessionRunner --> ToolBuilder["TinyElfToolRegistryBuilder<br/>buildToolRegistry()"]
SessionRunner --> PromptBuilder["TinyElfPromptBuilder<br/>buildMessages()"]
SessionRunner --> LoopInst["TinyElfAgentLoop<br/>instantiation"]
SessionRunner --> |"run"| Loop["AgentLoop.run()"]

ToolBuilder --> FileTools["FileSystemTools"]
ToolBuilder --> ShellT["ShellTool"]
ToolBuilder --> WebT["WebSearch/WebFetch"]
ToolBuilder --> SkillsT["Skills tools"]
ToolBuilder --> SpawnT["SpawnTool"]
ToolBuilder --> McpT["MCP tools"]
ToolBuilder --> SessionT["Session tools"]

Loop --> LLMCall["callLLMWithRetry()"]
Loop --> ToolExec["executeToolCalls()"]
Loop --> Save["onIterationComplete()"]

セッション実行フロー

sequenceDiagram
participant R as AgentRouter
participant E as TinyElfEngine
participant B as ToolRegistryBuilder
participant S as SessionRunner
participant L as AgentLoop
participant LLM as TinyElfLLMAdapter
participant T as ToolRegistry

R->>E: startSession(ctx)
E->>E: saveMessage(user)
E->>E: sender.send(userMessage)
E->>B: buildToolRegistry()
B-->>E: { toolRegistry, subagentManager, ... }
E->>S: runSession(deps, params)
S->>S: new TinyElfLLMAdapter()
S->>S: new ExecutionFirewall()
S->>S: buildGuardian()
S->>L: new TinyElfAgentLoop()
S->>S: buildMessages()
S->>L: run(messages, callbacks)

loop Up to 40 iterations
L->>LLM: callLLMWithRetry(messages, tools)
LLM-->>L: { content, toolCalls, usage }

alt No tool calls
L->>L: Check background results
alt Has background results
L->>L: Inject background results into messages
L->>L: continue (not consuming iteration)
else No background results
L-->>S: AgentLoopResult (finishReason: stop)
end
else Has tool calls
L->>L: executeToolCalls(parallel)
Note over L,T: Security pipeline: Firewall → Guardian → Permission
L->>T: execute(tool)
T-->>L: ToolCallResult
L->>S: onIterationComplete(data)
S->>S: saveMessage(assistant + tools)
S->>S: sender.send(assistantMessage)
L->>L: Append tool results to messages
L->>L: Drain background results
end
end

S->>S: saveFinalResult()
S->>S: sender.send(result + complete)

Agent ループアルゴリズム

ステップ 1: LLM を呼び出す(リトライ付き)

callLLMWithRetry(messages, tools, signal)
  • 最大 MAX_LLM_RETRIES(2)回試行
  • リトライ遅延: LLM_RETRY_DELAY_MS * (attempt + 1)(1 秒、2 秒、3 秒と増加)
  • リトライ条件: LLM が内容なしで finishReason === 'error' を返す、または例外をスローする
  • すべての試行が失敗した後、エラーメッセージを返す
  • 各リトライ前に AbortSignal を確認する

ステップ 2: レスポンスを解析する

LLM レスポンスには 3 つの部分が含まれます。

フィールド説明
contentテキスト返信(<think> ブロックを含む場合があります)
toolCallsツール呼び出しリクエストのリスト
finishReasonstop / tool_use / max_tokens / error

ツール呼び出しがない場合:

  1. <think>...</think> ブロックを除去する(DeepSeek 推論形式)
  2. text_delta 進捗イベントを送信する
  3. 保留中のバックグラウンド sub-Agent 結果があるか確認する
  4. バックグラウンド結果が存在し、最大ドレインラウンド(3)を超えていない場合 → 結果を注入してループを続行
  5. それ以外の場合は最終結果を返す

ステップ 3: ツール実行(並列)

同じ LLM レスポンス内のすべてのツール呼び出しは 並列 に実行されます(Promise.all)。各ツール呼び出しは、次のセキュリティパイプラインを通過します。

graph LR
TC["Tool call request"] --> Abort{"Check Abort"}
Abort -->|Aborted| Err1["Return error"]
Abort -->|Not aborted| NS{"Native search skip?"}
NS -->|Yes| Skip["Skip (provider handles)"]
NS -->|No| Chan{"Channel role check"}
Chan -->|Denied| Err2["No permission"]
Chan -->|Allowed| Hook{"PreToolUse Hook"}
Hook -->|deny| Err3["Hook denied"]
Hook -->|allow| FW{"ExecutionFirewall"}
FW -->|blocked| Err4["Firewall denied"]
FW -->|allowed| GA{"GuardianAgent"}
GA -->|blocked| Err5["Guardian denied"]
GA -->|allowed| Perm{"Permission Callback"}
Perm -->|deny| Err6["User denied"]
Perm -->|allow| Exec["Execute tool<br/>5 minute timeout"]
Exec --> PostHook["PostToolUse Hook<br/>(fire-and-forget)"]

セキュリティパイプラインの詳細な手順:

  1. 中断チェックsignal.aborted が true の場合、ただちに返す
  2. ネイティブ検索のスキップnativeSearchEnabled かつツールがネイティブ検索の場合、ローカル実行をスキップする
  3. Channel ロールチェックchannelUserPermissions.canUseTool === false の場合、すべてのツールをブロックする
  4. PreToolUse Hook — 登録済みフックを実行し、behavior === 'deny' の場合はブロックする
  5. ExecutionFirewall — ファイルパスとコマンドが拒否ゾーンに触れていないか確認する
  6. GuardianAgent — LLM によるセキュリティ評価(有効な場合)
  7. 権限コールバック — 機密性の高いツールはユーザー確認を必要とする
  8. 実行 — タイムアウト付きのツール実行(Promise.race 対タイムアウト Promise)
  9. PostToolUse Hook — 非同期トリガー、非ブロッキング(fire-and-forget)

ステップ 4: 結果処理

  1. 出力の切り詰めTOOL_RESULT_MAX_CHARS(50KB)を超える結果は切り詰められる
  2. YieldSignal チェック — ツールが YieldSignal をスローした場合、ただちにループを終了する
  3. イテレーション完了コールバックawait onIterationComplete() がメッセージを DB に保存する
  4. メッセージへ追加 — ツール結果を role: 'tool' メッセージとして追加する
  5. バックグラウンド結果のドレイン — 完了したバックグラウンド sub-Agent 結果をメッセージリストへ注入する

ステップ 5: ループ制御

次のいずれかの終了条件を満たすまで、ステップ 1 に戻ります。

終了条件finishReason
LLM がプレーンテキストを返す(ツール呼び出しなし)stop
最大イテレーション数に到達max_iterations
AbortSignal がトリガーされたinterrupted
LLM がエラーを返すerror
YieldSignal がトリガーされたstop

定数定義

定数説明
MAX_ITERATIONS40最大ループイテレーション数
TEMPERATURE0.1LLM 温度パラメーター
MAX_TOKENS8192LLM 最大出力トークン数
TOOL_RESULT_MAX_CHARS50,000ツール結果の最大文字数
MAX_LLM_RETRIES2LLM 呼び出しのリトライ回数
LLM_RETRY_DELAY_MS1,000リトライ基本遅延(ms)
DEFAULT_TOOL_EXECUTION_TIMEOUT_MS300,000単一ツール実行タイムアウト(5 分)
PERMISSION_TIMEOUT300,000権限確認タイムアウト(5 分)
MAX_BACKGROUND_DRAIN_ROUNDS3連続バックグラウンドドレインの最大ラウンド数
DEFAULT_MAX_HISTORY100読み込む履歴メッセージの最大数
VERSION0.1.0TinyElf バージョン

バックグラウンド sub-Agent 連携

TinyElf はバックグラウンド sub-Agent の並列実行に対応しています。メインループは次の仕組みでバックグラウンドタスクと連携します。

結果注入

pushBackgroundResult(result: BackgroundAgentResult): void

バックグラウンド sub-Agent の完了後、SubagentManager.setBackgroundCompleteCallback 経由で結果をメインループキューへプッシュします。

ドレイン戦略

  • 各イテレーションの最後にバックグラウンド結果キューを確認する
  • 新しい結果がある場合、[Background agent "label" (runId) outcome] 形式で注入する
  • LLM がプレーンテキストを返したものの保留中のバックグラウンド結果がある場合、ループを続行する(最大 3 ラウンド)
  • 実際のツール呼び出しはドレインカウンターをリセットする

メッセージ永続化

各イテレーション完了時に、onIterationComplete コールバック経由でメッセージを保存します。

  1. MessageBlock[](thinking + tool_use ブロック)を構築する
  2. アシスタントメッセージを保存する(ツール呼び出し情報を含む)
  3. 各ツール結果メッセージを保存する(tool_result ブロックを含む)
  4. IPC 経由で assistantMessage イベントを送信する

最終的なプレーンテキスト返信は saveFinalResult() 経由で別途保存され、実行統計(inputTokens、outputTokens)を含みます。

SDK 二重書き込み(削除済み、2026-05-19)

履歴メモ: TinyElf は当初、Claude SDK とのクロスエンジン相互運用性のためにスキーマを共有する目的で、TinyElfSdkAdapter.convertSingleMessage() により SDK JSONL スタイルへ変換し、各メッセージを sdk_records テーブルへ書き込んでいました。しかし sdk_records メカニズム全体(SDK 側 IPC ミラー + TinyElf 側合成)は 2026-05-19 に終了しました。Claude SDK 側の IPC スキーマがディスク JSONL スキーマと互換性を持たなかったためです(docs/dev/66_sdk_records/01_schema_divergence_investigation.md を参照)。また、SDK 0.3.x は現在、公式の SessionStore インターフェースを提供しています(packages/desktop/app/main/services/agent-core/agent/SqliteSessionStore.ts を参照)。TinyElf は現在、永続化に chat_messages のみを利用しており、SDK ミラーは不要です。

sdkDualWrite 状態フィールド、initSdkDualWrite / initSdkDualWriteForResume / writeSdkRecords メソッド、および TinyElfSdkAdapter.ts ファイルはすべて削除されています。

主要ファイル

ファイルパス説明
Agent ループtinyelf/TinyElfAgentLoop.tsコアループロジック
エンジンtinyelf/TinyElfEngine.tsIEngine 実装
セッションランナーtinyelf/TinyElfSessionRunner.tsセッション実行のオーケストレーション
LLM アダプターtinyelf/TinyElfLLMAdapter.tsLLM 呼び出しの適応
プロンプトビルダーtinyelf/TinyElfPromptBuilder.tsメッセージ構築
tinyelf/types.ts型と定数の定義

すべてのパスは packages/desktop/app/main/services/agent-core/engine/ からの相対パスです。

拡張ポイント

  • カスタムツールタイムアウトTinyElfEngineConfig.toolExecutionTimeout
  • カスタムイテレーション上限TinyElfEngineConfig.maxIterations または maxTurns
  • カスタム温度TinyElfEngineConfig.temperature
  • 思考レベルTinyElfEngineConfig.thinkingLevel(none/low/medium/high)
  • Hook 拡張HookExecutor 経由で PreToolUse / PostToolUse / SessionStart / SessionEnd フックを登録する

関連モジュール

モジュールパス関係
ToolRegistryBuildertinyelf/TinyElfToolRegistryBuilder.tsツールレジストリを構築する
SubagentManagertinyelf/tools/SpawnTool.tsSub-Agent 管理
ExecutionFirewallplatform/security/ExecutionFirewall.tsレイヤー 1 セキュリティ
GuardianAgentplatform/security/GuardianAgent.tsレイヤー 2 セキュリティ
TinyElfPermissionstinyelf/TinyElfPermissions.tsレイヤー 3 セキュリティ