refactor llm_client

This commit is contained in:
Keisuke Hirata 2026-04-05 01:02:31 +09:00
parent 865c89e553
commit ed1db41319
23 changed files with 215 additions and 4692 deletions

View File

@ -14,22 +14,5 @@ insomnia(i6a)は不休のエージェントループを回すためのエージ
## ドキュメント
`llm-worker` の設計ドキュメントは [`crates/llm-worker/docs/`](crates/llm-worker/docs/) に配置されています。
### 設計仕様 (`spec/`)
- [basis](crates/llm-worker/docs/spec/basis.md) — 基盤用語とアーキテクチャ概要
- [timeline](crates/llm-worker/docs/spec/timeline.md) — Timeline抽象レイヤー設計
- [cache_lock](crates/llm-worker/docs/spec/cache_lock.md) — KVキャッシュ最適化Type-stateパターン
- [hooks](crates/llm-worker/docs/spec/hooks.md) — フックライフサイクルと介入システム
- [worker](crates/llm-worker/docs/spec/worker.md) — Workerオーケストレーション設計
- [cancellation](crates/llm-worker/docs/spec/cancellation.md) — 非同期キャンセル機構
- [tools](crates/llm-worker/docs/spec/tools.md) — ツールシステム設計
### 調査資料 (`research/`)
- [openresponses_mapping](crates/llm-worker/docs/research/openresponses_mapping.md) — Open Responsesマッピング
- [llm-streaming](crates/llm-worker/docs/research/2026-01-02-llm-streaming.md) — LLMストリーミング調査
- [provider-event-specs](crates/llm-worker/docs/research/2026-01-02-provider-event-specs.md) — プロバイダイベント仕様
### 計画 (`plan/`)
- [rig_adoption](crates/llm-worker/docs/plan/rig_adoption.md) — rig参照設計の採用計画
- [worker_api](crates/llm-worker/docs/plan/worker_api.md) — Worker API/DSL設計計画
- [要件](crates/llm-worker/docs/requirements.md) — llm-workerに求める性能 (R1-R4)
- [アーキテクチャ](crates/llm-worker/docs/architecture.md) — 3層構成とモジュール配置

View File

@ -0,0 +1,73 @@
# llm-worker アーキテクチャ
## 概要
llm-workerは3層構成でLLMとのインタラクションを管理する。
```
┌─────────────────────────────────────────┐
│ Worker (オーケストレーション) │
│ ターンループ / フック / ツール実行 │
│ Type-state: Mutable ↔ CacheLocked │
└───────────┬─────────────────────────────┘
┌───────────▼─────────────────────────────┐
│ Timeline (イベント処理) │
│ Handler dispatch / Block collectors │
└───────────┬─────────────────────────────┘
┌───────────▼─────────────────────────────┐
│ LLM Client (プロトコル) │
│ Provider (HTTP) / Scheme (変換) │
│ Anthropic / OpenAI / Gemini / Ollama │
└─────────────────────────────────────────┘
```
## モジュール構成
| モジュール | 責務 | 要件 |
|---|---|---|
| `worker` | ターンループ、フック統合、ツール実行、Pause/Resume | R1, R4 |
| `state` | Type-state (Mutable/CacheLocked) | R2 |
| `hook` | Hook trait、10フックポイント | R3, R4 |
| `tool` / `tool_server` | ツール定義・登録・実行 | R3 |
| `timeline` | イベントストリーム処理、Handler dispatch | — |
| `handler` | Handler/Kind trait、ブロック別ハンドラ | — |
| `subscriber` | WorkerSubscriber trait、UI向けイベント配信 | — |
| `llm_client` | LLMプロバイダへのHTTPリクエスト/ストリーミング | — |
| `llm_client/scheme` | プロバイダ固有ワイヤーフォーマット変換 | — |
| `llm_client/providers` | Anthropic, OpenAI, Gemini, Ollama実装 | — |
## データフロー
### リクエスト(送信)
```
Worker.history (Vec<Item>)
→ build_request() → Request { items, tools, config }
→ Scheme.build_request() → プロバイダ固有JSON
→ Provider.stream() → HTTP POST
```
### レスポンス(受信)
```
HTTP SSE bytes
→ Provider → SSE events
→ Scheme.parse_event() → Event (統一型)
→ Timeline.dispatch() → Handler.on_event()
→ TextBlockCollector / ToolCallCollector
→ Worker: 履歴に追加、ツール実行判定
```
## 内部型
### Item (会話履歴の単位)
- `Item::Message` — テキストメッセージ (user/assistant)
- `Item::ToolCall` — ツール呼び出し
- `Item::ToolResult` — ツール実行結果
- `Item::Reasoning` — 思考 (Extended Thinking)
### Event (ストリーミングイベント)
- Meta: `Ping`, `Usage`, `Status`, `Error`
- Block: `BlockStart``BlockDelta`* → `BlockStop` / `BlockAbort`
単一の `Event` 型が全層で共有される(`llm_client::event` で定義、他層はre-export

View File

@ -1,207 +0,0 @@
# rig 参照設計の導入実装方針
## 目的
`llm_worker_rs` の既存方針Timeline中心、Worker主導を維持しながら、`rig` 由来で有効だった設計を段階導入する。
対象は以下の5項目。
1. Tool 実行を `ToolServer` 的に分離する
2. Hook をストリーミング粒度まで拡張する
3. Typed output構造化出力の第一級 API 化
4. Provider 固有最適化を provider 層で吸収する
5. GenAI telemetry を標準実装にする
---
## 1. Tool 実行を `ToolServer` 的に分離する ✅
### 目的
Worker から「ツール登録・呼び出し・定義解決・動的選択」の責務を分離し、Worker は turn orchestration と hook 制御に集中させる。
### 実装状況: ✅ 完了
- `tool_server.rs``ToolServer` / `ToolServerHandle` を実装済み。
- `Worker``ToolServerHandle` を保持し、直接 `Tool` マップを持たない設計に移行済み。
- `Worker::register_tool()` は内部で `ToolServerHandle::register_tool()` を呼ぶ形に統一済み。
- ツール実行は `ToolServerHandle::call_tool(name, args)` 経由に統一済み。
- `tool_definitions_sorted()` で決定的順序のツール定義送信を実装済み。
- 単体テスト(重複登録・未登録呼び出し・ソート順)を `tool_server.rs` 内に実装済み。
### 計画との差分
- `ToolExecutor` trait 抽象は未導入。現状 `ToolServerHandle` が具象型として直接使われている。remote tool / MCP 対応時に trait 化する余地あり。
---
## 2. Hook をストリーミング粒度まで拡張する ✅
### 目的
Text/Tool delta を受けた時点でフック介入できるようにし、監査・UI 連携・早期停止を改善する。
### 実装状況: ✅ 完了
- `hook.rs` に以下の streaming 系イベント種別を追加済み:
- `OnTextDelta` (入力: `TextDeltaContext`)
- `OnToolCallDelta` (入力: `ToolCallDeltaContext`)
- `OnStreamChunk` (入力: `StreamChunkContext`)
- `OnStreamComplete` (入力: `StreamCompleteContext`)
- 戻り値は `StreamHookResult { Continue | Abort(String) | Pause }` で統一済み。
- `Worker` に個別登録 API を実装済み:
- `add_on_text_delta_hook()`
- `add_on_tool_call_delta_hook()`
- `add_on_stream_chunk_hook()`
- `add_on_stream_complete_hook()`
- `HookRegistry` に全 streaming hook のストレージを追加済み。
- `worker.rs` の stream dispatch ループ内で `BlockDelta` 受信時に各 hook を順序保証付きで呼び出し済み。
- `Abort` / `Pause` の制御線が Worker の run ループに接続済み。
### 計画との差分
- 高レベル統合 APIsubscriber/DSL`worker_api_plan` 側で別途進行中。
- 実行順序仕様の文書化は未完了(コード上は登録順実行を実装済み)。
---
## 3. Typed output構造化出力の第一級 API 化 ⬚
### 目的
「テキストを返して呼び出し側で JSON parse」から脱却し、型付きレスポンスを API レベルで保証する。
### 実装状況: ⬚ 未着手
- `run_typed<T>()` API は未実装。
- `output_schema` の request builder 統合は未実装。
- エラー種別(`StructuredOutputUnsupported` / `Deserialize` / `Empty`)は未追加。
### 次のステップ
1. `output_schema` を Worker request builder に追加
2. typed 実行 API と decode ロジックを実装
3. provider capability 判定を `llm_client` 層に追加
4. エラー型とユーザー向けメッセージを整理
---
## 4. Provider 固有最適化を provider 層で吸収する 🔄
### 目的
キャッシュ、beta header、細粒度 stream 差分などの provider 特有処理を Worker から排除する。
### 実装状況: 🔄 部分的に進行
- `llm_client/providers/*` に Anthropic / OpenAI / Gemini / Ollama の provider 実装が存在する。
- `llm_client/scheme/*` に各 provider の request/events 変換が分離済み。
- Worker は provider 非依存の `Event` を Timeline 経由で処理する設計に移行済み。
- ただし `ProviderOptions` 型や capability trait は未導入。
### 次のステップ
1. provider capability trait を追加
2. 既存 provider 実装を capability 駆動へ移行
3. Worker 側に残る provider 分岐があれば削除
---
## 5. GenAI telemetry を標準実装にする ⬚
### 目的
運用時に必要な追跡情報turn、tool call、usage、latency、abort reasonを一貫して記録可能にする。
### 実装状況: ⬚ 未着手
- `tracing` クレートは依存に含まれており、`worker.rs` 内で `debug!` / `info!` / `trace!` / `warn!` マクロが使用されている。
- ただし GenAI semantic conventions に沿った構造化 span は未実装。
- `TelemetryConfig` / telemetry モジュールは未追加。
### 次のステップ
1. telemetry モジュール追加field key 定義)
2. Worker/timeline/tool/hook に span 計装
3. usage と stop reason を終了イベントで集約
4. ログとメトリクス出力の最小 adapter を実装
---
## 導入順序(更新版)
1. ~~ToolServer 分離~~
2. ~~Streaming Hook 拡張~~
3. Telemetry 標準化 ⬚
4. Typed output API ⬚
5. Provider capability 移行の仕上げ 🔄
---
## チケット分解(実装順・進捗付き)
### Epic A: ToolServer 分離 ✅
| ID | タイトル | 状態 | 備考 |
| --- | --- | --- | --- |
| A1 | ToolServer 最小骨格の追加 | ✅ | `tool_server.rs``ToolServer` / `ToolServerHandle` 実装済み |
| A2 | Worker の tool map を ToolServerHandle に置換 | ✅ | `Worker``ToolServerHandle` を保持。`register_tool` / `call_tool` / `tool_definitions_sorted` 全て経由 |
| A3 | Hook context を ToolServer 参照へ接続 | ✅ | `pre_tool_call` / `post_tool_call``ToolCallContext``meta` / `tool``ToolServerHandle::get_tool()` から取得 |
| A4 | KV キャッシュ保護ルールの実装 | ✅ | `CacheLocked` 型状態パターンで実装。`lock()` 後は prefix の変更が型レベルで防止される |
| A5 | ToolServer 単体/統合テスト追加 | ✅ | `tool_server.rs` 内に重複登録・未登録呼び出し・ソート順のテスト実装済み |
### Epic B: Streaming Hook 拡張 ✅
| ID | タイトル | 状態 | 備考 |
| --- | --- | --- | --- |
| B1 | Hook event 種別追加delta/stream | ✅ | `OnTextDelta` / `OnToolCallDelta` / `OnStreamChunk` / `OnStreamComplete` 定義済み |
| B2 | Timeline dispatch に hook 呼び出し点追加 | ✅ | `BlockDelta` 受信時に hook 実行。stream dispatch ループ内に組み込み済み |
| B3 | Abort/Pause 制御線の接続 | ✅ | `StreamHookResult``Abort` / `Pause` が Worker の run ループに接続済み |
| B4 | 実行順序仕様と回帰テスト | 🔄 | コード上は登録順実行を実装。仕様文書化・専用回帰テストは未完了 |
### Epic C: Telemetry 標準化 ⬚
| ID | タイトル | 状態 | 備考 |
| --- | --- | --- | --- |
| C1 | telemetry モジュールと semantic key 定義 | ⬚ | 未着手 |
| C2 | worker/turn/llm/tool/hook span 計装 | ⬚ | 未着手。`tracing` の基本利用はあるが構造化 span は未実装 |
| C3 | マスキング設定と no-op 運用 | ⬚ | 未着手 |
| C4 | telemetry テスト/サンプル整備 | ⬚ | 未着手 |
### Epic D: Typed Output API ⬚
| ID | タイトル | 状態 | 備考 |
| --- | --- | --- | --- |
| D1 | Request builder に output_schema 追加 | ⬚ | 未着手 |
| D2 | `run_typed<T>` / `run_typed_with_history<T>` 実装 | ⬚ | 未着手 |
| D3 | 非対応 provider のフォールバックとエラー分離 | ⬚ | 未着手 |
| D4 | typed 出力の回帰テスト | ⬚ | 未着手 |
### Epic E: Provider Capability 移行 🔄
| ID | タイトル | 状態 | 備考 |
| --- | --- | --- | --- |
| E1 | provider capability trait 導入 | ⬚ | 未着手 |
| E2 | Anthropic/OpenAI/Gemini の capability 移行 | 🔄 | scheme 層での request/events 分離は完了。capability trait は未導入 |
| E3 | Worker 側 provider 分岐の削除 | 🔄 | Worker は Event ベースで provider 非依存に近いが、完全な分離は未検証 |
| E4 | snapshot テストと責務ドキュメント更新 | ⬚ | 未着手 |
---
## チケット運用ルール
- 各 ticket は `feature/<ID>-...` ブランチで実装する。
- 依存 ticket が `main` に入るまで、後続 ticket は着手しない。
- 各 ticket で最低 1 つ回帰テストを追加する(ドキュメント専用 ticket を除く)。
- `CacheLocked` の prefix 不変性を崩す変更は、A4 と同時に防止テストを追加する。
- tool 定義のリクエスト化は決定的順序にする(内部保持が `HashMap` でも送信順は固定)。
## 非目標
- 既存 Worker API の全面破壊的変更
- 1回の PR で5項目を同時に実装
- Open Responses 完全準拠の即時達成
## 備考
`llm_worker_rs` は Timeline 駆動設計が中核であり、この方針は維持する。`rig` の設計は部品として参照し、全体アーキテクチャは置換しない。

View File

@ -1,147 +0,0 @@
# Worker API/DSL 実装計画
## 目的
- [Open Responses](https://www.openresponses.org)(以後"OR")に準拠した正規化を前提に、
Item/Part の2段スコープを扱える Worker API を設計する。
- APIの煩雑化を防ぐため、worker.on_xxx として公開するのを避けつつ、
Text/Thinking/Tool など型の違いを静的に扱える DSL を提供する。
## 方針
- 内部は Timeline が Event を正規化し、Item/Part/Meta
を単一ストリームとして扱う。
- API では Item/Part 型ごとに ctx を持てるようにし、DSL
で記述の冗長さを削減する。
- まず macro_rules! 版を作り、必要なら proc-macro に拡張する。
- Item/Part の型パラメータはクレートが公開する Kind 型を使う。
## 仕様の前提
- Item は OR の item (message, function_call, reasoning など) に対応する。
- Part は OR の content part (output_text, reasoning_text など) に対応する。
- Item は必ず start/stop を持つ。Part は Item 内で複数発生し得る。
- Item/Part の型指定は `Item<Message>` / `Part<ReasoningText>` のように書く。
---
## 設計ステップ
### 1. 内部イベントモデルの整理 ✅
- Event を Item/Part/Meta の3層に整理する。
#### 実装状況: ✅ 完了
`timeline/event.rs` に3層のイベントモデルを実装済み:
- **Meta イベント**: `Ping`, `Usage`, `Status`, `Error`
- **Block イベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
- Block 種別として `Text`, `Thinking`, `ToolUse`, `ToolResult` を定義
- `DeltaContent``Text(String)`, `Thinking(String)`, `InputJson(String)` を区別
#### 計画との差分
- 計画の「ItemEvent / PartEvent は型パラメータで区別する」方針ではなく、`BlockType` enum + `DeltaContent` enum で区別する設計を採用。Item/Part の2段ではなく Block/Delta の2段として実装。
- OR の Item 型は `llm_client/types.rs``Item` enum で直接モデル化済み(`Message`, `FunctionCall`, `FunctionCallOutput`, `Reasoning`)。
### 2. スコープの二段化 ✅
- Item ctx: Item 型ごとに1つ
- Part ctx: Part 型ごとに1つ
#### 実装状況: ✅ 完了Block/Handler スコープとして実装)
`handler.rs``Handler<K: Kind>` trait で Block 単位のスコープを実装済み:
- `type Scope: Default` で Handler ごとのスコープ型を定義
- `on_event(&mut self, scope: &mut Self::Scope, event: &K::Event)` でスコープ付きイベント処理
- `timeline.rs``ErasedHandler``start_scope()` / `end_scope()` のライフサイクル管理
#### 計画との差分
- 計画の「Item ctx + Part ctx の二段」ではなく、Block 単位の単一スコープとして実装。
- Block 開始で `Scope::default()` 生成、Block 終了で破棄する方式。Item/Part の入れ子構造は持たない。
- これは十分実用的であり、追加の階層化は必要に応じて将来検討。
### 3. Handler trait の再定義 ✅
- Item/Part を型で指定できる trait を導入する。
#### 実装状況: ✅ 完了Kind/Handler として実装)
`handler.rs` に以下を実装済み:
- `Kind` trait: イベント型を関連型 `type Event` で指定するマーカー trait
- `Handler<K: Kind>` trait: Kind ごとのイベント処理
- Block Kind 定義:
- `TextBlockKind` (Event = `TextBlockEvent { Start | Delta | Stop }`)
- `ThinkingBlockKind` (Event = `ThinkingBlockEvent { Start | Delta | Stop }`)
- `ToolUseBlockKind` (Event = `ToolUseBlockEvent { Start | InputJsonDelta | Stop }`)
- Meta Kind 定義:
- `UsageKind`, `PingKind`, `StatusKind`, `ErrorKind`
#### 計画との差分
- 計画の `ItemHandler<I>` / `PartHandler<I, P>` 構造ではなく、`Handler<K: Kind>` の単一 trait で統一。
- PartHandler に ItemCtx を必須で渡す設計は採用せず、Block 単位のフラットな構造。
### 4. Timeline との結合 ✅
- Timeline は BlockStart で Scope を生成、BlockStop で Scope を破棄。
#### 実装状況: ✅ 完了
`timeline/timeline.rs` に以下を実装済み:
- `Timeline``ErasedHandler<K>` のリストを保持
- `dispatch(&Event)` でイベントを各 Handler にディスパッチ
- `BlockStart``start_scope()`, `BlockStop` / `BlockAbort``end_scope()`
- 実用的な collector として `TextBlockCollector`, `ToolCallCollector` を実装済み
- `Worker` が Timeline を所有し、stream ループ内で `timeline.dispatch()` を呼び出し
`subscriber.rs``WorkerSubscriber` trait で高レベルのイベント購読を提供:
- `on_text_block()`, `on_tool_use_block()` (スコープ付き)
- `on_usage()`, `on_status()`, `on_error()` (メタイベント)
- `on_text_complete()`, `on_tool_call_complete()` (完了イベント)
- `on_turn_start()`, `on_turn_end()` (ターン制御)
### 5. DSL (macro_rules!) の導入 ⬚
- 宣言的 DSL を提供する。
#### 実装状況: ⬚ 未着手
- `macro_rules!` による `handler!` DSL は未実装。
- 代わりに `llm-worker-macros` クレートで `#[tool_registry]` / `#[tool]` proc-macro を実装済み(ツール定義の自動生成用)。
- Handler 定義の DSL は未提供。現状は trait を直接実装する方式。
#### 次のステップ
- Handler 定義の冗長さが問題になった時点で `handler!` DSL を導入する。
- 現状の `Handler<K>` trait 直接実装 + `WorkerSubscriber` trait の組み合わせで多くのユースケースをカバーできているため、優先度は低い。
### 6. 拡張ポイント 🔄
- 追加 Part (output_image など) を DSL に追加しやすい形にする。
#### 実装状況: 🔄 部分的
- `BlockType` enum に新しい種別を追加し、対応する `Kind``Event` を定義すれば拡張可能な設計。
- `ThinkingBlockKind` が後から追加された実績あり。
- proc-macro への移行は未実施(現時点では不要)。
---
## 実装順序(更新版)
1. ~~Event/Item/Part の型定義の整理~~
2. ~~Item/Part ctx を持つ Timeline 実装~~ ✅ (Block/Scope として)
3. ~~Handler trait の定義・既存コードの移行~~
4. macro_rules! DSL の実装 ⬚
5. ~~既存ユースケースの移植~~ ✅ (WorkerSubscriber 経由)
---
## TODO更新版
- ~~Item と Part の型対応表を整理する~~`Item` enum + `BlockType` + `Kind` で対応完了
- ~~OR と既存 llm_client の差分を再確認する~~`Item` 型が OR ネイティブに移行済みMessage / FunctionCall / FunctionCallOutput / Reasoning
- ~~Tool args の delta を OR 拡張として扱うか検討する~~`ToolUseBlockEvent::InputJsonDelta` として実装
- macro_rules! で表現可能な DSL の最小文法を確定する ⬚ (優先度低: WorkerSubscriber で大半のユースケースをカバー済み)

View File

@ -0,0 +1,48 @@
# llm-worker 要件
## 前提
a. userメッセージを追加しなくてもagentの途中ママ投げれば、AIはそれを自身の生成途中と認識して普通に継続する
b. KVキャッシュは速度・効率の面で有利で、コンテキストの事後改変はキャッシュヒット率を大幅に下げる
c. ツール・フックの基本的なスキーマ自動化を提供する
## 要件
### R1: Resume/Pause
メッセージの送信と生成のResume、一時停止/再開。
- `Worker::run()` でターンを開始
- フックから `Pause` を返してターンを一時停止
- `Worker::resume()` でユーザーメッセージを追加せず継続
- AIは中断を認識せず、継続として処理する
**実装**: `worker.rs``resume()`, `get_pending_tool_calls()`, `WorkerResult::Paused`
### R2: 暗黙的KVキャッシュ保証
キャッシュを破壊しうる操作を明示的にブロックせずとも、いつの間にかキャッシュ破壊してた状態にはしたくない。
- Type-stateパターン`Mutable` / `CacheLocked`)でコンパイル時に保証
- `Worker::lock()` でCacheLocked状態に遷移
- CacheLocked状態ではシステムプロンプトや履歴の変更APIが型レベルで利用不可
- `locked_prefix_len` でプレフィックスの不変性を追跡
**実装**: `state.rs` (sealed trait), `worker.rs` (state-specific impl blocks)
### R3: ツール・フックスキーマ自動化
- `#[tool]` マクロでツール定義を自動生成
- `#[tool_registry]` マクロでツールサーバーを自動構成
- `Hook` traitで10種のフックポイント
**実装**: `llm-worker-macros/`, `tool.rs`, `tool_server.rs`, `hook.rs`
### R4: フックは上層の関心事
フックはLLMクライアント層ではなく、Workerオーケストレーション層に配置する。
- LLMクライアント (`llm_client/`) はストリーミングとプロトコルのみ
- Worker層でフック実行、ツール統合、Pause/Resume制御
**実装**: `worker.rs` (hook integration), `hook.rs` (trait definitions)

View File

@ -1,143 +0,0 @@
# llm-worker
LLMを用いた自律的なワーカーを構築するためのライブラリ。
ツールの定義・呼び出し、コンテキスト管理、ストリーミングイベント処理、フック制御などを責務にもつ。
ワークスペース `insomnia` に属し、以下の3クレートで構成される:
| クレート | 概要 |
|---|---|
| `insomnia` | アプリケーション本体 |
| `llm-worker` | LLMワーカーライブラリ (本クレート) |
| `llm-worker-macros` | `#[tool_registry]` / `#[tool]` 手続きマクロ |
## 用語定義
- **Item**:
会話の基本単位。Open Responses仕様に準じた列挙型で、`Message`・`FunctionCall`・`FunctionCallOutput`・`Reasoning`の4バリアントを持つ。従来のメッセージベースAPIと異なり、ツール呼び出しや思考をファーストクラスのItemとして扱う。
- **ContentPart**:
Message Item内のコンテンツ要素。`InputText`(ユーザー入力)・`OutputText`(アシスタント出力)・`Refusal`(拒否)を区別する。
- **ブロック (Block)**:
モデルが「開始→デルタ→終了」で囲む一塊の出力。OpenAIの`response.output_item`やAnthropicの`content_block`、Geminiの`candidates[].content.parts`などが該当する。`BlockType`として`Text`・`Thinking`・`ToolUse`・`ToolResult`が定義されている。
- **メタイベント (Meta Event)**:
`Ping`・`Usage`・`Status`・`Error`など、ブロックに属さない補助イベント群。ステータス更新やハートビートとして処理される。
- **Event**:
LLMからのストリーミングレスポンスを表現する統一型。メタイベントとブロックイベント(`BlockStart`・`BlockDelta`・`BlockStop`・`BlockAbort`)から成る。`llm_client`層と`event`モジュールの双方に定義が存在する。
- **Worker**:
LLMとのインタラクションを管理する中心コンポーネント。ツール実行ループ、フック呼び出し、ストリーミングイベントのディスパッチを統括する。
以降の仕様では「ブロック」を上記の生成・ツール単位の総称として扱う。
## アーキテクチャ
モジュール構成概念図:
```plaintext
llm-worker
├── worker # Workerコア (実行ループ、状態管理)
├── state # Type-stateパターン (Mutable / CacheLocked)
├── tool # Tool trait, ToolMeta, ToolDefinition
├── tool_server # ToolServer (ツールのレジストリと実行)
├── hook # Hook trait, HookRegistry (10種のフックポイント)
├── handler # Kind/Handler trait (イベントディスパッチ)
├── subscriber # WorkerSubscriber trait (UI向けイベント購読)
├── event # Worker層の公開Event型
├── message # Item/ContentPart/Roleのre-export
├── timeline # イベントストリームの状態管理とHandlerディスパッチ
│ ├── event # Timeline内部イベント型
│ ├── timeline # Timelineコア
│ ├── text_block_collector # テキスト収集Handler
│ └── tool_call_collector # ツール呼び出し収集Handler
└── llm_client # LLMクライアント層
├── client # LlmClient trait
├── event # llm_client層のEvent型
├── error # ClientError
├── types # Item, ContentPart, Role, Request, RequestConfig, ToolDefinition
├── scheme # APIスキーマ変換
│ ├── openai # OpenAI Chat Completions API
│ ├── anthropic # Anthropic Messages API
│ └── gemini # Google Gemini API
└── providers # プロバイダ固有クライアント
├── openai # OpenAI
├── anthropic # Anthropic
├── gemini # Google Gemini
└── ollama # Ollama (ローカルLLM)
```
OpenAI互換のプロバイダでスキーマを使い回せるよう、`scheme`と`providers`モジュールは分離されている。
### scheme層
単純な変換を責務とするスキーマを定義する。
- リクエスト変換: `Request`(SystemPrompt + Items + Tools + RequestConfig) → プロバイダ固有のリクエストJSON
- レスポンス変換: SSEイベント → 型付き`Event`構造体のストリーム
各APIスキーマ(OpenAI / Anthropic / Gemini)ごとに実装を持ち、APIスキーマに準じたパース・バリデーションを行う。
### llm_client (providers) 層
`LlmClient` traitを実装する各プロバイダクライアントがリクエストを送信し、差異が吸収され統一された`Event`ストリームを出力する。
ストリーミング中のバッファリング(ToolArguments の累積等)もこの層で行う。
`ConfigWarning`により、プロバイダがサポートしない設定オプションを警告として通知する仕組みを持つ。
### timeline層
`llm_client`からのイベントストリームを受信し、登録された`Handler`にディスパッチする。
`Kind` traitがイベント型を定義し、`Handler<K: Kind>` traitが`Scope`(ブロックのライフサイクルに対応する状態)を持つイベント処理を行う。組み込みHandlerとして`TextBlockCollector`と`ToolCallCollector`が提供される。
### worker層
`Worker`はType-stateパターンにより`Mutable`状態と`CacheLocked`状態を持つ。
- **Mutable**: システムプロンプトの設定変更、メッセージ履歴の編集、ツール・フックの登録が可能
- **CacheLocked**: `Worker::lock()`で遷移。LLM APIのKVキャッシュヒット率を最大化するため、既存履歴の変更を制限する
`WorkerSubscriber` traitにより、テキスト生成やツール呼び出しのストリーミングイベントをUI等に配信できる。
## Tools
各種プロバイダのLLMのAPI仕様として存在するTool CallやFunction Callingを`Tool` traitとして統一的に扱う。
- **`ToolMeta`**: ツール名・説明・入力スキーマ(JSON Schema)を保持する不変のメタ情報。Worker登録後は変更されない。
- **`ToolDefinition`**: `Fn() -> (ToolMeta, Arc<dyn Tool>)`型のファクトリ。Worker登録時に一度呼び出され、メタ情報とインスタンスがセッションスコープでキャッシュされる。
- **`Tool` trait**: `async fn call(&self, arguments: Value) -> Result<String, ToolError>`を持つ非同期trait。セッション中に状態を保持できる。
- **`ToolServer`**: 登録済みツールのレジストリと実行を管理するインメモリサーバー。
`llm-worker-macros`クレートが提供する`#[tool_registry]` / `#[tool]`手続きマクロにより、`impl`ブロック上のメソッドから`Tool` trait実装を自動生成する。
## Hooks
Claude Codeに存在するようなHooksと同様のターン制御・介入機構を搭載する。
以下の10種のフックポイントが定義されている:
| フックポイント | タイミング | 戻り値による制御 |
|---|---|---|
| `OnPromptSubmit` | プロンプト送信時 | Continue / Cancel |
| `PreLlmRequest` | LLMリクエスト直前 | Continue / Cancel |
| `PreToolCall` | ツール実行直前 | Continue / Skip / Abort / Pause |
| `PostToolCall` | ツール実行直後 | Continue / Abort |
| `OnTurnEnd` | ターン終了時 | Finish / ContinueWithMessages / Paused |
| `OnAbort` | 中断時 | (通知のみ) |
| `OnTextDelta` | テキストデルタ受信時 | (ストリーミング) |
| `OnToolCallDelta` | ツール引数デルタ受信時 | (ストリーミング) |
| `OnStreamChunk` | ストリームチャンク受信時 | (ストリーミング) |
| `OnStreamComplete` | ストリーム完了時 | (ストリーミング) |
フックは`Hook` traitとして表現され、`HookRegistry`で管理される。
## ストリーミングイベント処理
イベントストリームを扱う設計目標として、Text / InputJSON / Thinkingのデルタと、[細粒度ツールストリーミング](https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming)を含む、デルタの低遅延・リアルタイム処理がある。
ブロックのライフサイクル: `BlockStart``BlockDelta`(複数回) → `BlockStop` / `BlockAbort`
`DeltaContent`の種別:
- `Text(String)` - テキスト生成のデルタ
- `Thinking(String)` - 思考(Extended Thinking等)のデルタ
- `InputJson(String)` - ツール引数JSONの部分文字列デルタ

View File

@ -1,189 +0,0 @@
# KVキャッシュを中心とした設計
LLMのKVキャッシュのヒット率を重要なメトリクスであるとし、APIレベルでキャッシュ操作を中心とした設計を行う。
## 前提
リクエスト間キャッシュ(Context Caching)は、複数のリクエストで同じ入力トークン列が繰り返された際、プロバイダ側が計算済みの状態を再利用することでレイテンシと入力コストを下げる仕組みである。
キャッシュは主に**先頭一致 (Common Prefix)** によってHitするため、前提となるシステムプロンプトや、会話ログの過去部分前方を変化させると、以降のキャッシュは無効となる。
## 要件
1. **前方不変性の保証 (Prefix Immutability)**
* 後方に会話が追加されても、前方のデータシステムプロンプトや確定済みのメッセージ履歴が変化しないことをAPIレベルで保証する。
* これにより、意図しないキャッシュミスCache Missを防ぐ。
2. **データ上の再現性**
* コンテキストのデータ構造が同一であれば、生成されるリクエスト構造も同一であることを保証する。
* シリアライズ結果のバイト単位の完全一致までは求めないが、論理的なリクエスト構造は保たれる必要がある。
## アプローチ: Type-state Pattern
RustのType-stateパターンを利用し、Workerの状態によって利用可能な操作をコンパイル時に制限する。
### 1. 状態定義(`state.rs`
`WorkerState`トレイトはsealedパターンで実装され、外部からの実装を防ぐ:
```rust
pub trait WorkerState: private::Sealed + Send + Sync + 'static {}
mod private {
pub trait Sealed {}
}
```
* **`Mutable` (初期状態)**
* 自由な編集が可能な状態。
* システムプロンプトの設定・変更が可能。
* メッセージ履歴の初期構築(ロード、編集、クリア)が可能。
* ツール・フックの登録が可能。
* リクエスト設定(`max_tokens`, `temperature`, `top_p`, `top_k`, `stop_sequences`)の変更が可能。
* **`CacheLocked` (キャッシュ保護状態)**
* キャッシュの有効活用を目的とした、前方不変状態。
* **システムプロンプトの変更不可**。
* **既存メッセージ履歴の変更不可**`run()`による末尾追記のみ許可)。
* 実行(`run`)はこの状態で行うことを推奨する。
両状態とも`Debug`, `Clone`, `Copy`, `Default`を実装する。
### 2. Worker構造体
`Worker`は状態パラメータ`S: WorkerState`を持ち、デフォルトは`Mutable`:
```rust
pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
client: C,
timeline: Timeline,
text_block_collector: TextBlockCollector,
tool_call_collector: ToolCallCollector,
tool_server: ToolServerHandle,
hooks: HookRegistry,
system_prompt: Option<String>,
history: Vec<Item>,
locked_prefix_len: usize, // ロック時の履歴長
turn_count: usize,
turn_notifiers: Vec<Box<dyn TurnNotifier>>,
request_config: RequestConfig,
last_run_interrupted: bool,
cancel_tx: mpsc::Sender<()>,
cancel_rx: mpsc::Receiver<()>,
_state: PhantomData<S>,
}
```
`Worker`自身がコンテキスト(履歴)のオーナーとなり、状態によってアクセサを制限する。
### 3. 状態遷移とAPI
#### 共通API全状態で利用可能
```rust
impl<C: LlmClient, S: WorkerState> Worker<C, S> {
pub async fn run(&mut self, user_input: impl Into<String>) -> Result<WorkerResult, WorkerError>;
pub fn history(&self) -> &[Item]; // 参照のみ
pub fn system_prompt(&self) -> Option<&str>;
pub fn cancel_token(&self) -> CancelToken; // キャンセルトークン取得
// ... その他参照系メソッド
}
```
#### Mutable限定API
```rust
impl<C: LlmClient> Worker<C, Mutable> {
pub fn new(client: C) -> Self;
// システムプロンプト
pub fn system_prompt(self, prompt: impl Into<String>) -> Self; // ビルダー
pub fn set_system_prompt(&mut self, prompt: impl Into<String>);
// 履歴操作
pub fn history_mut(&mut self) -> &mut Vec<Item>;
pub fn set_history(&mut self, items: Vec<Item>);
pub fn with_item(self, item: Item) -> Self;
pub fn push_item(&mut self, item: Item);
pub fn with_items(self, items: impl IntoIterator<Item = Item>) -> Self;
pub fn extend_history(&mut self, items: impl IntoIterator<Item = Item>);
pub fn clear_history(&mut self);
// ツール登録
pub fn register_tool(&mut self, factory: WorkerToolDefinition) -> Result<(), ToolRegistryError>;
pub fn register_tools(&mut self, factories: impl IntoIterator<Item = WorkerToolDefinition>) -> Result<(), ToolRegistryError>;
// リクエスト設定
pub fn max_tokens(self, max_tokens: u32) -> Self;
pub fn temperature(self, temperature: f32) -> Self;
pub fn top_p(self, top_p: f32) -> Self;
pub fn top_k(self, top_k: u32) -> Self;
pub fn stop_sequence(self, sequence: impl Into<String>) -> Self;
pub fn with_config(self, config: RequestConfig) -> Self;
pub fn validate(self) -> Result<Self, WorkerError>;
// 状態遷移
pub fn lock(self) -> Worker<C, CacheLocked>;
}
```
#### CacheLocked限定API
```rust
impl<C: LlmClient> Worker<C, CacheLocked> {
pub fn locked_prefix_len(&self) -> usize;
pub fn unlock(self) -> Worker<C, Mutable>;
}
```
### 4. 使用例
```rust
// 1. Mutable状態で初期化
let mut worker = Worker::new(client)
.system_prompt("You are a helpful assistant.")
.max_tokens(4096);
// 2. コンテキストの構築Mutableなので自由に変更可
worker.push_item(Item::user_message("Hello"));
worker.register_tool(my_tool)?;
// 3. ロックしてCacheLocked状態へ遷移
// ここまでの履歴長がlocked_prefix_lenとして記録される
let mut locked_worker = worker.lock();
// 4. 利用CacheLocked状態
// 実行は可能。新しいメッセージは履歴の末尾に追記される。
// 前方の履歴やシステムプロンプトは変更できないため、キャッシュヒットが保証される。
locked_worker.run("user input").await?;
// NG操作コンパイルエラー
// locked_worker.set_system_prompt("New prompt");
// locked_worker.history_mut().clear();
// locked_worker.register_tool(another_tool);
// 5. 必要に応じてアンロック(キャッシュ保護は解除される)
let mut mutable_worker = locked_worker.unlock();
mutable_worker.set_system_prompt("New prompt");
```
### 5. lock/unlockの実装
`lock()`と`unlock()`はWorkerの全フィールドを移動して新しい状態のWorkerを構築する。値の所有権移動のため、元のWorkerは使用不可になる。
- `lock()`: `locked_prefix_len`に現在の`history.len()`を記録
- `unlock()`: `locked_prefix_len`を0にリセット
### 6. UsageEventによるキャッシュ監視
`UsageEvent`には`cache_read_input_tokens`と`cache_creation_input_tokens`が含まれており、キャッシュヒット率のモニタリングが可能:
```rust
pub struct UsageEvent {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
```
TimelineのUsageKind Handlerを登録することで、キャッシュの効果を実行時に確認できる。

View File

@ -1,251 +0,0 @@
# 非同期キャンセル仕様
Workerの非同期キャンセル機構についての仕様ドキュメント。
## 概要
`tokio::sync::mpsc`チャネルバッファサイズ1を用いて、別タスクからWorkerの実行を安全にキャンセルできる。Worker内部では`tokio::select!`により、ストリーム受信・ツール実行の各フェーズでキャンセルシグナルを検知する。
## 基本的な使い方
### cancel() メソッドによるキャンセル
```rust
let worker = Arc::new(Mutex::new(Worker::new(client)));
// 実行タスク
let w = worker.clone();
let handle = tokio::spawn(async move {
w.lock().await.run("prompt").await
});
// キャンセルtry_sendによる非同期安全な送信
worker.lock().await.cancel();
```
### cancel_sender() によるキャンセル
ロックを取得せずにキャンセルする場合、事前に`Sender`を取得しておく。
```rust
let worker = Arc::new(Mutex::new(Worker::new(client)));
// ロック中にSenderを取得
let cancel_tx = {
let w = worker.lock().await;
w.cancel_sender()
};
// 実行タスク
let worker_clone = worker.clone();
let task = tokio::spawn(async move {
let mut w = worker_clone.lock().await;
w.run("Tell me a long story").await
});
// 別タスクからキャンセル(ロック不要)
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let _ = cancel_tx.send(()).await;
});
task.await?;
```
## API
| メソッド / フィールド | 説明 |
| --------------------- | --------------------------------------------- |
| `cancel()` | `try_send`でキャンセルをトリガー |
| `cancel_sender()` | `mpsc::Sender<()>`のcloneを返す |
| `is_cancelled()` | キャンセルキューにシグナルがあるか確認 |
| `last_run_interrupted()` | 前回のrunが中断されたかどうか |
## キャンセル検知ポイント
Worker内部には複数のキャンセル検知ポイントが存在する。
### 1. ターンループ先頭
```rust
loop {
if self.try_cancelled() {
self.timeline.abort_current_block();
return Err(WorkerError::Cancelled);
}
// ...
}
```
各ターンの開始時に`try_recv()`でキャンセルキューを確認する。
### 2. ストリーム取得時
```rust
let mut stream = tokio::select! {
stream_result = self.client.stream(request) => stream_result?,
cancel = self.cancel_rx.recv() => {
self.timeline.abort_current_block();
return Err(WorkerError::Cancelled);
}
};
```
LLMクライアントへのリクエスト送信中にキャンセル可能。
### 3. ストリーム受信中
```rust
loop {
tokio::select! {
event_result = stream.next() => {
// イベント処理
}
cancel = self.cancel_rx.recv() => {
self.timeline.abort_current_block();
return Err(WorkerError::Cancelled);
}
}
}
```
ストリーミング中のイベント受信ループで、各イベント間にキャンセルが割り込める。
### 4. ツール並列実行中
```rust
let mut results = tokio::select! {
results = join_all(futures) => results,
cancel = self.cancel_rx.recv() => {
self.timeline.abort_current_block();
return Err(WorkerError::Cancelled);
}
};
```
`join_all`によるツール並列実行中にもキャンセル可能。
## キャンセル時の処理フロー
```
キャンセルシグナル検知
timeline.abort_current_block() // 進行中ブロックの終端処理
last_run_interrupted = true // 中断フラグをセット
Err(WorkerError::Cancelled) を返す
finalize_interruption() // 中断の最終処理
run_on_abort_hooks("Cancelled") // on_abort フック呼び出し
Err(WorkerError::Cancelled) を返す(呼び出し元へ)
```
## キャンセルキューの管理
### drain_cancel_queue
`run_turn_loop()`の開始時に、キューに溜まった古いキャンセルシグナルを排出する。これにより、前回のキャンセルが次回の`run()`に影響することを防ぐ。
```rust
fn drain_cancel_queue(&mut self) {
loop {
match self.cancel_rx.try_recv() {
Ok(()) => continue,
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
}
```
### try_cancelled
ンブロッキングでキャンセル状態を確認する。チャネルがdisconnectedの場合もキャンセル扱いとなる。
```rust
fn try_cancelled(&mut self) -> bool {
match self.cancel_rx.try_recv() {
Ok(()) => true,
Err(TryRecvError::Empty) => false,
Err(TryRecvError::Disconnected) => true,
}
}
```
## 中断状態の管理
### last_run_interrupted フラグ
Workerは`last_run_interrupted`フラグで前回の実行が中断されたかどうかを追跡する。
- `run()` / `resume()` の開始時に`false`にリセット
- エラー発生時に`true`にセット
- `Pause`による中断時にも`true`にセット
- 正常終了(`WorkerResult::Finished`)時に`false`にセット
### finalize_interruption
すべての`run()`/`resume()`の結果は`finalize_interruption()`を経由して返される。結果が`Err`の場合、中断理由を抽出して`on_abort`フックを呼び出す。
```rust
async fn finalize_interruption<T>(&mut self, result: Result<T, WorkerError>) -> Result<T, WorkerError> {
match result {
Ok(value) => Ok(value),
Err(err) => {
self.last_run_interrupted = true;
let reason = match &err {
WorkerError::Aborted(reason) => reason.clone(),
WorkerError::Cancelled => "Cancelled".to_string(),
_ => err.to_string(),
};
self.run_on_abort_hooks(&reason).await?;
Err(err)
}
}
}
```
## on_abort フック
`on_abort`フックはキャンセルだけでなく、あらゆる中断時に発火する。
**入力**: `&mut String` - 中断理由
**発火条件**:
- `WorkerError::Cancelled` -- reason: `"Cancelled"`
- `WorkerError::Aborted(reason)` -- reason: フックが指定した理由
- `WorkerError::Client(e)` -- reason: エラーの表示文字列
- `WorkerError::Tool(e)` -- reason: エラーの表示文字列
- `WorkerError::Hook(e)` -- reason: エラーの表示文字列
```rust
struct CleanupHook;
#[async_trait]
impl Hook<OnAbort> for CleanupHook {
async fn call(&self, reason: &mut String) -> Result<(), HookError> {
tracing::info!("Worker aborted: {}", reason);
Ok(())
}
}
```
## resume() との関係
`resume()`はPause状態からの再開に使用される。内部では`run_turn_loop()`を呼び出し、保留中のツール呼び出しhistoryに`FunctionCall`があるが対応する`FunctionCallOutput`がないもの)を検出して実行を再開する。
`resume()`中もキャンセルは同様に機能し、`finalize_interruption()`経由で`on_abort`フックが発火する。
## WorkerError の種別
| エラー種別 | 発生条件 |
| --------------------- | --------------------------------------------- |
| `Cancelled` | mpscチャネル経由のキャンセルシグナル受信 |
| `Aborted(String)` | フックによるAbort/Cancel、またはstream hookのPause |
| `Client(ClientError)` | LLMクライアントのエラー |
| `Tool(ToolError)` | ツール実行エラー |
| `Hook(HookError)` | フック実行中のエラー |
| `ConfigWarnings(Vec)` | サポートされていない設定オプション |

View File

@ -1,556 +0,0 @@
# Hooks 仕様
## 概要
HookはWorker層でのターン制御に介入するためのメカニズムである。
メッセージ送信・ツール実行・ストリーム処理・ターン終了等の各ポイントで処理を差し込むことができる。
## コンセプト
- **制御の介入**: ターンの進行、メッセージの内容、ツールの実行に対して介入
- **Contextへのアクセス**: Item履歴を読み書き可能
- **非破壊的チェーン**: 複数のHookを登録順に実行、後続Hookへの影響を制御
## Hook一覧
| Hook | タイミング | 主な用途 | 戻り値 |
| -------------------- | -------------------------- | -------------------------- | ----------------------- |
| `on_prompt_submit` | `run()` 呼び出し時 | ユーザーItemの前処理 | `OnPromptSubmitResult` |
| `pre_llm_request` | 各ターンのLLM送信前 | コンテキスト改変/検証 | `PreLlmRequestResult` |
| `pre_tool_call` | ツール実行前 | 実行許可/引数改変 | `PreToolCallResult` |
| `post_tool_call` | ツール実行後 | 結果加工/マスキング | `PostToolCallResult` |
| `on_stream_chunk` | ストリーム各イベント受信後 | 監査/低遅延介入 | `StreamHookResult` |
| `on_text_delta` | Text delta受信時 | 出力監視/中断 | `StreamHookResult` |
| `on_tool_call_delta` | Tool JSON delta受信時 | 引数監視/中断 | `StreamHookResult` |
| `on_stream_complete` | 1回のstream完了時 | サマリ/完了検証 | `StreamHookResult` |
| `on_turn_end` | ツールなしでターン終了直前 | 検証/リトライ指示 | `OnTurnEndResult` |
| `on_abort` | 中断時 | クリーンアップ/通知 | `()` |
## Hook Trait
```rust
#[async_trait]
pub trait Hook<E: HookEventKind>: Send + Sync {
async fn call(&self, input: &mut E::Input) -> Result<E::Output, HookError>;
}
```
## HookEventKind / 制御フロー型
Hookイベントごとに入力型(`Input`)と出力型(`Output`)を分離し、意味のない制御フローを排除する。
```rust
pub trait HookEventKind: Send + Sync + 'static {
type Input;
type Output;
}
```
### イベント種別一覧
```rust
pub struct OnPromptSubmit; // Input: Item, Output: OnPromptSubmitResult
pub struct PreLlmRequest; // Input: Vec<Item>, Output: PreLlmRequestResult
pub struct PreToolCall; // Input: ToolCallContext, Output: PreToolCallResult
pub struct PostToolCall; // Input: PostToolCallContext, Output: PostToolCallResult
pub struct OnTurnEnd; // Input: Vec<Item>, Output: OnTurnEndResult
pub struct OnAbort; // Input: String, Output: ()
pub struct OnTextDelta; // Input: TextDeltaContext, Output: StreamHookResult
pub struct OnToolCallDelta; // Input: ToolCallDeltaContext, Output: StreamHookResult
pub struct OnStreamChunk; // Input: StreamChunkContext, Output: StreamHookResult
pub struct OnStreamComplete; // Input: StreamCompleteContext, Output: StreamHookResult
```
### 制御フロー Result 型
```rust
pub enum OnPromptSubmitResult {
Continue,
Cancel(String),
}
pub enum PreLlmRequestResult {
Continue,
Cancel(String),
}
pub enum PreToolCallResult {
Continue,
Skip,
Abort(String),
Pause,
}
pub enum PostToolCallResult {
Continue,
Abort(String),
}
pub enum OnTurnEndResult {
Finish,
ContinueWithMessages(Vec<Item>),
Paused,
}
pub enum StreamHookResult {
Continue,
Abort(String),
Pause,
}
```
### コンテキスト型
#### ToolCallContext (PreToolCall用)
```rust
pub struct ToolCallContext {
pub call: ToolCall, // ツール呼び出し情報(改変可能)
pub meta: ToolMeta, // 不変メタデータ
pub tool: Arc<dyn Tool>, // 状態アクセス用
}
```
#### PostToolCallContext (PostToolCall用)
```rust
pub struct PostToolCallContext {
pub call: ToolCall, // ツール呼び出し情報
pub result: ToolResult, // 実行結果(改変可能)
pub meta: ToolMeta, // 不変メタデータ
pub tool: Arc<dyn Tool>, // 状態アクセス用
}
```
#### TextDeltaContext (OnTextDelta用)
```rust
pub struct TextDeltaContext {
pub index: usize, // ブロックインデックス
pub delta: String, // テキストデルタ内容
}
```
#### ToolCallDeltaContext (OnToolCallDelta用)
```rust
pub struct ToolCallDeltaContext {
pub index: usize, // ブロックインデックス
pub delta_json_fragment: String, // 部分JSONフラグメント
}
```
#### StreamChunkContext (OnStreamChunk用)
```rust
pub struct StreamChunkContext {
pub event: crate::event::Event, // Worker層の公開イベント
}
```
#### StreamCompleteContext (OnStreamComplete用)
```rust
pub struct StreamCompleteContext {
pub turn: usize, // 現在のターン番号
pub event_count: usize, // このリクエストでのストリームイベント数
}
```
## 呼び出しタイミング
```
Worker::run(user_input)
├── on_prompt_submit ─────────────────────────┐
│ ユーザーItemの前処理・検証 │
最初の1回のみ
│ │
└── loop {
├── pre_llm_request ──────────────────│
│ history の clone に対して改変・検証 │
│ (毎ターン実行) │
│ │
├── LLMリクエスト送信 & ストリーム処理 │
│ │ │
│ ├── on_stream_chunk (各イベント) │
│ ├── on_text_delta (テキストデルタ) │
│ └── on_tool_call_delta (JSONデルタ) │
│ │
├── on_stream_complete │
│ │
├── ツール呼び出しがある場合: │
│ │ │
│ ├── pre_tool_call (各ツールごと・逐次) │
│ │ 実行可否の判定、引数の改変 │
│ │ │
│ ├── ツール並列実行 (join_all) │
│ │ │
│ └── post_tool_call (各結果ごと・逐次) │
│ 結果の確認、加工、ログ出力 │
│ │
├── ツール結果をhistoryに追加 │
│ → ループ先頭へ │
│ │
└── ツールなしの場合: │
│ │
└── on_turn_end ───────────────┘
最終応答のチェックLint/Fmt等
エラーがあればContinueWithMessagesでリトライ
}
※ 中断時は on_abort が呼ばれるfinalize_interruption経由
```
## 各Hookの詳細
### on_prompt_submit
**呼び出しタイミング**: `run()` でユーザーメッセージを受け取った直後最初の1回のみ
**入力**: `&mut Item` - ユーザーItem改変可能
**用途**:
- ユーザー入力のバリデーション
- 入力のサニタイズ・フィルタリング
- ログ出力
- `OnPromptSubmitResult::Cancel` による実行キャンセル(`WorkerError::Aborted`になる)
**例**: 入力のバリデーション
```rust
struct InputValidator;
#[async_trait]
impl Hook<OnPromptSubmit> for InputValidator {
async fn call(
&self,
item: &mut Item,
) -> Result<OnPromptSubmitResult, HookError> {
if let Item::Message { content, .. } = item {
if content.trim().is_empty() {
return Ok(OnPromptSubmitResult::Cancel("Empty input".to_string()));
}
}
Ok(OnPromptSubmitResult::Continue)
}
}
```
### pre_llm_request
**呼び出しタイミング**: 各ターンのLLMリクエスト送信前ループの毎回
**入力**: `&mut Vec<Item>` - historyのclone改変可能、元のhistoryは変更されない
**用途**:
- コンテキストへのシステムメッセージ注入
- Itemのバリデーション
- 機密情報のフィルタリング
- リクエスト内容のログ出力
- `PreLlmRequestResult::Cancel` による送信キャンセル(`WorkerError::Aborted`になる)
### pre_tool_call
**呼び出しタイミング**: 各ツール実行前(並列実行フェーズの前に逐次実行)
**入力**: `&mut ToolCallContext``ToolCall` + `ToolMeta` + `Arc<dyn Tool>`
**用途**:
- 危険なツールのブロック(`Skip`
- 引数のサニタイズ(`context.call.input` の直接改変)
- 確認プロンプトの表示UIとの連携
- 実行ログの記録
- `PreToolCallResult::Pause` による一時停止(`WorkerResult::Paused`を返す)
- `PreToolCallResult::Abort` による処理全体の中断
**備考**: 未登録ツールToolServerに存在しないツール名の場合、Hookは適用されずそのまま実行される実行時にエラーとなる
**例**: 特定ツールをブロック
```rust
struct ToolBlocker {
blocked_tools: HashSet<String>,
}
#[async_trait]
impl Hook<PreToolCall> for ToolBlocker {
async fn call(
&self,
ctx: &mut ToolCallContext,
) -> Result<PreToolCallResult, HookError> {
if self.blocked_tools.contains(&ctx.call.name) {
Ok(PreToolCallResult::Skip)
} else {
Ok(PreToolCallResult::Continue)
}
}
}
```
### post_tool_call
**呼び出しタイミング**: 各ツール実行後(並列実行フェーズの後に逐次実行)
**入力**: `&mut PostToolCallContext``ToolCall` + `ToolResult` + `ToolMeta` + `Arc<dyn Tool>`
**用途**:
- 結果の加工・フォーマット(`context.result` の直接改変)
- 機密情報のマスキング
- 結果のキャッシュ
- 実行結果のログ出力
- `PostToolCallResult::Abort` による処理全体の中断
**例**: 結果にプレフィックスを追加
```rust
struct ResultFormatter;
#[async_trait]
impl Hook<PostToolCall> for ResultFormatter {
async fn call(
&self,
ctx: &mut PostToolCallContext,
) -> Result<PostToolCallResult, HookError> {
if !ctx.result.is_error {
ctx.result.content = format!("[OK] {}", ctx.result.content);
}
Ok(PostToolCallResult::Continue)
}
}
```
### on_stream_chunk
**呼び出しタイミング**: Timeline dispatchの後、各ストリームイベント受信時
**入力**: `&mut StreamChunkContext`Worker層の`Event`を含む)
**用途**:
- 監査・ログ記録
- 低遅延での介入
### on_text_delta
**呼び出しタイミング**: `BlockDelta`イベントのうち`DeltaContent::Text`受信時
**入力**: `&mut TextDeltaContext``index` + `delta`
**用途**:
- テキスト出力の監視
- 特定パターン検出による中断
### on_tool_call_delta
**呼び出しタイミング**: `BlockDelta`イベントのうち`DeltaContent::InputJson`受信時
**入力**: `&mut ToolCallDeltaContext``index` + `delta_json_fragment`
**用途**:
- ツール引数JSONの部分監視
- 危険な引数パターン検出による中断
### on_stream_complete
**呼び出しタイミング**: 1回のストリームが完了した後内側ループ終了後
**入力**: `&mut StreamCompleteContext``turn` + `event_count`
**用途**:
- ストリーム完了の検証
- イベント数の記録
### on_turn_end
**呼び出しタイミング**: ツール呼び出しなしでターンが終了する直前
**入力**: `&mut Vec<Item>` - historyのclone改変可能
**用途**:
- 生成されたコードのLint/Fmt
- 出力形式のバリデーション
- 自己修正のためのリトライ指示(`ContinueWithMessages`でItemを追加して再ループ
- 最終結果のログ出力
- `OnTurnEndResult::Paused` による一時停止
**例**: JSON形式のバリデーション
```rust
struct JsonValidator;
#[async_trait]
impl Hook<OnTurnEnd> for JsonValidator {
async fn call(
&self,
items: &mut Vec<Item>,
) -> Result<OnTurnEndResult, HookError> {
// 最後のアシスタントメッセージを検査
let last_text = items.iter().rev().find_map(|item| {
if let Item::Message { role, content, .. } = item {
if role == "assistant" { Some(content.as_str()) } else { None }
} else { None }
});
if let Some(text) = last_text {
if serde_json::from_str::<serde_json::Value>(text).is_err() {
return Ok(OnTurnEndResult::ContinueWithMessages(vec![
Item::user_message("Invalid JSON. Please fix and try again.")
]));
}
}
Ok(OnTurnEndResult::Finish)
}
}
```
### on_abort
**呼び出しタイミング**: Worker実行が中断された時`finalize_interruption`経由)
**入力**: `&mut String` - 中断理由
**用途**:
- クリーンアップ処理
- 中断理由のログ出力
- 外部システムへの通知
**発火条件**: `run()`や`resume()`の結果が`Err`の場合に必ず発火する。具体的には以下のケース:
- `WorkerError::Cancelled` -- reason: `"Cancelled"`
- `WorkerError::Aborted(reason)` -- reason: フックやキャンセルが指定した理由
- その他のエラーClient, Tool, Hook等 -- reason: エラーの表示文字列
## ストリームイベントの処理順序
`BlockDelta`到着時の順序:
1. Timelineへdispatchcollector/subscriber更新
2. `on_stream_chunk`
3. `on_text_delta` または `on_tool_call_delta`(デルタ種別による)
`DeltaContent::Thinking` に対するHookは現在存在しない。
## 複数Hookの実行順序
Hookは**イベントごとに登録順**に実行される。
```rust
worker.add_pre_tool_call_hook(HookA); // 1番目に実行
worker.add_pre_tool_call_hook(HookB); // 2番目に実行
worker.add_pre_tool_call_hook(HookC); // 3番目に実行
```
### 制御フローの伝播
- `Continue` / `Finish`: 後続Hookも実行
- `Skip`: 現在の処理をスキップし、後続Hookは実行しない
- `Abort`: 即座に`WorkerError::Aborted`を返し、処理全体を中断
- `Pause`: Workerを一時停止`WorkerResult::Paused`を返す。`resume()`で再開可能)
- `Cancel`: `WorkerError::Aborted`を返し、処理を中断
```
Hook A: Continue → Hook B: Skip → (Hook Cは実行されない)
処理をスキップ
Hook A: Continue → Hook B: Abort("reason")
WorkerError::Aborted
Hook A: Continue → Hook B: Pause
WorkerResult::Paused
```
注: stream系Hookの`Pause`は現状 `WorkerError::Aborted("Paused by stream hook")` として扱われる。
## Hook登録API
Workerは各Hookイベントに対応する登録メソッドを提供する:
```rust
worker.add_on_prompt_submit_hook(hook);
worker.add_pre_llm_request_hook(hook);
worker.add_pre_tool_call_hook(hook);
worker.add_post_tool_call_hook(hook);
worker.add_on_turn_end_hook(hook);
worker.add_on_abort_hook(hook);
worker.add_on_text_delta_hook(hook);
worker.add_on_tool_call_delta_hook(hook);
worker.add_on_stream_chunk_hook(hook);
worker.add_on_stream_complete_hook(hook);
```
これらのメソッドはWorkerの状態`Mutable`/`CacheLocked`)に関係なく利用可能。
## HookRegistry
全Hookは`HookRegistry`構造体で内部管理される。各フィールドは`Vec<Box<dyn Hook<E>>>`であり、Workerが初期化時に空のレジストリを作成する。
## HookError
```rust
pub enum HookError {
Aborted(String), // 処理の中断
Internal(String), // 内部エラー
}
```
`HookError`は`WorkerError::Hook`に変換され、`finalize_interruption`で`on_abort`フックが発火する。
## 設計上のポイント
### 1. イベントごとの実装
必要なイベントのみ `Hook<Event>` を実装する。1つの構造体で複数イベントの`Hook`を実装可能。
### 2. 可変参照による改変
`&mut`で引数を受け取るため、直接改変が可能。
```rust
async fn call(&self, ctx: &mut ToolCallContext) -> ... {
ctx.call.input["sanitized"] = json!(true);
Ok(PreToolCallResult::Continue)
}
```
### 3. 並列実行との統合
- `pre_tool_call`: 並列実行**前**に逐次実行(許可判定のため)
- ツール実行: `join_all`で**並列**実行(`tokio::select!`によりキャンセル可能)
- `post_tool_call`: 並列実行**後**に逐次実行(結果加工のため)
### 4. Send + Sync 要件
`Hook`は`Send + Sync`を要求するため、スレッドセーフな実装が必要。
状態を持つ場合は`Arc<Mutex<T>>`や`AtomicUsize`などを使用する。
### 5. pre_llm_request / on_turn_end のコンテキスト
`pre_llm_request`と`on_turn_end`はhistoryの**clone**に対して操作する。Hookによる改変はリクエスト構築時またはリトライ用メッセージ追加時のみ反映され、Worker内部のhistoryは直接変更されない。
## 典型的なユースケース
| ユースケース | 使用Hook | 処理内容 |
| ------------------ | ---------------------- | -------------------------- |
| ツール許可制御 | `pre_tool_call` | 危険なツールをSkip |
| 実行ログ | `pre/post_tool_call` | 呼び出しと結果を記録 |
| 出力バリデーション | `on_turn_end` | 形式チェック、リトライ指示 |
| コンテキスト注入 | `pre_llm_request` | システムメッセージ追加 |
| 結果のサニタイズ | `post_tool_call` | 機密情報のマスキング |
| レート制限 | `pre_tool_call` | 呼び出し頻度の制御 |
| ストリーム監視 | `on_text_delta` | 出力内容のリアルタイム監視 |
| 中断時通知 | `on_abort` | 外部システムへの通知 |

View File

@ -1,497 +0,0 @@
# Timeline層設計
## 目的
- OpenAI / Anthropic / Gemini のストリーミングイベントを単一の抽象レイヤーに正規化し、LLMクライアントの状態遷移を制御する。
- イベント単位処理Meta系などとブロック単位処理テキスト/Thinking/ToolCallを同一のパイプラインで扱えるようにする。
## 要件
イベントストリームに対して直接的にループ処理をしようとすると発生する煩雑な状態管理を避ける。
イベントをloop+matchで処理をするような手段を取ると、テキストのデルタの更新先や、完了タイミングなどの状態管理が必要になる。
また、コンテンツブロックに対する単純なイテレータではping/usageなどの単発イベントを同期的に処理することができない。
- Meta系イベントの即時処理ブロック内部イベントと順序が前後しないようにする
- ブロック開始/差分/終了でスコープを保持する
- 型安全なハンドラー
- blockでキャッチするdeltaについて、Text/InputJson/Thinking等、ブロックに即したイベントの型が必要。
- エラーの適切な制御
## Memo
- ブロックを処理するHandlerで保持するコンテキストについて、LLMで用いられるコンテキストと混同を避けるために「スコープ」と呼称する。
- ブロックは常に一つである前提。複数のブロックが同時に存在することは無いため。`Timeline`は`current_block: Option<BlockType>`で現在のブロックを追跡する。
## モジュール構成
```
crates/llm-worker/src/
├── handler.rs # Kind / Handler トレイト定義、Meta系・Block系Kind定義
├── timeline/
│ ├── mod.rs # 公開API re-export
│ ├── event.rs # Timeline層のイベント型llm_client::eventからの変換含む
│ ├── timeline.rs # Timeline本体、ErasedHandler、BlockHandlerWrapper群
│ ├── text_block_collector.rs # TextBlockCollector組み込みHandler
│ └── tool_call_collector.rs # ToolCallCollector組み込みHandler
└── event.rs # Worker層の公開イベント型timeline::eventからの変換含む
```
イベント型は3層に存在する:
1. `llm_client::event` - プロバイダ正規化済みイベント
2. `timeline::event` - Timeline内部イベント`llm_client::event`からの`From`変換)
3. `crate::event`Worker層公開 - 外部公開イベント(`timeline::event`からの`From`変換)
## イベントモデル
前提:`llm_client`層は各プロバイダのストリーミングレスポンスを正規化し、**フラットなEvent列挙**として出力する。Timeline層はそれを受け取り同一構造の`timeline::event::Event`に変換する。
```rust
// timeline::event::Event
pub enum Event {
// Meta events
Ping(PingEvent),
Usage(UsageEvent),
Status(StatusEvent),
Error(ErrorEvent),
// Block lifecycle events
BlockStart(BlockStart),
BlockDelta(BlockDelta),
BlockStop(BlockStop),
BlockAbort(BlockAbort),
}
```
### メタイベント型
```rust
pub struct PingEvent {
pub timestamp: Option<u64>,
}
pub struct UsageEvent {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
pub struct StatusEvent {
pub status: ResponseStatus,
}
pub enum ResponseStatus {
Started,
Completed,
Cancelled,
Failed,
}
pub struct ErrorEvent {
pub code: Option<String>,
pub message: String,
}
```
### ブロックイベント型
```rust
pub enum BlockType {
Text,
Thinking,
ToolUse,
ToolResult,
}
pub struct BlockStart {
pub index: usize,
pub block_type: BlockType,
pub metadata: BlockMetadata,
}
pub enum BlockMetadata {
Text,
Thinking,
ToolUse { id: String, name: String },
ToolResult { tool_use_id: String },
}
pub struct BlockDelta {
pub index: usize,
pub delta: DeltaContent,
}
pub enum DeltaContent {
Text(String),
Thinking(String),
InputJson(String),
}
pub struct BlockStop {
pub index: usize,
pub block_type: BlockType,
pub stop_reason: Option<StopReason>,
}
pub enum StopReason {
EndTurn,
MaxTokens,
StopSequence,
ToolUse,
}
pub struct BlockAbort {
pub index: usize,
pub block_type: BlockType,
pub reason: String,
}
```
## TimelineとKind/Handler
Timelineは`Event`ストリームを受け取り、登録された`Handler<K: Kind>`にディスパッチする。
### Kind`handler.rs`
`Kind`はイベント型のみを定義する。スコープはHandler側で定義するため、同じKindに対して異なるスコープを持つHandlerを登録できる。
```rust
pub trait Kind {
type Event;
}
```
### Handler`handler.rs`
`Handler`はKindに対する処理を定義し、自身のスコープ型も決定する。
```rust
pub trait Handler<K: Kind> {
type Scope: Default;
fn on_event(&mut self, scope: &mut Self::Scope, event: &K::Event);
}
```
- `Kind`によって受け取るイベント型が決定される
- `Handler::Scope`によってHandler固有のスコープ型が決定される
- Meta系とBlock系を統一的に扱える
### Meta系Kind
スコープ不要の単発イベント。登録時にスコープが即座に開始される:
```rust
pub struct UsageKind;
impl Kind for UsageKind {
type Event = UsageEvent;
}
pub struct PingKind;
impl Kind for PingKind {
type Event = PingEvent;
}
pub struct StatusKind;
impl Kind for StatusKind {
type Event = StatusEvent;
}
pub struct ErrorKind;
impl Kind for ErrorKind {
type Event = ErrorEvent;
}
```
### Block系Kind
ライフサイクルStart/Delta/Stopを持つ。スコープはHandler側で定義。Block系は3種定義されている:
#### TextBlockKind
```rust
pub struct TextBlockKind;
impl Kind for TextBlockKind {
type Event = TextBlockEvent;
}
pub enum TextBlockEvent {
Start(TextBlockStart),
Delta(String),
Stop(TextBlockStop),
}
pub struct TextBlockStart {
pub index: usize,
}
pub struct TextBlockStop {
pub index: usize,
pub stop_reason: Option<StopReason>,
}
```
#### ThinkingBlockKind
```rust
pub struct ThinkingBlockKind;
impl Kind for ThinkingBlockKind {
type Event = ThinkingBlockEvent;
}
pub enum ThinkingBlockEvent {
Start(ThinkingBlockStart),
Delta(String),
Stop(ThinkingBlockStop),
}
pub struct ThinkingBlockStart {
pub index: usize,
}
pub struct ThinkingBlockStop {
pub index: usize,
}
```
#### ToolUseBlockKind
```rust
pub struct ToolUseBlockKind;
impl Kind for ToolUseBlockKind {
type Event = ToolUseBlockEvent;
}
pub enum ToolUseBlockEvent {
Start(ToolUseBlockStart),
InputJsonDelta(String),
Stop(ToolUseBlockStop),
}
pub struct ToolUseBlockStart {
pub index: usize,
pub id: String,
pub name: String,
}
pub struct ToolUseBlockStop {
pub index: usize,
pub id: String,
pub name: String,
}
```
### 使用例
```rust
// 使用例1: デルタを即時出力(スコープ不要)
struct PrintHandler;
impl Handler<TextBlockKind> for PrintHandler {
type Scope = ();
fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
if let TextBlockEvent::Delta(s) = event {
print!("{}", s);
}
}
}
// 使用例2: テキストを蓄積して収集Stringをスコープとして利用
struct TextCollector { results: Vec<String> }
impl Handler<TextBlockKind> for TextCollector {
type Scope = String;
fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
match event {
TextBlockEvent::Start(_) => {}
TextBlockEvent::Delta(s) => buffer.push_str(s),
TextBlockEvent::Stop(_) => {
self.results.push(std::mem::take(buffer));
}
}
}
}
```
## Timelineの責務
1. `Event`ストリームを受信
2. Meta系イベントを即座に該当Handlerへディスパッチ
3. Block系イベントBlockStart/Delta/Stop/AbortをBlockKindごとのライフサイクルイベントに変換
4. 各Handlerごとのスコープの生成・管理BlockStart時に生成、BlockStop/Abort時に破棄
5. 登録されたHandlerへの登録順ディスパッチ
6. 暗黙的なスコープ開始BlockStartなしにDeltaが到達した場合の対応
## Handlerの型消去`timeline.rs`
### Meta系: `ErasedHandler<K>`
各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要。`Send + Sync`境界付き:
```rust
pub trait ErasedHandler<K: Kind>: Send + Sync {
fn dispatch(&mut self, event: &K::Event);
fn start_scope(&mut self);
fn end_scope(&mut self);
}
pub struct HandlerWrapper<H, K>
where
H: Handler<K>,
K: Kind,
{
handler: H,
scope: Option<H::Scope>,
_kind: PhantomData<fn() -> K>, // Send+Sync安全なPhantomData
}
```
Meta系Handlerは登録時に`start_scope()`が呼ばれ、スコープが常にアクティブな状態で保持される。
### Block系: `ErasedBlockHandler`
Block系は`BlockStart`/`BlockDelta`/`BlockStop`/`BlockAbort`の4メソッドに分離されたtrait:
```rust
trait ErasedBlockHandler: Send + Sync {
fn dispatch_start(&mut self, start: &BlockStart);
fn dispatch_delta(&mut self, delta: &BlockDelta);
fn dispatch_stop(&mut self, stop: &BlockStop);
fn dispatch_abort(&mut self, abort: &BlockAbort);
fn start_scope(&mut self);
fn end_scope(&mut self);
fn has_scope(&self) -> bool;
}
```
各BlockKindに対し専用のラッパー構造体が実装されている:
- `TextBlockHandlerWrapper<H>` - `DeltaContent::Text`のみをフィルタしてディスパッチ
- `ThinkingBlockHandlerWrapper<H>` - `DeltaContent::Thinking`のみをフィルタしてディスパッチ
- `ToolUseBlockHandlerWrapper<H>` - `DeltaContent::InputJson`をフィルタし、`BlockMetadata::ToolUse`からid/nameを追跡。Stopイベントにもid/nameを含めてディスパッチ
## Timeline構造体
```rust
pub struct Timeline {
// Meta系ハンドラー
usage_handlers: Vec<Box<dyn ErasedHandler<UsageKind>>>,
ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,
// Block系ハンドラーBlockTypeごとにグループ化
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
// 現在アクティブなブロック
current_block: Option<BlockType>,
}
```
### ハンドラ登録メソッド
| メソッド | Kind | 備考 |
|---|---|---|
| `on_usage()` | `UsageKind` | Meta系登録時にスコープ開始 |
| `on_ping()` | `PingKind` | Meta系 |
| `on_status()` | `StatusKind` | Meta系 |
| `on_error()` | `ErrorKind` | Meta系 |
| `on_text_block()` | `TextBlockKind` | Block系 |
| `on_thinking_block()` | `ThinkingBlockKind` | Block系 |
| `on_tool_use_block()` | `ToolUseBlockKind` | Block系 |
### ディスパッチ処理
```rust
impl Timeline {
pub fn dispatch(&mut self, event: &Event) {
match event {
// Meta系: 即時ディスパッチ(登録順)
Event::Usage(u) => self.dispatch_usage(u),
Event::Ping(p) => self.dispatch_ping(p),
Event::Status(s) => self.dispatch_status(s),
Event::Error(e) => self.dispatch_error(e),
// Block系: スコープ管理しながらディスパッチ
Event::BlockStart(s) => self.handle_block_start(s),
Event::BlockDelta(d) => self.handle_block_delta(d),
Event::BlockStop(s) => self.handle_block_stop(s),
Event::BlockAbort(a) => self.handle_block_abort(a),
}
}
}
```
Block系のディスパッチフロー:
- **BlockStart**: `current_block`を設定 → 該当BlockTypeのハンドラに対し`start_scope()` → `dispatch_start()`
- **BlockDelta**: `current_block`未設定時は暗黙的に設定。スコープ未開始のハンドラには暗黙的に`start_scope()`を呼ぶ → `dispatch_delta()`
- **BlockStop**: `dispatch_stop()``end_scope()``current_block`をクリア
- **BlockAbort**: `dispatch_abort()``end_scope()``current_block`をクリア
`BlockType::ToolResult`は`text_block_handlers`にルーティングされる(テキストとして扱う)。
### ブロック中断
```rust
pub fn abort_current_block(&mut self)
```
キャンセルやエラー時に呼び出し、進行中のブロックに対して`BlockAbort`イベントを発火してスコープをクリーンアップする。
## 組み込みHandler
### TextBlockCollector
テキストブロックを収集する組み込みHandler。`Arc<Mutex<Vec<String>>>`を内部に持ち、`Clone`可能。
```rust
pub struct TextBlockCollector {
collected: Arc<Mutex<Vec<String>>>,
}
impl Handler<TextBlockKind> for TextBlockCollector {
type Scope = TextCollectorState; // buffer: String を保持
// Start: バッファクリア
// Delta: バッファに追記
// Stop: バッファの内容をcollectedに確定
}
```
主なメソッド: `take_collected()`, `collected()`, `has_content()`, `clear()`
### ToolCallCollector
ToolUseブロックを収集する組み込みHandler。完了したツール呼び出しを`ToolCall`として収集する。
```rust
pub struct ToolCallCollector {
collected: Arc<Mutex<Vec<ToolCall>>>,
}
impl Handler<ToolUseBlockKind> for ToolCallCollector {
type Scope = CollectorState; // current_id, current_name, input_json_buffer を保持
// Start: id/nameを記録、バッファクリア
// InputJsonDelta: JSONバッファに追記
// Stop: バッファをパースしToolCallを確定
}
```
主なメソッド: `take_collected()`, `collected()`, `has_pending_calls()`, `clear()`
Workerは内部で`TextBlockCollector`と`ToolCallCollector`を自動的にTimelineに登録して使用する。
## 期待効果
- **統一インターフェース**: Meta系もBlock系も`Handler<K: Kind>`で統一
- **型安全**: `Kind`によってイベント型がコンパイル時に決定、`Handler::Scope`によってHandler固有のスコープ型が決定
- **スコープの柔軟性**: 同一Kindに対して異なるScopeを持つ複数Handlerを登録可能
- **責務分離**: `llm_client`層はフラットなEvent出力、Timeline層がブロック構造化、Worker層が公開イベントへの変換
- **スコープ管理の自動化**: Handlerは自前でスコープ保持を意識せずに済む
- **暗黙的スコープ開始**: BlockStartを送らないプロバイダOpenAI等にも対応
- **Send + Sync**: 全ハンドラが`Send + Sync`境界を持ち、非同期コンテキストで安全に使用可能

View File

@ -1,390 +0,0 @@
# Tool 設計
## 概要
`llm-worker`のツールシステムは、LLMが外部リソースにアクセスしたり計算を実行するための仕組みを提供する。
メタ情報の不変性とセッションスコープの状態管理を両立させる設計となっている。
ツールの管理は`ToolServer` / `ToolServerHandle`に分離されており、
Workerから独立したツール管理・実行が可能。
## 主要な型
```
type ToolDefinition
Fn() -> (ToolMeta, Arc<dyn Tool>)
Worker.register_tool() → ToolServerHandle.register_tool() で呼び出し
|
v
- struct ToolMeta (name, desc, schema)
不変・登録時固定
- trait Tool (execute)
登録時生成・セッション中再利用
- struct ToolServer / ToolServerHandle
ツールの管理・実行を担当
```
### ToolMeta
ツールのメタ情報を保持する不変構造体。登録時に固定され、Worker内で変更されない。
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolMeta {
pub name: String,
pub description: String,
pub input_schema: Value,
}
impl ToolMeta {
pub fn new(name: impl Into<String>) -> Self;
pub fn description(mut self, desc: impl Into<String>) -> Self;
pub fn input_schema(mut self, schema: Value) -> Self;
}
```
**目的:**
- LLM へのツール定義として送信
- Hook からの参照(読み取り専用)
- 登録後の不変性を保証
### Tool trait
ツールの実行ロジックのみを定義するトレイト。
```rust
#[async_trait]
pub trait Tool: Send + Sync {
async fn execute(&self, input_json: &str) -> Result<String, ToolError>;
}
```
**設計方針:**
- メタ情報name, description, schemaは含まない
- 状態を持つことが可能(セッション中のカウンターなど)
- `Send + Sync` で並列実行に対応
**インスタンスのライフサイクル:**
1. `register_tool()` 呼び出し時にファクトリが実行され、インスタンスが生成される
2. LLM がツールを呼び出すと、既存インスタンスの `execute()` が実行される
3. 同じセッション中は同一インスタンスが再利用される
※ 「最初に呼ばれたとき」の遅延初期化ではなく、**登録時の即時初期化**である。
### ToolDefinition
メタ情報とツールインスタンスを生成するファクトリ。
```rust
pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Sync>;
```
**なぜファクトリか:**
- Worker への登録時に一度だけ呼び出される
- メタ情報とインスタンスを同時に生成し、整合性を保証
- クロージャでコンテキスト(`self.clone()`)をキャプチャ可能
### ToolError
```rust
#[derive(Debug, Error)]
pub enum ToolError {
#[error("Invalid argument: {0}")]
InvalidArgument(String),
#[error("Execution failed: {0}")]
ExecutionFailed(String),
#[error("Internal error: {0}")]
Internal(String),
}
```
## ToolServer / ToolServerHandle
ツール管理がWorkerから分離された専用コンポーネント。
`ToolServer`がツールマップを所有し、`ToolServerHandle`がそのクローン可能な参照を提供する。
### ToolServer
```rust
#[derive(Clone, Default)]
pub struct ToolServer {
tools: Arc<Mutex<ToolMap>>, // HashMap<String, (ToolMeta, Arc<dyn Tool>)>
}
impl ToolServer {
pub fn new() -> Self;
pub fn handle(&self) -> ToolServerHandle;
}
```
### ToolServerHandle
```rust
#[derive(Clone, Default)]
pub struct ToolServerHandle {
tools: Arc<Mutex<ToolMap>>,
}
impl ToolServerHandle {
// 登録pub(crate) - Worker経由でのみ呼び出し可能
pub(crate) fn register_tool(&self, factory: WorkerToolDefinition) -> Result<(), ToolServerError>;
pub(crate) fn register_tools(&self, factories: impl IntoIterator<Item = WorkerToolDefinition>) -> Result<(), ToolServerError>;
// 検索Hookからのアクセス用
pub fn get_tool(&self, name: &str) -> Option<(ToolMeta, Arc<dyn Tool>)>;
// 実行
pub async fn call_tool(&self, name: &str, input_json: &str) -> Result<String, ToolServerError>;
// LLMリクエスト用定義生成名前順ソート済み
pub fn tool_definitions_sorted(&self) -> Vec<LlmToolDefinition>;
}
```
### ToolServerError
```rust
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ToolServerError {
#[error("Tool with name '{0}' already registered")]
DuplicateName(String),
#[error("Tool '{0}' not found")]
ToolNotFound(String),
#[error("Tool execution failed: {0}")]
ToolExecution(String),
}
```
### 設計上のポイント
- `ToolServer`は`Clone`可能で、`Arc<Mutex<ToolMap>>`により複数箇所で共有可能
- `register_tool` / `register_tools``pub(crate)` でWorker経由のみ
- `get_tool`, `call_tool``pub` でHookやアプリケーションから直接利用可能
- `tool_definitions_sorted()` は名前でソートされた決定的な定義リストを返す
- Worker側では `tool_server_handle()``ToolServerHandle` を取得可能
## Worker でのツール管理
Worker は `ToolServerHandle` を内部に持ち、ツール登録のAPIを提供する。
登録は `Mutable` 状態でのみ可能。
```rust
// Worker API (Mutable状態のみ)
pub fn register_tool(&mut self, factory: WorkerToolDefinition) -> Result<(), ToolRegistryError>;
pub fn register_tools(&mut self, factories: impl IntoIterator<Item = WorkerToolDefinition>) -> Result<(), ToolRegistryError>;
// ハンドルの取得(全状態)
pub fn tool_server_handle(&self) -> ToolServerHandle;
```
登録時の処理:
1. ファクトリを呼び出し `(meta, instance)` を取得
2. 同名ツールが既に登録されていればエラー(`ToolRegistryError::DuplicateName`
3. HashMap に `(meta, instance)` を保存
## マクロによる自動生成 (`llm-worker-macros`)
`#[tool_registry]` マクロと `#[tool]` マクロにより、メソッドから自動的に
`Tool` トレイト実装と `ToolDefinition` ファクトリを生成する。
### マクロ一覧
| マクロ | 種類 | 用途 |
| --- | --- | --- |
| `#[tool_registry]` | proc_macro_attribute | `impl`ブロックに適用。`#[tool]`メソッドを検出し、コード生成 |
| `#[tool]` | proc_macro_attribute | メソッドに適用。マーカー属性(`tool_registry`が処理) |
| `#[description]` | proc_macro_attribute | 引数に適用。schemarsの`description`に変換 |
### 使用例
```rust
#[tool_registry]
impl MyApp {
/// 検索を実行する
/// 指定されたクエリでデータベースを検索します。
#[tool]
async fn search(
&self,
#[description = "検索クエリ文字列"] query: String,
) -> String {
format!("Results for: {}", query)
}
}
// 生成されるもの:
// - SearchArgs 構造体
// - ToolSearch 構造体 (Tool trait実装)
// - MyApp::search_definition() メソッド
```
### 生成されるコード詳細
マクロは以下を自動生成します。
**1. 引数構造体(`{PascalCase}Args`**
```rust
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct SearchArgs {
#[schemars(description = "検索クエリ文字列")]
pub query: String,
}
```
- `#[description = "..."]` 属性は `#[schemars(description = "...")]` に変換される
- 引数がない場合は空の構造体が生成される
**2. ラッパー構造体(`Tool{PascalCase}`**
```rust
#[derive(Clone)]
pub struct ToolSearch {
ctx: MyApp,
}
#[async_trait]
impl Tool for ToolSearch {
async fn execute(&self, input_json: &str) -> Result<String, ToolError> {
let args: SearchArgs = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
let result = self.ctx.search(args.query).await;
Ok(format!("{:?}", result))
}
}
```
- 戻り値が `Result` の場合、`Err` は `ToolError::ExecutionFailed` に変換される
- 戻り値が `Result` でない場合、`format!("{:?}", result)` で文字列化される
- async/非asyncの両方をサポート
**3. ファクトリメソッド(`{method_name}_definition`**
```rust
impl MyApp {
pub fn search_definition(&self) -> ToolDefinition {
let ctx = self.clone();
Arc::new(move || {
let schema = schemars::schema_for!(SearchArgs);
let meta = ToolMeta::new("search")
.description("検索を実行する\n指定されたクエリでデータベースを検索します。")
.input_schema(serde_json::to_value(schema).unwrap_or(json!({})));
let tool: Arc<dyn Tool> = Arc::new(ToolSearch { ctx: ctx.clone() });
(meta, tool)
})
}
}
```
- ツール名はメソッド名snake_caseがそのまま使われる
- ツールの説明はメソッドのdocコメント全体が使われるdocコメントがない場合は `"Tool: {name}"` がフォールバック)
- `input_schema``schemars::schema_for!` で生成される
### 命名規則
| 元のメソッド名 | 引数構造体 | ラッパー構造体 | ファクトリメソッド |
| --- | --- | --- | --- |
| `search` | `SearchArgs` | `ToolSearch` | `search_definition()` |
| `get_user` | `GetUserArgs` | `ToolGetUser` | `get_user_definition()` |
PascalCaseへの変換は `to_pascal_case()` 関数で行われるsnake_caseの各セグメントを先頭大文字に変換
## Hook との連携
Hook は `ToolCallContext` / `PostToolCallContext`
を通じてメタ情報とインスタンスにアクセスできる。
```rust
pub struct ToolCallContext {
pub call: ToolCall, // 呼び出し情報(改変可能)
pub meta: ToolMeta, // メタ情報(読み取り専用)
pub tool: Arc<dyn Tool>, // インスタンス(状態アクセス用)
}
pub struct PostToolCallContext {
pub call: ToolCall, // 呼び出し情報
pub result: ToolResult, // 実行結果(改変可能)
pub meta: ToolMeta, // メタ情報(読み取り専用)
pub tool: Arc<dyn Tool>, // インスタンス(状態アクセス用)
}
```
**用途:**
- `meta` で名前やスキーマを確認
- `tool` でツールの内部状態を読み取り(ダウンキャスト必要)
- `call` の引数を改変してツールに渡すPreToolCall
- `result` の内容を改変PostToolCall
## 使用例
### 手動実装
```rust
struct Counter { count: AtomicUsize }
#[async_trait]
impl Tool for Counter {
async fn execute(&self, _: &str) -> Result<String, ToolError> {
let n = self.count.fetch_add(1, Ordering::SeqCst);
Ok(format!("count: {}", n))
}
}
let def: ToolDefinition = Arc::new(|| {
let meta = ToolMeta::new("counter")
.description("カウンターを増加")
.input_schema(json!({"type": "object"}));
(meta, Arc::new(Counter { count: AtomicUsize::new(0) }))
});
worker.register_tool(def)?;
```
### マクロ使用(推奨)
```rust
#[derive(Clone)]
struct App;
#[tool_registry]
impl App {
/// 挨拶する
#[tool]
async fn greet(&self, #[description = "名前"] name: String) -> String {
format!("Hello, {}!", name)
}
}
let app = App;
worker.register_tool(app.greet_definition())?;
```
### 複数ツールの一括登録
```rust
worker.register_tools([
app.search_definition(),
app.get_user_definition(),
app.greet_definition(),
])?;
```
## 設計上の決定
| 問題 | 決定 | 理由 |
| --- | --- | --- |
| メタ情報の変更可能性 | ToolMeta を分離・不変化 | 登録後の整合性を保証 |
| 状態管理 | 登録時にインスタンス生成 | セッション中の状態保持、同一インスタンス再利用 |
| Factory vs Instance | Factory + 登録時即時呼び出し | コンテキストキャプチャと登録時検証 |
| Hook からのアクセス | Context に meta と tool を含む | 柔軟な介入を可能に |
| ツール管理の分離 | ToolServer / ToolServerHandle | Worker外からのツール検索・実行を可能に |
| 登録の可視性 | `register_tool``pub(crate)` | Worker経由でのみ登録を許可し、整合性を維持 |
| 定義リストの順序 | 名前順ソート | 決定的なリクエスト生成を保証 |

View File

@ -1,460 +0,0 @@
# Worker 設計
## 概要
`Worker`はアプリケーションの「ターン」を制御する高レベルコンポーネントです。
`LlmClient`と`Timeline`を内包し、ユーザー定義の`Tool`と`Hook`を用いて自律的なインタラクションを行います。
Type-stateパターンにより、`Mutable`(編集可能)と`CacheLocked`キャッシュ保護の2つの状態を持ちます。
## アーキテクチャ
```mermaid
graph TD
User[Application / User] -->|1. Run| Worker
Worker -->|2. Event Loop| Timeline
Timeline -->|3. Dispatch| Handler[Handlers (TextBlockCollector, ToolCallCollector)]
subgraph "Worker Layer"
Worker
Hook[HookRegistry]
ToolServer[ToolServerHandle]
end
subgraph "Core Layer"
Timeline
LlmClient
end
Worker -.->|Intervene| Hook
ToolServer -.->|Execute| Tool[User Defined Tools]
Worker -.->|Subscribe| Subscriber[WorkerSubscriber]
```
## Worker 構造体
```rust
pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
client: C,
timeline: Timeline,
text_block_collector: TextBlockCollector,
tool_call_collector: ToolCallCollector,
tool_server: ToolServerHandle,
hooks: HookRegistry,
system_prompt: Option<String>,
history: Vec<Item>,
locked_prefix_len: usize,
turn_count: usize,
turn_notifiers: Vec<Box<dyn TurnNotifier>>,
request_config: RequestConfig,
last_run_interrupted: bool,
cancel_tx: mpsc::Sender<()>,
cancel_rx: mpsc::Receiver<()>,
_state: PhantomData<S>,
}
```
## 状態遷移 (Type-state)
| 状態 | 説明 | 利用可能な操作 |
| --- | --- | --- |
| `Mutable` | 初期状態。自由に編集可能 | system_prompt設定、history編集、tool/hook登録、lock() |
| `CacheLocked` | キャッシュ保護状態 | historyへの追記のみ、unlock() |
```rust
// Mutable → CacheLocked
let locked = worker.lock();
// CacheLocked → Mutable
let mutable = locked.unlock();
```
`CacheLocked`状態ではLLM APIのKVキャッシュヒットを確保するため、
システムプロンプトと既存の履歴(ロック時点のプレフィックス)は不変となります。
## ライフサイクル (ターン制御)
Workerは以下のループターンを実行します。
1. **Start Turn**: `Worker::run(user_input)` 呼び出し
2. **Hook: OnPromptSubmit**:
- ユーザーメッセージ(`Item`)の改変、バリデーション、キャンセルが可能。
- `Cancel` を返すと `WorkerError::Aborted` で終了。
3. **Resume Check**:
- 未応答のToolCallPauseから復帰した場合等があれば、先にツール実行を行う。
4. **Turn Loop**:
1. **Hook: PreLlmRequest**:
- LLMリクエスト送信前にコンテキスト`Vec<Item>`)を改変可能。
- `Cancel` を返すとターン中断。
2. **Request & Stream**:
- LLMへリクエスト送信。イベントストリーム開始。
- `Timeline`によるイベント処理(`TextBlockCollector`, `ToolCallCollector`)。
- ストリーム中に **Hook: OnStreamChunk**, **OnTextDelta**, **OnToolCallDelta** を実行。
- ストリーム完了時に **Hook: OnStreamComplete** を実行。
3. **Tool Handling (Parallel)**:
- レスポンス内に含まれる全てのTool Callを収集。
- 各Toolに対して **Hook: PreToolCall** を実行実行可否、引数改変、Pause
- 許可されたToolを**並列実行 (`join_all`)**`ToolServerHandle`経由)。
- 各Tool実行後に **Hook: PostToolCall** を実行(結果の確認、加工)。
4. **Next Request Decision**:
- Tool実行結果がある場合 -> 結果を`Item::FunctionCallOutput`としてhistoryに追加し、**Step 4.1へ戻る**(自動ループ)。
- Tool実行がない場合 -> Step 5へ。
5. **Hook: OnTurnEnd**:
- 最終的な応答に対するチェックLint/Fmtなど
- `ContinueWithMessages(items)`: 追加メッセージをhistoryに追加して**Step 4.1へ戻る**ことで自己修正を促せる。
- `Paused`: `WorkerResult::Paused` を返し、後で `resume()` で再開可能。
- `Finish`: ターン正常終了。
## キャンセル機構
`cancel_tx` / `cancel_rx` チャネルによる非同期キャンセルをサポートします。
```rust
// キャンセルトリガーの取得
let sender = worker.cancel_sender();
// 別タスクからキャンセル
sender.try_send(()).ok();
// または直接
worker.cancel();
```
キャンセルはストリーム開始前、ストリーム受信中、ツール実行中のいずれのタイミングでも
`tokio::select!` により検出され、`WorkerError::Cancelled` を返します。
## 実行結果
```rust
pub enum WorkerResult {
/// 完了(ユーザー入力待ち)
Finished,
/// 一時停止resume()で再開可能)
Paused,
}
```
`resume()` メソッドにより、Paused状態からユーザーメッセージを追加せずにターン処理を再開できます。
## Hook 設計
### Hook Trait
```rust
#[async_trait]
pub trait Hook<E: HookEventKind>: Send + Sync {
async fn call(&self, input: &mut E::Input) -> Result<E::Output, HookError>;
}
pub trait HookEventKind: Send + Sync + 'static {
type Input;
type Output;
}
```
### Hook イベント一覧
| Hook | Input | Output | タイミング |
| --- | --- | --- | --- |
| `OnPromptSubmit` | `Item` | `OnPromptSubmitResult` | `run()` 直後、ユーザーメッセージ受信時 |
| `PreLlmRequest` | `Vec<Item>` | `PreLlmRequestResult` | 各ターンのLLMリクエスト送信前 |
| `PreToolCall` | `ToolCallContext` | `PreToolCallResult` | 各ツール実行前 |
| `PostToolCall` | `PostToolCallContext` | `PostToolCallResult` | 各ツール実行後 |
| `OnTurnEnd` | `Vec<Item>` | `OnTurnEndResult` | ツール呼び出しなしでターン終了時 |
| `OnAbort` | `String` | `()` | エラーまたはキャンセルで中断時 |
| `OnTextDelta` | `TextDeltaContext` | `StreamHookResult` | テキストデルタ受信時 |
| `OnToolCallDelta` | `ToolCallDeltaContext` | `StreamHookResult` | ツール呼び出しJSON断片受信時 |
| `OnStreamChunk` | `StreamChunkContext` | `StreamHookResult` | ストリームイベント受信時 |
| `OnStreamComplete` | `StreamCompleteContext` | `StreamHookResult` | ストリーム完了時 |
### Hook 結果型
```rust
pub enum OnPromptSubmitResult {
Continue,
Cancel(String),
}
pub enum PreLlmRequestResult {
Continue,
Cancel(String),
}
pub enum PreToolCallResult {
Continue,
Skip, // ツール実行をスキップ
Abort(String), // 処理中断
Pause, // 一時停止resume()で再開)
}
pub enum PostToolCallResult {
Continue,
Abort(String),
}
pub enum OnTurnEndResult {
Finish,
ContinueWithMessages(Vec<Item>), // メッセージを追加してターン継続
Paused,
}
pub enum StreamHookResult {
Continue,
Abort(String),
Pause,
}
```
### Tool Call Context
`PreToolCall` / `PostToolCall` は、ツール実行の文脈を含むコンテキストを受け取ります。
```rust
pub struct ToolCallContext {
pub call: ToolCall, // 呼び出し情報(改変可能)
pub meta: ToolMeta, // メタ情報(読み取り専用)
pub tool: Arc<dyn Tool>, // インスタンス(状態アクセス用)
}
pub struct PostToolCallContext {
pub call: ToolCall, // 呼び出し情報
pub result: ToolResult, // 実行結果(改変可能)
pub meta: ToolMeta, // メタ情報(読み取り専用)
pub tool: Arc<dyn Tool>, // インスタンス(状態アクセス用)
}
```
### ストリーミングHookのコンテキスト
```rust
pub struct TextDeltaContext {
pub index: usize,
pub delta: String,
}
pub struct ToolCallDeltaContext {
pub index: usize,
pub delta_json_fragment: String,
}
pub struct StreamChunkContext {
pub event: crate::event::Event,
}
pub struct StreamCompleteContext {
pub turn: usize,
pub event_count: usize,
}
```
### Hook 登録API
```rust
worker.add_on_prompt_submit_hook(my_hook);
worker.add_pre_llm_request_hook(my_hook);
worker.add_pre_tool_call_hook(my_hook);
worker.add_post_tool_call_hook(my_hook);
worker.add_on_turn_end_hook(my_hook);
worker.add_on_abort_hook(my_hook);
worker.add_on_text_delta_hook(my_hook);
worker.add_on_tool_call_delta_hook(my_hook);
worker.add_on_stream_chunk_hook(my_hook);
worker.add_on_stream_complete_hook(my_hook);
```
## Worker Event API (Subscriber)
### 背景と目的
Workerは内部でイベントを処理し結果を返しますが、UIへのストリーミング表示やリアルタイムフィードバックには、イベントを外部に公開する仕組みが必要です。
### WorkerSubscriber Trait
`WorkerSubscriber`トレイトを実装し、`worker.subscribe()` で一括登録します。
```rust
pub trait WorkerSubscriber: Send {
// スコープ型(ブロックイベント用)
type TextBlockScope: Default + Send + Sync;
type ToolUseBlockScope: Default + Send + Sync;
// === ブロックイベント(スコープ管理あり)===
fn on_text_block(
&mut self,
scope: &mut Self::TextBlockScope,
event: &TextBlockEvent,
) {}
fn on_tool_use_block(
&mut self,
scope: &mut Self::ToolUseBlockScope,
event: &ToolUseBlockEvent,
) {}
// === 単発イベント ===
fn on_usage(&mut self, event: &UsageEvent) {}
fn on_status(&mut self, event: &StatusEvent) {}
fn on_error(&mut self, event: &ErrorEvent) {}
// === 累積イベントWorker層で追加===
fn on_text_complete(&mut self, text: &str) {}
fn on_tool_call_complete(&mut self, call: &ToolCall) {}
// === ターン制御 ===
fn on_turn_start(&mut self, turn: usize) {}
fn on_turn_end(&mut self, turn: usize) {}
}
```
### 内部実装
SubscriberはWorker内部でAdapter群に分解され、各KindのHandlerとしてTimelineに登録されます。
累積イベント(`on_text_complete`, `on_tool_call_complete`はAdapter内でブロック終了時にバッファから合成されます。
- `TextBlockSubscriberAdapter`: テキストデルタを蓄積し、Stop時に`on_text_complete`を呼ぶ
- `ToolUseBlockSubscriberAdapter`: ID・名前・JSONを蓄積し、Stop時に`on_tool_call_complete`を呼ぶ
- `UsageSubscriberAdapter`, `StatusSubscriberAdapter`, `ErrorSubscriberAdapter`: 単純な転送
- `SubscriberTurnNotifier`: ターン開始・終了の通知
### 使用例
```rust
struct StreamPrinter;
impl WorkerSubscriber for StreamPrinter {
type TextBlockScope = ();
type ToolUseBlockScope = ();
fn on_text_block(&mut self, _: &mut (), event: &TextBlockEvent) {
if let TextBlockEvent::Delta(text) = event {
print!("{}", text);
}
}
fn on_text_complete(&mut self, text: &str) {
println!("\n--- Complete: {} chars ---", text.len());
}
}
worker.subscribe(StreamPrinter);
let result = worker.run("Hello!").await?;
```
## Worker API
### 生成と設定 (Mutable状態のみ)
```rust
// 生成(ビルダーパターン)
let worker = Worker::new(client)
.system_prompt("You are a helpful assistant.")
.max_tokens(4096)
.temperature(0.7)
.top_p(0.9)
.top_k(40)
.stop_sequence("\n\n")
.with_config(request_config)
.with_item(item)
.with_items(items);
// バリデーション(プロバイダの対応確認)
let worker = worker.validate()?;
// ミューテーション
worker.set_system_prompt("...");
worker.set_max_tokens(4096);
worker.set_temperature(0.7);
worker.set_top_p(0.9);
worker.set_top_k(40);
worker.add_stop_sequence("\n\n");
worker.clear_stop_sequences();
worker.set_request_config(config);
// 履歴操作
worker.push_item(item);
worker.extend_history(items);
worker.set_history(items);
worker.clear_history();
worker.history_mut(); // &mut Vec<Item>
// ツール登録
worker.register_tool(factory)?;
worker.register_tools(factories)?;
```
### 全状態で利用可能
```rust
// 実行
let result = worker.run("user input").await?;
let result = worker.resume().await?;
// キャンセル
worker.cancel();
let sender = worker.cancel_sender();
// 参照
worker.history(); // &[Item]
worker.get_system_prompt(); // Option<&str>
worker.turn_count(); // usize
worker.request_config(); // &RequestConfig
worker.last_run_interrupted(); // bool
worker.is_cancelled(); // bool
// ツールサーバー
worker.tool_server_handle(); // ToolServerHandle
// Timeline直接アクセス
worker.timeline_mut(); // &mut Timeline
// イベント購読
worker.subscribe(my_subscriber);
// Hook登録
worker.add_on_prompt_submit_hook(hook);
worker.add_pre_llm_request_hook(hook);
worker.add_pre_tool_call_hook(hook);
worker.add_post_tool_call_hook(hook);
worker.add_on_turn_end_hook(hook);
worker.add_on_abort_hook(hook);
worker.add_on_text_delta_hook(hook);
worker.add_on_tool_call_delta_hook(hook);
worker.add_on_stream_chunk_hook(hook);
worker.add_on_stream_complete_hook(hook);
```
## エラー型
```rust
pub enum WorkerError {
Client(ClientError),
Tool(ToolError),
Hook(HookError),
Aborted(String),
Cancelled,
ConfigWarnings(Vec<ConfigWarning>),
}
pub enum ToolRegistryError {
DuplicateName(String),
}
```
## 公開エクスポート
`lib.rs` から以下がre-exportされています。
```rust
pub use message::{ContentPart, Item, Message, Role};
pub use worker::{ToolRegistryError, Worker, WorkerConfig, WorkerError, WorkerResult};
```
モジュール:
- `pub mod event`
- `pub mod hook`
- `pub mod llm_client`
- `pub mod state`
- `pub mod subscriber`
- `pub mod timeline`
- `pub mod tool`
- `pub mod tool_server`

View File

@ -1,446 +1,5 @@
//! Public event types for Worker layer
//!
//! Event representation exposed to external users.
//! Re-exports from the canonical event definitions in llm_client.
use serde::{Deserialize, Serialize};
// =============================================================================
// Core Event Types (from llm_client layer)
// =============================================================================
/// Streaming events from LLM
///
/// Responses from each LLM provider are processed uniformly
/// as a stream of `Event`.
///
/// # Event Types
///
/// - **Meta events**: `Ping`, `Usage`, `Status`, `Error`
/// - **Block events**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
///
/// # Block Lifecycle
///
/// Text and tool calls have events in the order of
/// `BlockStart` → `BlockDelta`(multiple) → `BlockStop`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// Heartbeat
Ping(PingEvent),
/// Token usage
Usage(UsageEvent),
/// Stream status change
Status(StatusEvent),
/// Error occurred
Error(ErrorEvent),
/// Block start (text, tool use, etc.)
BlockStart(BlockStart),
/// Block delta data
BlockDelta(BlockDelta),
/// Block normal end
BlockStop(BlockStop),
/// Block abort
BlockAbort(BlockAbort),
}
// =============================================================================
// Meta Events
// =============================================================================
/// Ping event (heartbeat)
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent {
pub timestamp: Option<u64>,
}
/// Usage event
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// Input token count
pub input_tokens: Option<u64>,
/// Output token count
pub output_tokens: Option<u64>,
/// Total token count
pub total_tokens: Option<u64>,
/// Cache read token count
pub cache_read_input_tokens: Option<u64>,
/// Cache creation token count
pub cache_creation_input_tokens: Option<u64>,
}
/// Status event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent {
pub status: ResponseStatus,
}
/// Response status
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus {
/// Stream started
Started,
/// Completed normally
Completed,
/// Cancelled
Cancelled,
/// Error occurred
Failed,
}
/// Error event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent {
pub code: Option<String>,
pub message: String,
}
// =============================================================================
// Block Types
// =============================================================================
/// Block type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType {
/// Text generation
Text,
/// Thinking (Claude Extended Thinking, etc.)
Thinking,
/// Tool call
ToolUse,
/// Tool result
ToolResult,
}
/// Block start event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart {
/// Block index
pub index: usize,
/// Block type
pub block_type: BlockType,
/// Block-specific metadata
pub metadata: BlockMetadata,
}
impl BlockStart {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// Block metadata
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata {
Text,
Thinking,
ToolUse { id: String, name: String },
ToolResult { tool_use_id: String },
}
/// Block delta event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta {
/// Block index
pub index: usize,
/// Delta content
pub delta: DeltaContent,
}
/// Delta content
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent {
/// Text delta
Text(String),
/// Thinking delta
Thinking(String),
/// JSON substring of tool arguments
InputJson(String),
}
impl DeltaContent {
/// Get block type of the delta
pub fn block_type(&self) -> BlockType {
match self {
DeltaContent::Text(_) => BlockType::Text,
DeltaContent::Thinking(_) => BlockType::Thinking,
DeltaContent::InputJson(_) => BlockType::ToolUse,
}
}
}
/// Block stop event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop {
/// Block index
pub index: usize,
/// Block type
pub block_type: BlockType,
/// Stop reason
pub stop_reason: Option<StopReason>,
}
impl BlockStop {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// Block abort event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort {
/// Block index
pub index: usize,
/// Block type
pub block_type: BlockType,
/// Abort reason
pub reason: String,
}
impl BlockAbort {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// Stop reason
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {
/// Natural end
EndTurn,
/// Max tokens reached
MaxTokens,
/// Stop sequence reached
StopSequence,
/// Tool use
ToolUse,
}
// =============================================================================
// Builder / Factory helpers
// =============================================================================
impl Event {
/// Create text block start event
pub fn text_block_start(index: usize) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::Text,
metadata: BlockMetadata::Text,
})
}
/// Create text delta event
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::Text(text.into()),
})
}
/// Create text block stop event
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::Text,
stop_reason,
})
}
/// Create tool use block start event
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: id.into(),
name: name.into(),
},
})
}
/// Create tool input delta event
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::InputJson(json.into()),
})
}
/// Create tool use block stop event
pub fn tool_use_stop(index: usize) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::ToolUse,
stop_reason: Some(StopReason::ToolUse),
})
}
/// Create usage event
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
Event::Usage(UsageEvent {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
total_tokens: Some(input_tokens + output_tokens),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
}
/// Create ping event
pub fn ping() -> Self {
Event::Ping(PingEvent { timestamp: None })
}
}
// =============================================================================
// Conversions: timeline::event -> worker::event
// =============================================================================
impl From<crate::timeline::event::ResponseStatus> for ResponseStatus {
fn from(value: crate::timeline::event::ResponseStatus) -> Self {
match value {
crate::timeline::event::ResponseStatus::Started => ResponseStatus::Started,
crate::timeline::event::ResponseStatus::Completed => ResponseStatus::Completed,
crate::timeline::event::ResponseStatus::Cancelled => ResponseStatus::Cancelled,
crate::timeline::event::ResponseStatus::Failed => ResponseStatus::Failed,
}
}
}
impl From<crate::timeline::event::BlockType> for BlockType {
fn from(value: crate::timeline::event::BlockType) -> Self {
match value {
crate::timeline::event::BlockType::Text => BlockType::Text,
crate::timeline::event::BlockType::Thinking => BlockType::Thinking,
crate::timeline::event::BlockType::ToolUse => BlockType::ToolUse,
crate::timeline::event::BlockType::ToolResult => BlockType::ToolResult,
}
}
}
impl From<crate::timeline::event::BlockMetadata> for BlockMetadata {
fn from(value: crate::timeline::event::BlockMetadata) -> Self {
match value {
crate::timeline::event::BlockMetadata::Text => BlockMetadata::Text,
crate::timeline::event::BlockMetadata::Thinking => BlockMetadata::Thinking,
crate::timeline::event::BlockMetadata::ToolUse { id, name } => {
BlockMetadata::ToolUse { id, name }
}
crate::timeline::event::BlockMetadata::ToolResult { tool_use_id } => {
BlockMetadata::ToolResult { tool_use_id }
}
}
}
}
impl From<crate::timeline::event::DeltaContent> for DeltaContent {
fn from(value: crate::timeline::event::DeltaContent) -> Self {
match value {
crate::timeline::event::DeltaContent::Text(text) => DeltaContent::Text(text),
crate::timeline::event::DeltaContent::Thinking(text) => DeltaContent::Thinking(text),
crate::timeline::event::DeltaContent::InputJson(json) => DeltaContent::InputJson(json),
}
}
}
impl From<crate::timeline::event::StopReason> for StopReason {
fn from(value: crate::timeline::event::StopReason) -> Self {
match value {
crate::timeline::event::StopReason::EndTurn => StopReason::EndTurn,
crate::timeline::event::StopReason::MaxTokens => StopReason::MaxTokens,
crate::timeline::event::StopReason::StopSequence => StopReason::StopSequence,
crate::timeline::event::StopReason::ToolUse => StopReason::ToolUse,
}
}
}
impl From<crate::timeline::event::PingEvent> for PingEvent {
fn from(value: crate::timeline::event::PingEvent) -> Self {
PingEvent {
timestamp: value.timestamp,
}
}
}
impl From<crate::timeline::event::UsageEvent> for UsageEvent {
fn from(value: crate::timeline::event::UsageEvent) -> Self {
UsageEvent {
input_tokens: value.input_tokens,
output_tokens: value.output_tokens,
total_tokens: value.total_tokens,
cache_read_input_tokens: value.cache_read_input_tokens,
cache_creation_input_tokens: value.cache_creation_input_tokens,
}
}
}
impl From<crate::timeline::event::StatusEvent> for StatusEvent {
fn from(value: crate::timeline::event::StatusEvent) -> Self {
StatusEvent {
status: value.status.into(),
}
}
}
impl From<crate::timeline::event::ErrorEvent> for ErrorEvent {
fn from(value: crate::timeline::event::ErrorEvent) -> Self {
ErrorEvent {
code: value.code,
message: value.message,
}
}
}
impl From<crate::timeline::event::BlockStart> for BlockStart {
fn from(value: crate::timeline::event::BlockStart) -> Self {
BlockStart {
index: value.index,
block_type: value.block_type.into(),
metadata: value.metadata.into(),
}
}
}
impl From<crate::timeline::event::BlockDelta> for BlockDelta {
fn from(value: crate::timeline::event::BlockDelta) -> Self {
BlockDelta {
index: value.index,
delta: value.delta.into(),
}
}
}
impl From<crate::timeline::event::BlockStop> for BlockStop {
fn from(value: crate::timeline::event::BlockStop) -> Self {
BlockStop {
index: value.index,
block_type: value.block_type.into(),
stop_reason: value.stop_reason.map(Into::into),
}
}
}
impl From<crate::timeline::event::BlockAbort> for BlockAbort {
fn from(value: crate::timeline::event::BlockAbort) -> Self {
BlockAbort {
index: value.index,
block_type: value.block_type.into(),
reason: value.reason,
}
}
}
impl From<crate::timeline::event::Event> for Event {
fn from(value: crate::timeline::event::Event) -> Self {
match value {
crate::timeline::event::Event::Ping(p) => Event::Ping(p.into()),
crate::timeline::event::Event::Usage(u) => Event::Usage(u.into()),
crate::timeline::event::Event::Status(s) => Event::Status(s.into()),
crate::timeline::event::Event::Error(e) => Event::Error(e.into()),
crate::timeline::event::Event::BlockStart(s) => Event::BlockStart(s.into()),
crate::timeline::event::Event::BlockDelta(d) => Event::BlockDelta(d.into()),
crate::timeline::event::Event::BlockStop(s) => Event::BlockStop(s.into()),
crate::timeline::event::Event::BlockAbort(a) => Event::BlockAbort(a.into()),
}
}
}
pub use crate::llm_client::event::*;

View File

@ -126,10 +126,7 @@ impl AnthropicScheme {
let parts: Vec<AnthropicContentPart> = content
.iter()
.map(|p| match p {
ContentPart::InputText { text } => {
AnthropicContentPart::Text { text: text.clone() }
}
ContentPart::OutputText { text } => {
ContentPart::Text { text } => {
AnthropicContentPart::Text { text: text.clone() }
}
ContentPart::Refusal { refusal } => AnthropicContentPart::Text {
@ -158,7 +155,7 @@ impl AnthropicScheme {
}
}
Item::FunctionCall {
Item::ToolCall {
call_id,
name,
arguments,
@ -185,7 +182,7 @@ impl AnthropicScheme {
});
}
Item::FunctionCallOutput {
Item::ToolResult {
call_id, output, ..
} => {
// Flush pending assistant parts first
@ -305,16 +302,16 @@ mod tests {
}
#[test]
fn test_function_call_and_output() {
fn test_tool_call_and_result() {
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("What's the weather?")
.item(Item::function_call(
.item(Item::tool_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
.item(Item::tool_result("call_123", "Sunny, 25°C"));
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);

View File

@ -234,7 +234,7 @@ impl GeminiScheme {
});
}
Item::FunctionCall {
Item::ToolCall {
name, arguments, ..
} => {
// Flush pending user parts first
@ -257,7 +257,7 @@ impl GeminiScheme {
});
}
Item::FunctionCallOutput {
Item::ToolResult {
call_id, output, ..
} => {
// Flush pending model parts first
@ -390,16 +390,16 @@ mod tests {
}
#[test]
fn test_function_call_and_output() {
fn test_tool_call_and_result() {
let scheme = GeminiScheme::new();
let request = Request::new()
.user("What's the weather?")
.item(Item::function_call(
.item(Item::tool_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
.item(Item::tool_result("call_123", "Sunny, 25°C"));
let gemini_req = scheme.build_request(&request);

View File

@ -195,7 +195,7 @@ impl OpenAIScheme {
});
}
Item::FunctionCall {
Item::ToolCall {
call_id,
name,
arguments,
@ -211,7 +211,7 @@ impl OpenAIScheme {
});
}
Item::FunctionCallOutput {
Item::ToolResult {
call_id, output, ..
} => {
// Flush pending tool calls before tool result
@ -339,16 +339,16 @@ mod tests {
}
#[test]
fn test_function_call_and_output() {
fn test_tool_call_and_result() {
let scheme = OpenAIScheme::new();
let request = Request::new()
.user("Check weather")
.item(Item::function_call(
.item(Item::tool_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
.item(Item::tool_result("call_123", "Sunny, 25°C"));
let body = scheme.build_request("gpt-4o", &request);

View File

@ -1,494 +0,0 @@
//! Open Responses Event Parser
//!
//! Parses SSE events from the Open Responses API into internal Event types.
use serde::Deserialize;
use crate::llm_client::{
event::{
BlockMetadata, BlockStart, BlockStop, DeltaContent, ErrorEvent, Event, ResponseStatus,
StatusEvent, StopReason, UsageEvent,
},
ClientError,
};
// =============================================================================
// Open Responses SSE Event Types
// =============================================================================
/// Response created event
#[derive(Debug, Deserialize)]
pub struct ResponseCreatedEvent {
pub response: ResponseObject,
}
/// Response object
#[derive(Debug, Deserialize)]
pub struct ResponseObject {
pub id: String,
pub status: String,
#[serde(default)]
pub output: Vec<OutputItem>,
pub usage: Option<UsageObject>,
}
/// Output item in response
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OutputItem {
Message {
id: String,
role: String,
#[serde(default)]
content: Vec<ContentPartObject>,
},
FunctionCall {
id: String,
call_id: String,
name: String,
arguments: String,
},
Reasoning {
id: String,
#[serde(default)]
text: String,
},
}
/// Content part object
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPartObject {
OutputText { text: String },
InputText { text: String },
Refusal { refusal: String },
}
/// Usage object
#[derive(Debug, Deserialize)]
pub struct UsageObject {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
}
/// Output item added event
#[derive(Debug, Deserialize)]
pub struct OutputItemAddedEvent {
pub output_index: usize,
pub item: OutputItem,
}
/// Text delta event
#[derive(Debug, Deserialize)]
pub struct TextDeltaEvent {
pub output_index: usize,
pub content_index: usize,
pub delta: String,
}
/// Text done event
#[derive(Debug, Deserialize)]
pub struct TextDoneEvent {
pub output_index: usize,
pub content_index: usize,
pub text: String,
}
/// Function call arguments delta event
#[derive(Debug, Deserialize)]
pub struct FunctionCallArgumentsDeltaEvent {
pub output_index: usize,
pub call_id: String,
pub delta: String,
}
/// Function call arguments done event
#[derive(Debug, Deserialize)]
pub struct FunctionCallArgumentsDoneEvent {
pub output_index: usize,
pub call_id: String,
pub arguments: String,
}
/// Reasoning delta event
#[derive(Debug, Deserialize)]
pub struct ReasoningDeltaEvent {
pub output_index: usize,
pub delta: String,
}
/// Reasoning done event
#[derive(Debug, Deserialize)]
pub struct ReasoningDoneEvent {
pub output_index: usize,
pub text: String,
}
/// Content part done event
#[derive(Debug, Deserialize)]
pub struct ContentPartDoneEvent {
pub output_index: usize,
pub content_index: usize,
pub part: ContentPartObject,
}
/// Output item done event
#[derive(Debug, Deserialize)]
pub struct OutputItemDoneEvent {
pub output_index: usize,
pub item: OutputItem,
}
/// Response done event
#[derive(Debug, Deserialize)]
pub struct ResponseDoneEvent {
pub response: ResponseObject,
}
/// Error event from API
#[derive(Debug, Deserialize)]
pub struct ApiErrorEvent {
pub error: ApiError,
}
/// API error details
#[derive(Debug, Deserialize)]
pub struct ApiError {
pub code: Option<String>,
pub message: String,
}
// =============================================================================
// Event Parsing
// =============================================================================
/// Parse SSE event into internal Event(s)
///
/// Returns `Ok(None)` for events that should be ignored (e.g., heartbeats)
/// Returns `Ok(Some(vec))` for events that produce one or more internal Events
pub fn parse_event(event_type: &str, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
// Skip empty data
if data.is_empty() || data == "[DONE]" {
return Ok(None);
}
let events = match event_type {
// Response lifecycle
"response.created" => {
let _event: ResponseCreatedEvent = parse_json(data)?;
Some(vec![Event::Status(StatusEvent {
status: ResponseStatus::Started,
})])
}
"response.in_progress" => {
// Just a status update, no action needed
None
}
"response.completed" | "response.done" => {
let event: ResponseDoneEvent = parse_json(data)?;
let mut events = Vec::new();
// Emit usage if present
if let Some(usage) = event.response.usage {
events.push(Event::Usage(UsageEvent {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
}));
}
events.push(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}));
Some(events)
}
"response.failed" => {
// Try to parse error
if let Ok(error_event) = parse_json::<ApiErrorEvent>(data) {
Some(vec![
Event::Error(ErrorEvent {
code: error_event.error.code,
message: error_event.error.message,
}),
Event::Status(StatusEvent {
status: ResponseStatus::Failed,
}),
])
} else {
Some(vec![Event::Status(StatusEvent {
status: ResponseStatus::Failed,
})])
}
}
// Output item events
"response.output_item.added" => {
let event: OutputItemAddedEvent = parse_json(data)?;
Some(vec![convert_item_added(&event)])
}
"response.output_item.done" => {
let event: OutputItemDoneEvent = parse_json(data)?;
Some(vec![convert_item_done(&event)])
}
// Text content events
"response.output_text.delta" => {
let event: TextDeltaEvent = parse_json(data)?;
Some(vec![Event::text_delta(event.output_index, &event.delta)])
}
"response.output_text.done" => {
// Text done - we'll handle stop in output_item.done
let _event: TextDoneEvent = parse_json(data)?;
None
}
// Content part events
"response.content_part.added" => {
// Content part added - we handle this via output_item.added
None
}
"response.content_part.done" => {
// Content part done - we handle stop in output_item.done
None
}
// Function call events
"response.function_call_arguments.delta" => {
let event: FunctionCallArgumentsDeltaEvent = parse_json(data)?;
Some(vec![Event::BlockDelta(crate::llm_client::event::BlockDelta {
index: event.output_index,
delta: DeltaContent::InputJson(event.delta),
})])
}
"response.function_call_arguments.done" => {
// Arguments done - we handle stop in output_item.done
let _event: FunctionCallArgumentsDoneEvent = parse_json(data)?;
None
}
// Reasoning events
"response.reasoning.delta" | "response.reasoning_summary_text.delta" => {
let event: ReasoningDeltaEvent = parse_json(data)?;
Some(vec![Event::BlockDelta(crate::llm_client::event::BlockDelta {
index: event.output_index,
delta: DeltaContent::Thinking(event.delta),
})])
}
"response.reasoning.done" | "response.reasoning_summary_text.done" => {
// Reasoning done - we handle stop in output_item.done
let _event: ReasoningDoneEvent = parse_json(data)?;
None
}
// Error event
"error" => {
let event: ApiErrorEvent = parse_json(data)?;
Some(vec![Event::Error(ErrorEvent {
code: event.error.code,
message: event.error.message,
})])
}
// Unknown event type - ignore
_ => {
tracing::debug!(event_type = event_type, "Unknown Open Responses event type");
None
}
};
Ok(events)
}
fn parse_json<T: serde::de::DeserializeOwned>(data: &str) -> Result<T, ClientError> {
serde_json::from_str(data).map_err(|e| ClientError::Parse(e.to_string()))
}
fn convert_item_added(event: &OutputItemAddedEvent) -> Event {
match &event.item {
OutputItem::Message { id, role: _, content: _ } => Event::BlockStart(BlockStart {
index: event.output_index,
block_type: crate::llm_client::event::BlockType::Text,
metadata: BlockMetadata::Text,
}),
OutputItem::FunctionCall {
id,
call_id,
name,
arguments: _,
} => Event::BlockStart(BlockStart {
index: event.output_index,
block_type: crate::llm_client::event::BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: call_id.clone(),
name: name.clone(),
},
}),
OutputItem::Reasoning { id, text: _ } => Event::BlockStart(BlockStart {
index: event.output_index,
block_type: crate::llm_client::event::BlockType::Thinking,
metadata: BlockMetadata::Thinking,
}),
}
}
fn convert_item_done(event: &OutputItemDoneEvent) -> Event {
let stop_reason = match &event.item {
OutputItem::FunctionCall { .. } => Some(StopReason::ToolUse),
_ => Some(StopReason::EndTurn),
};
Event::BlockStop(BlockStop {
index: event.output_index,
stop_reason,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_response_created() {
let data = r#"{"response":{"id":"resp_123","status":"in_progress","output":[]}}"#;
let events = parse_event("response.created", data).unwrap().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
Event::Status(StatusEvent {
status: ResponseStatus::Started
})
));
}
#[test]
fn test_parse_text_delta() {
let data = r#"{"output_index":0,"content_index":0,"delta":"Hello"}"#;
let events = parse_event("response.output_text.delta", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
assert_eq!(delta.index, 0);
assert!(matches!(&delta.delta, DeltaContent::Text(t) if t == "Hello"));
} else {
panic!("Expected BlockDelta");
}
}
#[test]
fn test_parse_output_item_added_message() {
let data = r#"{"output_index":0,"item":{"type":"message","id":"msg_123","role":"assistant","content":[]}}"#;
let events = parse_event("response.output_item.added", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockStart(start) = &events[0] {
assert_eq!(start.index, 0);
assert!(matches!(
start.block_type,
crate::llm_client::event::BlockType::Text
));
} else {
panic!("Expected BlockStart");
}
}
#[test]
fn test_parse_output_item_added_function_call() {
let data = r#"{"output_index":1,"item":{"type":"function_call","id":"fc_123","call_id":"call_456","name":"get_weather","arguments":""}}"#;
let events = parse_event("response.output_item.added", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockStart(start) = &events[0] {
assert_eq!(start.index, 1);
assert!(matches!(
start.block_type,
crate::llm_client::event::BlockType::ToolUse
));
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
assert_eq!(id, "call_456");
assert_eq!(name, "get_weather");
} else {
panic!("Expected ToolUse metadata");
}
} else {
panic!("Expected BlockStart");
}
}
#[test]
fn test_parse_function_call_arguments_delta() {
let data = r#"{"output_index":1,"call_id":"call_456","delta":"{\"city\":"}"#;
let events = parse_event("response.function_call_arguments.delta", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
assert_eq!(delta.index, 1);
assert!(matches!(
&delta.delta,
DeltaContent::InputJson(s) if s == "{\"city\":"
));
} else {
panic!("Expected BlockDelta");
}
}
#[test]
fn test_parse_response_completed() {
let data = r#"{"response":{"id":"resp_123","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}"#;
let events = parse_event("response.completed", data).unwrap().unwrap();
assert_eq!(events.len(), 2);
// First event should be usage
if let Event::Usage(usage) = &events[0] {
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(20));
assert_eq!(usage.total_tokens, Some(30));
} else {
panic!("Expected Usage event");
}
// Second event should be status
assert!(matches!(
events[1],
Event::Status(StatusEvent {
status: ResponseStatus::Completed
})
));
}
#[test]
fn test_parse_error() {
let data = r#"{"error":{"code":"rate_limit","message":"Too many requests"}}"#;
let events = parse_event("error", data).unwrap().unwrap();
assert_eq!(events.len(), 1);
if let Event::Error(err) = &events[0] {
assert_eq!(err.code, Some("rate_limit".to_string()));
assert_eq!(err.message, "Too many requests");
} else {
panic!("Expected Error event");
}
}
#[test]
fn test_parse_unknown_event() {
let data = r#"{}"#;
let events = parse_event("some.unknown.event", data).unwrap();
assert!(events.is_none());
}
}

View File

@ -1,49 +0,0 @@
//! Open Responses Scheme
//!
//! Handles request/response conversion for the Open Responses API.
//! Since our internal types are already Open Responses native, this scheme
//! primarily passes through data with minimal transformation.
mod events;
mod request;
use crate::llm_client::{ClientError, Request};
pub use events::*;
pub use request::*;
/// Open Responses Scheme
///
/// Handles conversion between internal types and the Open Responses wire format.
#[derive(Debug, Clone, Default)]
pub struct OpenResponsesScheme {
/// Optional model override
pub model: Option<String>,
}
impl OpenResponsesScheme {
/// Create a new OpenResponsesScheme
pub fn new() -> Self {
Self::default()
}
/// Set the model
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Build Open Responses request from internal Request
pub fn build_request(&self, model: &str, request: &Request) -> OpenResponsesRequest {
build_request(model, request)
}
/// Parse SSE event data into internal Event(s)
pub fn parse_event(
&self,
event_type: &str,
data: &str,
) -> Result<Option<Vec<crate::llm_client::Event>>, ClientError> {
parse_event(event_type, data)
}
}

View File

@ -1,285 +0,0 @@
//! Open Responses Request Builder
//!
//! Converts internal Request/Item types to Open Responses API format.
//! Since our internal types are already Open Responses native, this is
//! mostly a direct serialization with some field renaming.
use serde::Serialize;
use serde_json::Value;
use crate::llm_client::{types::Item, Request, ToolDefinition};
/// Open Responses API request body
#[derive(Debug, Serialize)]
pub struct OpenResponsesRequest {
/// Model identifier
pub model: String,
/// Input items (conversation history)
pub input: Vec<OpenResponsesItem>,
/// System instructions
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
/// Tool definitions
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<OpenResponsesTool>,
/// Enable streaming
pub stream: bool,
/// Maximum output tokens
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
/// Temperature
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// Top P (nucleus sampling)
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
}
/// Open Responses input item
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OpenResponsesItem {
/// Message item
Message {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
role: String,
content: Vec<OpenResponsesContentPart>,
},
/// Function call item
FunctionCall {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
call_id: String,
name: String,
arguments: String,
},
/// Function call output item
FunctionCallOutput {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
call_id: String,
output: String,
},
/// Reasoning item
Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
text: String,
},
}
/// Open Responses content part
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OpenResponsesContentPart {
/// Input text (for user messages)
InputText { text: String },
/// Output text (for assistant messages)
OutputText { text: String },
/// Refusal
Refusal { refusal: String },
}
/// Open Responses tool definition
#[derive(Debug, Serialize)]
pub struct OpenResponsesTool {
/// Tool type (always "function")
pub r#type: String,
/// Function definition
pub name: String,
/// Description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Parameters schema
pub parameters: Value,
}
/// Build Open Responses request from internal Request
pub fn build_request(model: &str, request: &Request) -> OpenResponsesRequest {
let input = request.items.iter().map(convert_item).collect();
let tools = request.tools.iter().map(convert_tool).collect();
OpenResponsesRequest {
model: model.to_string(),
input,
instructions: request.system_prompt.clone(),
tools,
stream: true,
max_output_tokens: request.config.max_tokens,
temperature: request.config.temperature,
top_p: request.config.top_p,
}
}
fn convert_item(item: &Item) -> OpenResponsesItem {
match item {
Item::Message {
id,
role,
content,
status: _,
} => {
let role_str = match role {
crate::llm_client::types::Role::User => "user",
crate::llm_client::types::Role::Assistant => "assistant",
crate::llm_client::types::Role::System => "system",
};
let parts = content
.iter()
.map(|p| match p {
crate::llm_client::types::ContentPart::InputText { text } => {
OpenResponsesContentPart::InputText { text: text.clone() }
}
crate::llm_client::types::ContentPart::OutputText { text } => {
OpenResponsesContentPart::OutputText { text: text.clone() }
}
crate::llm_client::types::ContentPart::Refusal { refusal } => {
OpenResponsesContentPart::Refusal {
refusal: refusal.clone(),
}
}
})
.collect();
OpenResponsesItem::Message {
id: id.clone(),
role: role_str.to_string(),
content: parts,
}
}
Item::FunctionCall {
id,
call_id,
name,
arguments,
status: _,
} => OpenResponsesItem::FunctionCall {
id: id.clone(),
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
},
Item::FunctionCallOutput {
id,
call_id,
output,
} => OpenResponsesItem::FunctionCallOutput {
id: id.clone(),
call_id: call_id.clone(),
output: output.clone(),
},
Item::Reasoning {
id,
text,
status: _,
} => OpenResponsesItem::Reasoning {
id: id.clone(),
text: text.clone(),
},
}
}
fn convert_tool(tool: &ToolDefinition) -> OpenResponsesTool {
OpenResponsesTool {
r#type: "function".to_string(),
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::types::Item;
#[test]
fn test_build_simple_request() {
let request = Request::new()
.system("You are a helpful assistant.")
.user("Hello!");
let or_req = build_request("gpt-4o", &request);
assert_eq!(or_req.model, "gpt-4o");
assert_eq!(
or_req.instructions,
Some("You are a helpful assistant.".to_string())
);
assert_eq!(or_req.input.len(), 1);
assert!(or_req.stream);
}
#[test]
fn test_build_request_with_tool() {
let request = Request::new().user("What's the weather?").tool(
ToolDefinition::new("get_weather")
.description("Get current weather")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
})),
);
let or_req = build_request("gpt-4o", &request);
assert_eq!(or_req.tools.len(), 1);
assert_eq!(or_req.tools[0].name, "get_weather");
assert_eq!(or_req.tools[0].r#type, "function");
}
#[test]
fn test_function_call_and_output() {
let request = Request::new()
.user("What's the weather?")
.item(Item::function_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
let or_req = build_request("gpt-4o", &request);
assert_eq!(or_req.input.len(), 3);
// Check function call
if let OpenResponsesItem::FunctionCall { call_id, name, .. } = &or_req.input[1] {
assert_eq!(call_id, "call_123");
assert_eq!(name, "get_weather");
} else {
panic!("Expected FunctionCall");
}
// Check function call output
if let OpenResponsesItem::FunctionCallOutput { call_id, output, .. } = &or_req.input[2] {
assert_eq!(call_id, "call_123");
assert_eq!(output, "Sunny, 25°C");
} else {
panic!("Expected FunctionCallOutput");
}
}
}

View File

@ -1,10 +1,10 @@
//! LLM Client Common Types - Open Responses Native
//! LLM Client Common Types
//!
//! This module defines types that are natively aligned with the Open Responses specification.
//! Core conversation types for insomnia's LLM interaction model.
//! The core abstraction is `Item` which represents different types of conversation elements:
//! - Message items (user/assistant messages with content parts)
//! - FunctionCall items (tool invocations)
//! - FunctionCallOutput items (tool results)
//! - ToolCall items (tool invocations)
//! - ToolResult items (tool results)
//! - Reasoning items (extended thinking)
use serde::{Deserialize, Serialize};
@ -19,28 +19,20 @@ pub type ItemId = String;
/// Call ID type for linking function calls to their outputs
pub type CallId = String;
/// Conversation item - the primary unit in Open Responses
/// Conversation item - the primary unit of conversation history
///
/// Items represent discrete elements in a conversation. Unlike traditional
/// message-based APIs, Open Responses treats tool calls and reasoning as
/// first-class items rather than parts of messages.
/// Items represent discrete elements in a conversation. Tool calls and reasoning
/// are first-class items rather than parts of messages.
///
/// # Examples
///
/// ```ignore
/// use llm_worker::Item;
///
/// // User message
/// let user_item = Item::user_message("Hello!");
///
/// // Assistant message
/// let assistant_item = Item::assistant_message("Hi there!");
///
/// // Function call
/// let call = Item::function_call("call_123", "get_weather", json!({"city": "Tokyo"}));
///
/// // Function call output
/// let result = Item::function_call_output("call_123", "Sunny, 25°C");
/// let user = Item::user_message("Hello!");
/// let assistant = Item::assistant_message("Hi there!");
/// let call = Item::tool_call("call_123", "get_weather", json!({"city": "Tokyo"}));
/// let result = Item::tool_result("call_123", "Sunny, 25°C");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
@ -59,28 +51,28 @@ pub enum Item {
status: Option<ItemStatus>,
},
/// Function (tool) call from the assistant
FunctionCall {
/// Tool call from the assistant
ToolCall {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Call ID for linking to output
/// Call ID for linking to result
call_id: CallId,
/// Function name
/// Tool name
name: String,
/// Function arguments as JSON string
/// Tool arguments as JSON string
arguments: String,
/// Item status
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
},
/// Function (tool) call output/result
FunctionCallOutput {
/// Tool call result
ToolResult {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Call ID linking to the function call
/// Call ID linking to the tool call
call_id: CallId,
/// Output content
output: String,
@ -109,7 +101,7 @@ impl Item {
Self::Message {
id: None,
role: Role::User,
content: vec![ContentPart::InputText { text: text.into() }],
content: vec![ContentPart::Text { text: text.into() }],
status: None,
}
}
@ -129,7 +121,7 @@ impl Item {
Self::Message {
id: None,
role: Role::Assistant,
content: vec![ContentPart::OutputText { text: text.into() }],
content: vec![ContentPart::Text { text: text.into() }],
status: None,
}
}
@ -145,16 +137,16 @@ impl Item {
}
// ========================================================================
// Function call constructors
// Tool call constructors
// ========================================================================
/// Create a function call item
pub fn function_call(
/// Create a tool call item
pub fn tool_call(
call_id: impl Into<String>,
name: impl Into<String>,
arguments: impl Into<String>,
) -> Self {
Self::FunctionCall {
Self::ToolCall {
id: None,
call_id: call_id.into(),
name: name.into(),
@ -163,18 +155,18 @@ impl Item {
}
}
/// Create a function call item from a JSON value
pub fn function_call_json(
/// Create a tool call item from a JSON value
pub fn tool_call_json(
call_id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
Self::function_call(call_id, name, arguments.to_string())
Self::tool_call(call_id, name, arguments.to_string())
}
/// Create a function call output item
pub fn function_call_output(call_id: impl Into<String>, output: impl Into<String>) -> Self {
Self::FunctionCallOutput {
/// Create a tool result item
pub fn tool_result(call_id: impl Into<String>, output: impl Into<String>) -> Self {
Self::ToolResult {
id: None,
call_id: call_id.into(),
output: output.into(),
@ -202,8 +194,8 @@ impl Item {
pub fn with_id(mut self, id: impl Into<String>) -> Self {
match &mut self {
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
Self::FunctionCall { id: item_id, .. } => *item_id = Some(id.into()),
Self::FunctionCallOutput { id: item_id, .. } => *item_id = Some(id.into()),
Self::ToolCall { id: item_id, .. } => *item_id = Some(id.into()),
Self::ToolResult { id: item_id, .. } => *item_id = Some(id.into()),
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
}
self
@ -213,8 +205,8 @@ impl Item {
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
match &mut self {
Self::Message { status, .. } => *status = Some(new_status),
Self::FunctionCall { status, .. } => *status = Some(new_status),
Self::FunctionCallOutput { .. } => {} // Output items don't have status
Self::ToolCall { status, .. } => *status = Some(new_status),
Self::ToolResult { .. } => {} // Result items don't have status
Self::Reasoning { status, .. } => *status = Some(new_status),
}
self
@ -228,8 +220,8 @@ impl Item {
pub fn id(&self) -> Option<&str> {
match self {
Self::Message { id, .. } => id.as_deref(),
Self::FunctionCall { id, .. } => id.as_deref(),
Self::FunctionCallOutput { id, .. } => id.as_deref(),
Self::ToolCall { id, .. } => id.as_deref(),
Self::ToolResult { id, .. } => id.as_deref(),
Self::Reasoning { id, .. } => id.as_deref(),
}
}
@ -238,8 +230,8 @@ impl Item {
pub fn item_type(&self) -> &'static str {
match self {
Self::Message { .. } => "message",
Self::FunctionCall { .. } => "function_call",
Self::FunctionCallOutput { .. } => "function_call_output",
Self::ToolCall { .. } => "tool_call",
Self::ToolResult { .. } => "tool_result",
Self::Reasoning { .. } => "reasoning",
}
}
@ -266,14 +258,14 @@ impl Item {
)
}
/// Check if this is a function call
pub fn is_function_call(&self) -> bool {
matches!(self, Self::FunctionCall { .. })
/// Check if this is a tool call
pub fn is_tool_call(&self) -> bool {
matches!(self, Self::ToolCall { .. })
}
/// Check if this is a function call output
pub fn is_function_call_output(&self) -> bool {
matches!(self, Self::FunctionCallOutput { .. })
/// Check if this is a tool result
pub fn is_tool_result(&self) -> bool {
matches!(self, Self::ToolResult { .. })
}
/// Check if this is a reasoning item
@ -285,8 +277,7 @@ impl Item {
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Message { content, .. } if content.len() == 1 => match &content[0] {
ContentPart::InputText { text } => Some(text),
ContentPart::OutputText { text } => Some(text),
ContentPart::Text { text } => Some(text),
_ => None,
},
_ => None,
@ -300,19 +291,13 @@ impl Item {
/// Content part within a message item
///
/// Open Responses distinguishes between input and output content types.
/// Input types are used in user messages, output types in assistant messages.
/// Text content is role-agnostic; the containing Item's Role determines
/// whether it's user input or assistant output.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
/// Input text (for user messages)
InputText {
/// The text content
text: String,
},
/// Output text (for assistant messages)
OutputText {
/// Text content
Text {
/// The text content
text: String,
},
@ -322,18 +307,12 @@ pub enum ContentPart {
/// The refusal message
refusal: String,
},
// Future: InputAudio, OutputAudio, etc.
}
impl ContentPart {
/// Create an input text part
pub fn input_text(text: impl Into<String>) -> Self {
Self::InputText { text: text.into() }
}
/// Create an output text part
pub fn output_text(text: impl Into<String>) -> Self {
Self::OutputText { text: text.into() }
/// Create a text part
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
/// Create a refusal part
@ -346,8 +325,7 @@ impl ContentPart {
/// Get the text content regardless of type
pub fn as_text(&self) -> &str {
match self {
Self::InputText { text } => text,
Self::OutputText { text } => text,
Self::Text { text } => text,
Self::Refusal { refusal } => refusal,
}
}

View File

@ -1,16 +1,14 @@
//! Message and Item Types
//!
//! This module provides the core types for representing conversation items
//! in the Open Responses format.
//! Core types for representing conversation items.
//!
//! The primary type is [`Item`], which represents different kinds of conversation
//! elements: messages, function calls, function call outputs, and reasoning.
//! elements: messages, tool calls, tool results, and reasoning.
// Re-export all types from llm_client::types
pub use crate::llm_client::types::{ContentPart, Item, Role};
/// Convenience alias for backward compatibility
/// Convenience alias
///
/// In the Open Responses model, messages are just one type of Item.
/// This alias allows code that expects a "Message" type to continue working.
/// Messages are just one type of Item.
pub type Message = Item;

View File

@ -1,448 +1,5 @@
//! Timeline層のイベント型
//!
//! Timelineが受け取り、各Handlerへディスパッチするイベント表現
//! llm_client層のイベント型をそのまま使用する
use serde::{Deserialize, Serialize};
// =============================================================================
// Core Event Types (from llm_client layer)
// =============================================================================
/// LLMからのストリーミングイベント
///
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
/// 統一的に処理されます。
///
/// # イベントの種類
///
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
///
/// # ブロックのライフサイクル
///
/// テキストやツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
/// の順序でイベントが発生します。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// ハートビート
Ping(PingEvent),
/// トークン使用量
Usage(UsageEvent),
/// ストリームのステータス変化
Status(StatusEvent),
/// エラー発生
Error(ErrorEvent),
/// ブロック開始(テキスト、ツール使用等)
BlockStart(BlockStart),
/// ブロックの差分データ
BlockDelta(BlockDelta),
/// ブロック正常終了
BlockStop(BlockStop),
/// ブロック中断
BlockAbort(BlockAbort),
}
// =============================================================================
// Meta Events
// =============================================================================
/// Pingイベントハートビート
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent {
pub timestamp: Option<u64>,
}
/// 使用量イベント
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// 入力トークン数
pub input_tokens: Option<u64>,
/// 出力トークン数
pub output_tokens: Option<u64>,
/// 合計トークン数
pub total_tokens: Option<u64>,
/// キャッシュ読み込みトークン数
pub cache_read_input_tokens: Option<u64>,
/// キャッシュ作成トークン数
pub cache_creation_input_tokens: Option<u64>,
}
/// ステータスイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent {
pub status: ResponseStatus,
}
/// レスポンスステータス
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus {
/// ストリーム開始
Started,
/// 正常完了
Completed,
/// キャンセルされた
Cancelled,
/// エラー発生
Failed,
}
/// エラーイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent {
pub code: Option<String>,
pub message: String,
}
// =============================================================================
// Block Types
// =============================================================================
/// ブロックの種別
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType {
/// テキスト生成
Text,
/// 思考 (Claude Extended Thinking等)
Thinking,
/// ツール呼び出し
ToolUse,
/// ツール結果
ToolResult,
}
/// ブロック開始イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// ブロック固有のメタデータ
pub metadata: BlockMetadata,
}
impl BlockStart {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// ブロックのメタデータ
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata {
Text,
Thinking,
ToolUse { id: String, name: String },
ToolResult { tool_use_id: String },
}
/// ブロックデルタイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta {
/// ブロックのインデックス
pub index: usize,
/// デルタの内容
pub delta: DeltaContent,
}
/// デルタの内容
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent {
/// テキストデルタ
Text(String),
/// 思考デルタ
Thinking(String),
/// ツール引数のJSON部分文字列
InputJson(String),
}
impl DeltaContent {
/// デルタのブロック種別を取得
pub fn block_type(&self) -> BlockType {
match self {
DeltaContent::Text(_) => BlockType::Text,
DeltaContent::Thinking(_) => BlockType::Thinking,
DeltaContent::InputJson(_) => BlockType::ToolUse,
}
}
}
/// ブロック停止イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// 停止理由
pub stop_reason: Option<StopReason>,
}
impl BlockStop {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// ブロック中断イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// 中断理由
pub reason: String,
}
impl BlockAbort {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// 停止理由
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {
/// 自然終了
EndTurn,
/// 最大トークン数到達
MaxTokens,
/// ストップシーケンス到達
StopSequence,
/// ツール使用
ToolUse,
}
// =============================================================================
// Builder / Factory helpers
// =============================================================================
impl Event {
/// テキストブロック開始イベントを作成
pub fn text_block_start(index: usize) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::Text,
metadata: BlockMetadata::Text,
})
}
/// テキストデルタイベントを作成
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::Text(text.into()),
})
}
/// テキストブロック停止イベントを作成
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::Text,
stop_reason,
})
}
/// ツール使用ブロック開始イベントを作成
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: id.into(),
name: name.into(),
},
})
}
/// ツール引数デルタイベントを作成
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::InputJson(json.into()),
})
}
/// ツール使用ブロック停止イベントを作成
pub fn tool_use_stop(index: usize) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::ToolUse,
stop_reason: Some(StopReason::ToolUse),
})
}
/// 使用量イベントを作成
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
Event::Usage(UsageEvent {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
total_tokens: Some(input_tokens + output_tokens),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
}
/// Pingイベントを作成
pub fn ping() -> Self {
Event::Ping(PingEvent { timestamp: None })
}
}
// =============================================================================
// Conversions: llm_client::event -> timeline::event
// =============================================================================
impl From<crate::llm_client::event::ResponseStatus> for ResponseStatus {
fn from(value: crate::llm_client::event::ResponseStatus) -> Self {
match value {
crate::llm_client::event::ResponseStatus::Started => ResponseStatus::Started,
crate::llm_client::event::ResponseStatus::Completed => ResponseStatus::Completed,
crate::llm_client::event::ResponseStatus::Cancelled => ResponseStatus::Cancelled,
crate::llm_client::event::ResponseStatus::Failed => ResponseStatus::Failed,
}
}
}
impl From<crate::llm_client::event::BlockType> for BlockType {
fn from(value: crate::llm_client::event::BlockType) -> Self {
match value {
crate::llm_client::event::BlockType::Text => BlockType::Text,
crate::llm_client::event::BlockType::Thinking => BlockType::Thinking,
crate::llm_client::event::BlockType::ToolUse => BlockType::ToolUse,
crate::llm_client::event::BlockType::ToolResult => BlockType::ToolResult,
}
}
}
impl From<crate::llm_client::event::BlockMetadata> for BlockMetadata {
fn from(value: crate::llm_client::event::BlockMetadata) -> Self {
match value {
crate::llm_client::event::BlockMetadata::Text => BlockMetadata::Text,
crate::llm_client::event::BlockMetadata::Thinking => BlockMetadata::Thinking,
crate::llm_client::event::BlockMetadata::ToolUse { id, name } => {
BlockMetadata::ToolUse { id, name }
}
crate::llm_client::event::BlockMetadata::ToolResult { tool_use_id } => {
BlockMetadata::ToolResult { tool_use_id }
}
}
}
}
impl From<crate::llm_client::event::DeltaContent> for DeltaContent {
fn from(value: crate::llm_client::event::DeltaContent) -> Self {
match value {
crate::llm_client::event::DeltaContent::Text(text) => DeltaContent::Text(text),
crate::llm_client::event::DeltaContent::Thinking(text) => DeltaContent::Thinking(text),
crate::llm_client::event::DeltaContent::InputJson(json) => {
DeltaContent::InputJson(json)
}
}
}
}
impl From<crate::llm_client::event::StopReason> for StopReason {
fn from(value: crate::llm_client::event::StopReason) -> Self {
match value {
crate::llm_client::event::StopReason::EndTurn => StopReason::EndTurn,
crate::llm_client::event::StopReason::MaxTokens => StopReason::MaxTokens,
crate::llm_client::event::StopReason::StopSequence => StopReason::StopSequence,
crate::llm_client::event::StopReason::ToolUse => StopReason::ToolUse,
}
}
}
impl From<crate::llm_client::event::PingEvent> for PingEvent {
fn from(value: crate::llm_client::event::PingEvent) -> Self {
PingEvent {
timestamp: value.timestamp,
}
}
}
impl From<crate::llm_client::event::UsageEvent> for UsageEvent {
fn from(value: crate::llm_client::event::UsageEvent) -> Self {
UsageEvent {
input_tokens: value.input_tokens,
output_tokens: value.output_tokens,
total_tokens: value.total_tokens,
cache_read_input_tokens: value.cache_read_input_tokens,
cache_creation_input_tokens: value.cache_creation_input_tokens,
}
}
}
impl From<crate::llm_client::event::StatusEvent> for StatusEvent {
fn from(value: crate::llm_client::event::StatusEvent) -> Self {
StatusEvent {
status: value.status.into(),
}
}
}
impl From<crate::llm_client::event::ErrorEvent> for ErrorEvent {
fn from(value: crate::llm_client::event::ErrorEvent) -> Self {
ErrorEvent {
code: value.code,
message: value.message,
}
}
}
impl From<crate::llm_client::event::BlockStart> for BlockStart {
fn from(value: crate::llm_client::event::BlockStart) -> Self {
BlockStart {
index: value.index,
block_type: value.block_type.into(),
metadata: value.metadata.into(),
}
}
}
impl From<crate::llm_client::event::BlockDelta> for BlockDelta {
fn from(value: crate::llm_client::event::BlockDelta) -> Self {
BlockDelta {
index: value.index,
delta: value.delta.into(),
}
}
}
impl From<crate::llm_client::event::BlockStop> for BlockStop {
fn from(value: crate::llm_client::event::BlockStop) -> Self {
BlockStop {
index: value.index,
block_type: value.block_type.into(),
stop_reason: value.stop_reason.map(Into::into),
}
}
}
impl From<crate::llm_client::event::BlockAbort> for BlockAbort {
fn from(value: crate::llm_client::event::BlockAbort) -> Self {
BlockAbort {
index: value.index,
block_type: value.block_type.into(),
reason: value.reason,
}
}
}
impl From<crate::llm_client::event::Event> for Event {
fn from(value: crate::llm_client::event::Event) -> Self {
match value {
crate::llm_client::event::Event::Ping(p) => Event::Ping(p.into()),
crate::llm_client::event::Event::Usage(u) => Event::Usage(u.into()),
crate::llm_client::event::Event::Status(s) => Event::Status(s.into()),
crate::llm_client::event::Event::Error(e) => Event::Error(e.into()),
crate::llm_client::event::Event::BlockStart(s) => Event::BlockStart(s.into()),
crate::llm_client::event::Event::BlockDelta(d) => Event::BlockDelta(d.into()),
crate::llm_client::event::Event::BlockStop(s) => Event::BlockStop(s.into()),
crate::llm_client::event::Event::BlockAbort(a) => Event::BlockAbort(a.into()),
}
}
}
pub use crate::llm_client::event::*;

View File

@ -516,9 +516,9 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
items.push(Item::assistant_message(text));
}
// Add tool calls as FunctionCall items
// Add tool calls as ToolCall items
for call in tool_calls {
items.push(Item::function_call_json(
items.push(Item::tool_call_json(
&call.id,
&call.name,
call.input.clone(),
@ -702,20 +702,20 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
/// Check for pending tool calls (for resuming from Pause)
fn get_pending_tool_calls(&self) -> Option<Vec<ToolCall>> {
// Find the last FunctionCall items that don't have corresponding FunctionCallOutput
// Find the last ToolCall items that don't have corresponding ToolResult
let mut pending_calls = Vec::new();
let mut answered_call_ids = std::collections::HashSet::new();
// First pass: collect all answered call IDs
for item in &self.history {
if let Item::FunctionCallOutput { call_id, .. } = item {
if let Item::ToolResult { call_id, .. } = item {
answered_call_ids.insert(call_id.clone());
}
}
// Second pass: find unanswered function calls
// Second pass: find unanswered tool calls
for item in &self.history {
if let Item::FunctionCall {
if let Item::ToolCall {
call_id,
name,
arguments,
@ -894,7 +894,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
Ok(ToolExecutionResult::Completed(results)) => {
for result in results {
self.history.push(Item::function_call_output(
self.history.push(Item::tool_result(
&result.tool_use_id,
&result.content,
));
@ -985,27 +985,25 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
let event = result
.inspect_err(|_| self.last_run_interrupted = true)?;
let timeline_event: crate::timeline::event::Event = event.into();
self.timeline.dispatch(&timeline_event);
self.timeline.dispatch(&event);
let worker_event: crate::event::Event = timeline_event.clone().into();
self.run_on_stream_chunk_hooks(worker_event)
self.run_on_stream_chunk_hooks(event.clone())
.await
.inspect_err(|_| self.last_run_interrupted = true)?;
if let crate::timeline::event::Event::BlockDelta(delta) = &timeline_event {
if let crate::llm_client::event::Event::BlockDelta(delta) = &event {
match &delta.delta {
crate::timeline::event::DeltaContent::Text(text) => {
crate::llm_client::event::DeltaContent::Text(text) => {
self.run_on_text_delta_hooks(delta.index, text.clone())
.await
.inspect_err(|_| self.last_run_interrupted = true)?;
}
crate::timeline::event::DeltaContent::InputJson(json_fragment) => {
crate::llm_client::event::DeltaContent::InputJson(json_fragment) => {
self.run_on_tool_call_delta_hooks(delta.index, json_fragment.clone())
.await
.inspect_err(|_| self.last_run_interrupted = true)?;
}
crate::timeline::event::DeltaContent::Thinking(_) => {}
crate::llm_client::event::DeltaContent::Thinking(_) => {}
}
}
}
@ -1072,7 +1070,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
Ok(ToolExecutionResult::Completed(results)) => {
for result in results {
self.history.push(Item::function_call_output(
self.history.push(Item::tool_result(
&result.tool_use_id,
&result.content,
));