yoi/crates/llm-worker/docs/spec/hooks.md
2026-04-04 04:27:46 +09:00

557 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` | 外部システムへの通知 |