llm-worker-rs/docs/hooks.md

21 KiB
Raw Blame History

Worker Hook システム

Workerのフックシステムは、LLMとの対話プロセスの各段階でカスタムロジックを実行し、システムの動作を柔軟に制御できる強力な機能です。

概要

Hooksシステムは、WorkerがLLMとの対話で実行する各フェーズメッセージ処理、ツール実行、ターン完了でユーザー定義の処理を挿入できる機能です。これにより、以下のような高度なカスタマイズが可能になります

  • メッセージの前処理: ユーザーメッセージにコンテキスト情報を追加
  • ツール実行後の処理: ファイル操作後の自動コミットやフォーマット
  • ターン完了時の処理: 統計記録や追加情報の提供
  • ストリーミング制御: リアルタイム応答の変更・拡張

アーキテクチャ

コア型

// worker-types/src/lib.rs
pub trait WorkerHook: Send + Sync {
    fn name(&self) -> &str;
    fn hook_type(&self) -> &str;
    fn matcher(&self) -> &str;
    async fn execute(&self, context: HookContext) -> (HookContext, HookResult);
}

pub struct HookManager {
    hooks: HashMap<HookEvent, Vec<Box<dyn WorkerHook>>>,
}

pub enum HookEvent {
    OnMessageSend,
    PreToolUse,
    PostToolUse,
    OnTurnCompleted,
}

Hook実行フロー

1. ユーザーメッセージ受信
2. OnMessageSend hooks 実行
3. プロンプト構築・LLMへ送信
4. ストリーミング応答開始
5. ツール呼び出し検出
   ├── PreToolUse hooks 実行
   ├── ツール実行
   └── PostToolUse hooks 実行
6. 応答完了
7. OnTurnCompleted hooks 実行
8. 結果返却

Hook の種類

フェーズ別Hook

フェーズ タイプ名 実行タイミング ストリーミング
メッセージ送信時 OnMessageSend ユーザーメッセージをLLMに送信する直前 ×
ツール実行前 PreToolUse 特定のツール実行直前
ツール実行後 PostToolUse 特定のツール実行完了後
ターン完了時 OnTurnCompleted AIの応答が完了した時点

マッチャーパターン

PreToolUsePostToolUseおよびOnTurnCompletedでは、matcherパラメータで特定のツールに対してのみHookを実行できます

// Edit または Create ツールの実行後のみ実行
#[hook(hook_type = "PostToolUse", matcher = "Edit|Create")]

// すべてのツールで実行matcher省略時
#[hook(hook_type = "OnTurnCompleted")]

// 正規表現マッチング
#[hook(hook_type = "PostToolUse", matcher = r"^(Read|Write)File.*")]

Hook の作成方法

基本構文

use worker::types::{HookContext, HookResult, Role};
use worker_macros::hook;

#[hook(hook_type = "フェーズ名", matcher = "パターン")]
pub async fn your_hook_name(mut context: HookContext) -> HookResult {
    // カスタムロジックをここに実装
    HookResult::Continue
}

マクロを使わない実装

use worker::types::{WorkerHook, HookContext, HookResult};
use async_trait::async_trait;

pub struct CustomHook {
    name: String,
}

impl CustomHook {
    pub fn new(name: String) -> Self {
        Self { name }
    }
}

#[async_trait]
impl WorkerHook for CustomHook {
    fn name(&self) -> &str {
        &self.name
    }

    fn hook_type(&self) -> &str {
        "OnMessageSend"
    }

    fn matcher(&self) -> &str {
        ""
    }

    async fn execute(&self, mut context: HookContext) -> (HookContext, HookResult) {
        // Hook処理をここに実装
        (context, HookResult::Continue)
    }
}

HookContext API

HookContextは、Hook内で使用できる情報とメソッドを提供します

利用可能なデータ

pub struct HookContext {
    pub content: String,                    // 処理対象のコンテンツ
    pub workspace_path: String,             // 現在のワークスペースパス
    pub message_history: Vec<Message>,      // これまでの会話履歴
    pub tools: Vec<DynamicToolDefinition>,  // 利用可能なツール一覧
    pub variables: HashMap<String, String>, // Hook間で共有可能な変数
    pub tool_name: Option<String>,          // 実行中のツール名(ツール関連フックのみ)
    pub tool_args: Option<serde_json::Value>, // ツール実行引数(ツール関連フックのみ)
    pub tool_result: Option<String>,        // ツール実行結果PostToolUseのみ
}

操作メソッド

impl HookContext {
    // ワークスペースでコマンドを実行
    pub async fn run_command(&self, command: &str) -> Result<String, Box<dyn std::error::Error>>;

    // ツールを実行(将来実装予定)
    pub async fn run_tool(&self, tool_name: &str, args: serde_json::Value) -> Result<String, Box<dyn std::error::Error>>;

    // メッセージを履歴に追加
    pub fn add_message(&mut self, content: String, role: Role);

    // コンテンツを書き換え
    pub fn set_content(&mut self, content: String);

    // 変数の設定・取得
    pub fn set_variable(&mut self, key: String, value: String);
    pub fn get_variable(&self, key: &str) -> Option<&String>;

    // ツール情報の取得
    pub fn get_tool(&self, tool_name: &str) -> Option<&DynamicToolDefinition>;
    pub fn list_tool_names(&self) -> Vec<&String>;
}

ストリーミング用メソッド

impl HookContext {
    // ストリーミング中にメッセージを送信
    pub fn stream_message(&self, content: String, role: Role);

    // ストリーミング中にシステム通知を送信
    pub fn stream_system_message(&self, content: String);

    // ストリーミング中にデバッグ情報を送信
    pub fn stream_debug(&self, title: String, data: serde_json::Value);
}

HookResult の種類

Hook関数は以下のいずれかの結果を返す必要があります

pub enum HookResult {
    // 処理を続行
    Continue,

    // コンテンツを変更して続行
    ModifyContent(String),

    // システムメッセージを追加して続行
    AddMessage(String, Role),

    // 複数のメッセージを追加して続行
    AddMessages(Vec<Message>),

    // ターンを強制完了
    Complete,

    // エラーでターンを終了
    Error(String),

    // Hook処理をスキップデバッグ用
    Skip,
}

実用例

1. タイムスタンプ付きメッセージ

/// メッセージ送信時に現在時刻を追加するHook
#[hook(hook_type = "OnMessageSend")]
pub async fn add_timestamp_hook(mut context: HookContext) -> HookResult {
    let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
    let enhanced_content = format!("[{}] {}", timestamp, context.content);
    HookResult::ModifyContent(enhanced_content)
}

2. ファイル操作後の自動Git追跡

/// ファイル編集後にGitステータスを確認するHook
#[hook(hook_type = "PostToolUse", matcher = "Edit|Create|Write")]
pub async fn file_change_notification_hook(mut context: HookContext) -> HookResult {
    match context.run_command("git status --porcelain").await {
        Ok(output) => {
            if !output.trim().is_empty() {
                context.add_message(
                    format!("📝 ファイル変更を検出:\n{}", output),
                    Role::System,
                );
                HookResult::Continue
            } else {
                HookResult::Continue
            }
        }
        Err(_) => HookResult::Continue,
    }
}

3. 長い応答への注意喚起

/// 長い応答が生成された際に注意を促すHook
#[hook(hook_type = "OnTurnCompleted")]
pub async fn long_response_warning_hook(context: HookContext) -> HookResult {
    if context.content.len() > 2000 {
        HookResult::AddMessage(
            "⚠️ 長い応答が生成されました。スクロールして全体を確認してください。".to_string(),
            Role::System,
        )
    } else {
        HookResult::Continue
    }
}

4. ツール実行前のバリデーション

/// 危険なコマンドの実行前に確認を行うHook
#[hook(hook_type = "PreToolUse", matcher = "Execute|Shell")]
pub async fn dangerous_command_hook(context: HookContext) -> HookResult {
    if let Some(args) = &context.tool_args {
        if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
            let dangerous_commands = ["rm -rf", "format", "dd if="];

            for dangerous in &dangerous_commands {
                if command.contains(dangerous) {
                    return HookResult::Error(format!(
                        "危険なコマンド「{}」の実行が阻止されました: {}",
                        dangerous, command
                    ));
                }
            }
        }
    }

    HookResult::Continue
}

5. 自動読み取りHook (TUI用)

/// ファイル作成・編集後に自動的にファイル内容を読み取るHook
#[hook(hook_type = "PostToolUse", matcher = "Edit|Create|Write")]
pub async fn auto_read_hook(mut context: HookContext) -> HookResult {
    // ツール実行結果からファイルパスを抽出
    if let Some(tool_result) = &context.tool_result {
        if let Ok(result_data) = serde_json::from_str::<serde_json::Value>(tool_result) {
            if let Some(file_path) = result_data.get("file_path").and_then(|v| v.as_str()) {
                // ファイル内容を読み取って表示
                match tokio::fs::read_to_string(file_path).await {
                    Ok(content) => {
                        context.stream_system_message(format!(
                            "📄 **{}** の内容:\n```\n{}\n```",
                            file_path, content
                        ));
                    }
                    Err(e) => {
                        context.stream_system_message(format!(
                            "❌ ファイル読み取りエラー ({}): {}",
                            file_path, e
                        ));
                    }
                }
            }
        }
    }

    HookResult::Continue
}

Hook の登録と管理

Workerへの登録

use worker::Worker;

// 単一のHookを登録
worker.register_hook(Box::new(YourHook::new()));

// 複数のHookを一括登録
let hooks = vec![
    Box::new(TimestampHook::new()),
    Box::new(FileNotificationHook::new()),
    Box::new(DebugHook::new()),
];
worker.register_hooks(hooks);

// TUI用デフォルトHookの登録
let tui_hooks = crate::tui::hooks::get_default_hooks();
worker.register_hooks(tui_hooks);

Hook の実行順序

同じフェーズで複数のHookが登録されている場合、登録順に実行されます。先に実行されたHookの結果コンテキストの変更などは、後続のHookに引き継がれます。

// 実行順序の例
worker.register_hook(Box::new(TimestampHook));     // 1番目
worker.register_hook(Box::new(ValidationHook));   // 2番目
worker.register_hook(Box::new(LoggingHook));      // 3番目

Hook の中断

HookResult::CompleteまたはHookResult::Errorを返すHookがあると、それ以降のHookは実行されず、処理が中断されます。

Hook の動的管理

impl Worker {
    // Hook一覧を取得
    pub fn list_hooks(&self) -> Vec<(&str, &str)>; // (name, hook_type)

    // 特定のHookを削除
    pub fn remove_hook(&mut self, hook_name: &str) -> bool;

    // フェーズ別Hookを削除
    pub fn remove_hooks_by_phase(&mut self, hook_type: &str);

    // すべてのHookをクリア
    pub fn clear_hooks(&mut self);
}

ストリーミング処理

ストリーミング中のHook実行

// worker/src/lib.rs の process_with_shared_state より
stream! {
    // ... LLM応答処理中 ...

    // ツール呼び出し検出時
    if let Some(tool_calls) = &response.tool_calls {
        for tool_call in tool_calls {
            // PreToolUse hooks 実行
            let (context, hook_result) = execute_hooks(
                HookEvent::PreToolUse,
                tool_call.name.clone()
            ).await;

            match hook_result {
                HookResult::Error(msg) => {
                    yield Ok(StreamEvent::Error(msg));
                    continue;
                }
                HookResult::Complete => break,
                _ => {}
            }

            // ツール実行
            let result = execute_tool(tool_call).await;

            // PostToolUse hooks 実行(ストリーミング中)
            let (context, hook_result) = execute_hooks(
                HookEvent::PostToolUse,
                tool_call.name.clone()
            ).await;

            // Hook結果を即座にストリーミング
            if let HookResult::AddMessage(msg, role) = hook_result {
                yield Ok(StreamEvent::HookMessage {
                    hook_name: "PostToolUse".to_string(),
                    content: msg,
                    role,
                });
            }
        }
    }
}

ベストプラクティス

1. エラーハンドリング

#[hook(hook_type = "OnTurnCompleted")]
pub async fn robust_hook(mut context: HookContext) -> HookResult {
    match context.run_command("potentially_failing_command").await {
        Ok(output) => {
            // 成功時の処理
            context.add_message(format!("実行完了: {}", output), Role::System);
            HookResult::Continue
        }
        Err(error) => {
            // エラーログを記録するが、処理は継続
            tracing::warn!("Hook実行時にエラー: {}", error);
            HookResult::Continue
        }
    }
}

2. パフォーマンス配慮

#[hook(hook_type = "OnTurnCompleted")]
pub async fn performance_aware_hook(context: HookContext) -> HookResult {
    // 重い処理は条件分岐で制限
    if context.content.len() > 10000 {
        // 大きなコンテンツの場合はスキップ
        return HookResult::Skip;
    }

    // 非同期処理は適切にawaitする
    let result = tokio::time::timeout(
        Duration::from_secs(5),
        expensive_operation(&context)
    ).await;

    match result {
        Ok(output) => HookResult::AddMessage(output, Role::System),
        Err(_) => {
            tracing::warn!("Hook処理がタイムアウトしました");
            HookResult::Continue
        }
    }
}

3. 設定可能なHook

#[hook(hook_type = "OnMessageSend")]
pub async fn configurable_hook(mut context: HookContext) -> HookResult {
    // 環境変数で動作を制御
    let enabled = std::env::var("HOOK_ENABLED")
        .unwrap_or_default()
        .parse::<bool>()
        .unwrap_or(false);

    if !enabled {
        return HookResult::Skip;
    }

    // 設定ファイルからオプション読み込み
    let config_path = format!("{}/hook_config.json", context.workspace_path);
    if let Ok(config_content) = tokio::fs::read_to_string(&config_path).await {
        if let Ok(config) = serde_json::from_str::<HookConfig>(&config_content) {
            // 設定に基づく処理
            return process_with_config(&mut context, &config).await;
        }
    }

    HookResult::Continue
}

4. 条件付きHook

#[hook(hook_type = "PostToolUse", matcher = ".*")]
pub async fn conditional_hook(context: HookContext) -> HookResult {
    // ワークスペースの種類に応じて処理を変更
    let is_git_repo = context.run_command("git status").await.is_ok();
    let is_rust_project = tokio::fs::metadata(
        format!("{}/Cargo.toml", context.workspace_path)
    ).await.is_ok();

    match (is_git_repo, is_rust_project) {
        (true, true) => {
            // Rustプロジェクト + Git
            context.run_command("cargo fmt").await.ok();
            HookResult::AddMessage("Rustコードをフォーマットしました".to_string(), Role::System)
        }
        (true, false) => {
            // その他のGitプロジェクト
            HookResult::AddMessage("Gitプロジェクトで作業中です".to_string(), Role::System)
        }
        _ => HookResult::Continue
    }
}

デバッグとテスト

Hook のテスト

#[cfg(test)]
mod tests {
    use super::*;
    use worker::types::*;

    #[tokio::test]
    async fn test_timestamp_hook() {
        let mut context = HookContext {
            content: "Hello, world!".to_string(),
            workspace_path: "/tmp".to_string(),
            message_history: vec![],
            tools: vec![],
            variables: HashMap::new(),
            tool_name: None,
            tool_args: None,
            tool_result: None,
        };

        let result = add_timestamp_hook(context).await;

        match result {
            HookResult::ModifyContent(content) => {
                assert!(content.contains("Hello, world!"));
                assert!(content.contains("["));
                assert!(content.contains("]"));
            }
            _ => panic!("Expected ModifyContent"),
        }
    }
}

Hook のデバッグ

#[hook(hook_type = "OnMessageSend")]
pub async fn debug_hook(context: HookContext) -> HookResult {
    tracing::debug!(
        "Hook実行 - コンテンツ長: {}, ツール数: {}, 履歴数: {}",
        context.content.len(),
        context.tools.len(),
        context.message_history.len()
    );

    // デバッグ情報をストリーミング
    context.stream_debug(
        "Hook Debug Info".to_string(),
        serde_json::json!({
            "content_length": context.content.len(),
            "tool_count": context.tools.len(),
            "history_count": context.message_history.len(),
            "workspace": context.workspace_path
        })
    );

    HookResult::Continue
}

高度なHookパターン

状態を持つHook

pub struct StatefulHook {
    counter: Arc<Mutex<u32>>,
}

impl StatefulHook {
    pub fn new() -> Self {
        Self {
            counter: Arc::new(Mutex::new(0)),
        }
    }
}

#[async_trait]
impl WorkerHook for StatefulHook {
    fn name(&self) -> &str { "stateful_hook" }
    fn hook_type(&self) -> &str { "OnTurnCompleted" }
    fn matcher(&self) -> &str { "" }

    async fn execute(&self, mut context: HookContext) -> (HookContext, HookResult) {
        let mut count = self.counter.lock().unwrap();
        *count += 1;

        context.set_variable("turn_count".to_string(), count.to_string());

        if *count % 10 == 0 {
            (
                context,
                HookResult::AddMessage(
                    format!("🎉 {}回目のターンです!", count),
                    Role::System
                )
            )
        } else {
            (context, HookResult::Continue)
        }
    }
}

チェーン可能なHook

pub struct HookChain {
    hooks: Vec<Box<dyn WorkerHook>>,
}

impl HookChain {
    pub fn new() -> Self {
        Self { hooks: Vec::new() }
    }

    pub fn add_hook(mut self, hook: Box<dyn WorkerHook>) -> Self {
        self.hooks.push(hook);
        self
    }
}

#[async_trait]
impl WorkerHook for HookChain {
    fn name(&self) -> &str { "hook_chain" }
    fn hook_type(&self) -> &str { "OnMessageSend" }
    fn matcher(&self) -> &str { "" }

    async fn execute(&self, mut context: HookContext) -> (HookContext, HookResult) {
        for hook in &self.hooks {
            let (new_context, result) = hook.execute(context).await;
            context = new_context;

            match result {
                HookResult::Continue | HookResult::Skip => continue,
                other => return (context, other),
            }
        }

        (context, HookResult::Continue)
    }
}

よくある質問

Q: Hookはどのように読み込まれますか

A: 現在、HookはRustコードとしてコンパイル時に組み込まれます。#[hook]マクロを使用するか、WorkerHookトレイトを実装してworker.register_hook()で登録します。

Q: Hook内でファイルシステムにアクセスできますか

A: はい。run_commandメソッドを使用してシェルコマンドを実行できるほか、Rustの標準ライブラリやtokioを使用した直接的なファイル操作も可能です。

Q: Hook間でデータを共有できますか

A: HookContextvariablesを使用して、同一ターン内のHook間でデータを共有できます。永続的なデータ共有には、ファイルやデータベースを使用してください。

Q: ストリーミング中にHookの結果を表示できますか

A: はい。context.stream_message()context.stream_system_message()を使用することで、ストリーミング中にリアルタイムでメッセージを送信できます。

Q: Hookでエラーが発生した場合はどうなりますか

A: HookResult::Errorを返すと、そのターンは中断されます。継続したい場合は、エラーをログに記録してHookResult::Continueを返してください。

関連ドキュメント

  • worker.md - Worker全体の文書
  • worker-macro.md - マクロシステム
  • worker/src/lib.rs - Hook実装コード
  • worker-types/src/lib.rs - Hook型定義