16 KiB
Worker & Tool/Hook 設計
概要
Workerはアプリケーションの「ターン」を制御する高レベルコンポーネントです。
LlmClientとTimelineを内包し、ユーザー定義のToolとHookを用いて自律的なインタラクションを行います。
アーキテクチャ
graph TD
User[Application / User] -->|1. Run| Worker
Worker -->|2. Event Loop| Timeline
Timeline -->|3. Dispatch| Handler[Handlers (inc. ToolExecutor)]
subgraph "Worker Layer"
Worker
Hook[Hooks]
end
subgraph "Core Layer"
Timeline
LlmClient
end
Worker -.->|Intervene| Hook
Handler -.->|Execute| Tool[User Defined Tools]
ライフサイクル (ターン制御)
Workerは以下のループ(ターン)を実行します。
- Start Turn:
Worker::run(messages)呼び出し - Hook: OnMessageSend:
- ユーザーメッセージの改変、バリデーション、キャンセルが可能。
- コンテキストへのシステムプロンプト注入などもここで行う想定。
- Request & Stream:
- LLMへリクエスト送信。イベントストリーム開始。
Timelineによるイベント処理。
- Tool Handling (Parallel):
- レスポンス内に含まれる全てのTool Callを収集。
- 各Toolに対して Hook: BeforeToolCall を実行(実行可否、引数改変)。
- 許可されたToolを並列実行 (
join_all)。 - 各Tool実行後に Hook: AfterToolCall を実行(結果の確認、加工)。
- Next Request Decision:
- Tool実行結果がある場合 -> 結果をMessageとしてContextに追加し、Step 3へ戻る (自動ループ)。
- Tool実行がない場合 -> Step 6へ。
- Hook: OnTurnEnd:
- 最終的な応答に対するチェック(Lint/Fmt)。
- エラーがある場合、エラーメッセージをContextに追加して Step 3へ戻る ことで自己修正を促せる。
- 問題なければターン終了。
Tool 設計
アーキテクチャ概要
Rustの静的型付けシステムとLLMの動的なツール呼び出し(文字列による指定)を、Trait Object と 動的ディスパッチ を用いて接続します。
- 共通インターフェース (
ToolTrait): 全てのツールが実装すべき共通の振る舞い(メタデータ取得と実行)を定義します。 - ラッパー生成 (
#[tool]Macro): ユーザー定義のメソッドをラップし、ToolTraitを実装した構造体を自動生成します。 - レジストリ (
HashMap): Workerは動的ディスパッチ用にHashMap<String, Box<dyn Tool>>でツールを管理します。
この仕組みにより、「名前からツールを探し、JSON引数を型変換して関数を実行する」フローを安全に実現します。
1. Tool Trait 定義
ツールが最低限持つべきインターフェースです。Send + Sync を必須とし、マルチスレッド(並列実行)に対応します。
#[async_trait]
pub trait Tool: Send + Sync {
/// ツール名 (LLMが識別に使用)
fn name(&self) -> &str;
/// ツールの説明 (LLMへのプロンプトに含まれる)
fn description(&self) -> &str;
/// 引数のJSON Schema (schemars等で生成)
fn input_schema(&self) -> serde_json::Value;
/// 実行関数
/// JSON文字列を受け取り、デシリアライズして元のメソッドを実行し、結果を返す
async fn execute(&self, input_json: &str) -> Result<String, ToolError>;
}
2. マクロと実装モデル
ユーザーは「状態を持つ構造体」とその「メソッド」としてツールを定義します。
ユーザーコード:
#[derive(Clone)] // 状態はClone (Arc推奨) で共有される想定
struct MyApp {
db: Arc<Database>,
}
impl MyApp {
/// ユーザー情報を取得する
/// 指定されたIDのユーザーをDBから検索します。
#[tool]
async fn get_user(
&self,
#[description = "取得したいユーザーのID"] user_id: String
) -> Result<User, Error> {
let user = self.db.find(&user_id).await?;
Ok(user)
}
}
マクロ展開後のイメージ (擬似コード):
マクロは、元のメソッドに対応するラッパー構造体を生成します。このラッパーが Tool Trait を実装します。
// 1. 引数をデシリアライズ用の中間構造体に変換
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct GetUserArgs {
/// 取得したいユーザーのID
user_id: String,
}
// 2. ラッパー構造体 (元のコンテキストを持つ)
struct GetUserTool {
ctx: MyApp, // コンテキストを保持 (Clone)
}
#[async_trait]
impl Tool for GetUserTool {
fn name(&self) -> &str { "get_user" }
fn description(&self) -> &str { "ユーザー情報を取得する\n指定されたIDのユーザーをDBから検索します。" }
fn input_schema(&self) -> serde_json::Value {
schemars::schema_for!(GetUserArgs)
}
async fn execute(&self, input_json: &str) -> Result<String, ToolError> {
// A. JSONを引数構造体に変換
let args: GetUserArgs = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
// B. 元のメソッド呼び出し (self.ctx 経由)
let result = self.ctx.get_user(args.user_id).await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
// C. 結果を文字列化
Ok(format!("{:?}", result)) // または serde_json::to_string(&result)
}
}
3. Workerによる実行フロー
Workerは生成されたラッパー構造体を Box<dyn Tool> として保持し、以下のフローで実行します。
- 登録: アプリケーション開始時、コンテキスト(
MyApp)から各ツールのラッパー(GetUserTool)を生成し、WorkerのMapに登録。 - 解決: LLMからのレスポンスに含まれる
ToolUse { name: "get_user", ... }を受け取る。 - 検索:
nameをキーに Map からBox<dyn Tool>を取得。 - 実行:
tool.execute(json)を呼び出す。- 内部で
serde_jsonによる型変換とメソッド実行が行われる。 - 結果が返る。
これにより、型安全性を保ちつつ、動的なツール実行が可能になります。
Hook 設計
コンセプト
- 制御の介入: ターンの進行、メッセージの内容、ツールの実行に対して介入します。
- Contextへのアクセス: メッセージ履歴(Context)を読み書きできます。
Hook Trait
#[async_trait]
pub trait Hook<E: HookEventKind>: Send + Sync {
async fn call(&self, input: &mut E::Input) -> Result<E::Output, Error>;
}
pub trait HookEventKind {
type Input;
type Output;
}
pub struct OnMessageSend;
pub struct BeforeToolCall;
pub struct AfterToolCall;
pub struct OnTurnEnd;
pub struct OnAbort;
pub enum OnMessageSendResult {
Continue,
Cancel(String),
}
pub enum BeforeToolCallResult {
Continue,
Skip, // Tool実行などをスキップ
Abort(String), // 処理中断
Pause,
}
pub enum AfterToolCallResult {
Continue,
Abort(String),
}
pub enum OnTurnEndResult {
Finish,
ContinueWithMessages(Vec<Message>), // メッセージを追加してターン継続(自己修正など)
Paused,
}
Tool Call Context
before_tool_call / after_tool_call は、ツール実行の文脈を含む入力を受け取る。
pub struct ToolCallContext {
pub call: ToolCall,
pub meta: ToolMeta, // 不変メタデータ
pub tool: Arc<dyn Tool>, // 状態アクセス用
}
pub struct ToolResultContext {
pub result: ToolResult,
pub meta: ToolMeta,
pub tool: Arc<dyn Tool>,
}
実装方針
-
Worker Struct:
Timelineを所有。Handlerとして「ToolCallCollector」をTimelineに登録。stream終了後に収集したToolCallを処理するロジックを持つ。
-
Tool Executor Handler:
- Timeline上ではツール実行を行わず、あくまで「ToolCallブロックの収集」に徹する(Toolの実行は非同期かつ並列で、ストリーム終了後あるいはブロック確定後に行うため)。
- ただし、リアルタイム性を重視する場合(ストリーミング中にToolを実行開始等)は将来的な拡張とするが、現状は「結果が揃うのを待って」という要件に従い、収集フェーズと実行フェーズを分ける。
-
worker-macros:
syn,quoteを用いて、関数定義からToolトレイト実装とInputSchema(schemars利用) を生成。
Worker Event API 設計
背景と目的
Workerは内部でイベントを処理し結果を返しますが、UIへのストリーミング表示やリアルタイムフィードバックには、イベントを外部に公開する仕組みが必要です。
要件:
- テキストデルタをリアルタイムでUIに表示
- ツール呼び出しの進行状況を表示
- ブロック完了時に累積結果を受け取る
設計思想
Worker APIは Timeline層のHandler機構の薄いラッパー として設計します。
| 層 | 目的 | 提供するもの |
|---|---|---|
| Handler (Timeline層) | 内部実装、役割分離 | スコープ管理 + Deltaイベント |
| Worker Event API | ユーザー向け利便性 | Handler露出 + Completeイベント追加 |
Handlerのスコープ管理パターン(Start→Delta→End)をそのまま活かしつつ、累積済みのCompleteイベントも追加提供します。
APIパターン
1. 個別登録: worker.on_*(handler)
Timelineのon_*メソッドを直接露出。必要なイベントだけを個別に登録可能にする。
// ブロックイベント(スコープ管理あり)
worker.on_text_block(my_text_handler); // Handler<TextBlockKind>
worker.on_tool_use_block(my_tool_handler); // Handler<ToolUseBlockKind>
// 単発イベント(スコープ = ())
worker.on_usage(my_usage_handler); // Handler<UsageKind>
worker.on_status(my_status_handler); // Handler<StatusKind>
// 累積イベント(Worker層で追加、スコープ = ())
worker.on_text_complete(my_complete_handler); // Handler<TextCompleteKind>
worker.on_tool_call_complete(my_tool_complete); // Handler<ToolCallCompleteKind>
2. 一括登録: worker.subscribe(subscriber)
WorkerSubscriberトレイトを実装し、全ハンドラをまとめて登録。
/// 統合Subscriberトレイト
pub trait WorkerSubscriber: Send {
// スコープ型(ブロックイベント用)
type TextBlockScope: Default + Send;
type ToolUseBlockScope: Default + Send;
// === ブロックイベント(スコープ管理あり)===
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) {}
}
使用例: WorkerSubscriber
struct MyUI {
chat_view: ChatView,
}
impl WorkerSubscriber for MyUI {
type TextBlockScope = TextComponent;
type ToolUseBlockScope = ToolComponent;
fn on_text_block(&mut self, comp: &mut TextComponent, event: &TextBlockEvent) {
match event {
TextBlockEvent::Start(_) => {
// スコープ開始時にコンポーネント初期化(Defaultで自動生成)
}
TextBlockEvent::Delta(text) => {
comp.append(text);
self.chat_view.update(comp);
}
TextBlockEvent::Stop(_) => {
comp.set_immutable();
// スコープ終了後に自動破棄
}
}
}
fn on_text_complete(&mut self, text: &str) {
// 累積済みテキストを履歴に保存
self.chat_view.add_to_history(text);
}
fn on_tool_use_block(&mut self, comp: &mut ToolComponent, event: &ToolUseBlockEvent) {
match event {
ToolUseBlockEvent::Start(start) => {
comp.set_name(&start.name);
self.chat_view.show_tool_indicator(comp);
}
ToolUseBlockEvent::InputJsonDelta(delta) => {
comp.append_input(delta);
}
ToolUseBlockEvent::Stop(_) => {
comp.finalize();
}
}
}
fn on_tool_call_complete(&mut self, call: &ToolCall) {
self.chat_view.update_tool_result(&call.name, &call.input);
}
}
// Worker に登録
let mut worker = Worker::new(client);
worker.subscribe(MyUI::new());
let result = worker.run(messages).await?;
使用例: 個別登録
// シンプルなクロージャベース(将来的な糖衣構文として検討)
worker.on_text_complete(|text: &str| {
println!("Complete: {}", text);
});
// または Handler実装
struct TextLogger;
impl Handler<TextCompleteKind> for TextLogger {
type Scope = ();
fn on_event(&mut self, _: &mut (), text: &String) {
println!("Complete: {}", text);
}
}
worker.on_text_complete(TextLogger);
累積イベント用Kind定義
/// テキスト完了イベント用Kind
pub struct TextCompleteKind;
impl Kind for TextCompleteKind {
type Event = String; // 累積済みテキスト
}
/// ツール呼び出し完了イベント用Kind
pub struct ToolCallCompleteKind;
impl Kind for ToolCallCompleteKind {
type Event = ToolCall; // 完全なToolCall
}
内部実装
WorkerはSubscriberを内部で分解し、各Kindに対応するHandlerとしてTimelineに登録します。 累積イベント(TextComplete等)はWorker層で処理し、ブロック終了時に累積結果を渡します。
impl<C: LlmClient> Worker<C> {
pub fn subscribe<S: WorkerSubscriber + 'static>(&mut self, subscriber: S) {
let subscriber = Arc::new(Mutex::new(subscriber));
// TextBlock用ハンドラを登録
self.timeline.on_text_block(TextBlockAdapter {
subscriber: subscriber.clone(),
});
// 累積イベント用の内部ハンドラも登録
// (TextBlockCollectorのStop時にon_text_completeを呼ぶ)
}
}
設計上のポイント
- Handlerの再利用: 既存のHandler traitをそのまま活用
- スコープ管理の維持: ブロックイベントはStart→Delta→Endのライフサイクルを保持
- 選択的購読: on_*で必要なイベントだけ、またはSubscriberで一括
- 累積イベントの追加: Worker層でComplete系イベントを追加提供
- 後方互換性: 従来の
run()も引き続き使用可能