From 8e394005b25c297ba2cdfa494e474145c0c37b37 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Apr 2026 20:01:55 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=84=E3=83=BC=E3=83=AB=E3=81=AE=E5=8B=95?= =?UTF-8?q?=E7=9A=84=E5=89=8A=E9=99=A4=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 2 +- crates/llm-worker/src/tool_server.rs | 261 +++++++++++++++++++++++++++ tickets/tool-dynamic-registry.md | 24 --- 3 files changed, 262 insertions(+), 25 deletions(-) delete mode 100644 tickets/tool-dynamic-registry.md diff --git a/TODO.md b/TODO.md index 2e56d6f3..8cc5b8ce 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ - [ ] テスト設計 → [tickets/test-design.md](tickets/test-design.md) - [x] ツール出力の遅延読み込み設計 (ToolOutput / BlobStore / auto_summarize) - [ ] ツール設計 - - [ ] ツールの動的追加/削除 → [tickets/tool-dynamic-registry.md](tickets/tool-dynamic-registry.md) + - [x] ツールの動的追加/削除 → [tickets/tool-dynamic-registry.md](tickets/tool-dynamic-registry.md) - [x] run() 自動ロックとファクトリ遅延初期化 → [tickets/worker-auto-lock.md](tickets/worker-auto-lock.md) - [x] inspect ツール実装 - [x] max_turns: マニフェストによるターン数制限 diff --git a/crates/llm-worker/src/tool_server.rs b/crates/llm-worker/src/tool_server.rs index 82d580bc..dfc42cf7 100644 --- a/crates/llm-worker/src/tool_server.rs +++ b/crates/llm-worker/src/tool_server.rs @@ -123,6 +123,33 @@ impl ToolServerHandle { .map_err(|e| ToolServerError::ToolExecution(e.to_string())) } + /// Remove a registered tool by name. + /// + /// In-flight calls that already obtained an `Arc` clone are + /// unaffected and will run to completion. + pub fn unregister(&self, name: &str) -> Result<(), ToolServerError> { + let mut guard = self.tools.lock().unwrap_or_else(|e| e.into_inner()); + guard + .remove(name) + .map(|_| ()) + .ok_or_else(|| ToolServerError::ToolNotFound(name.to_string())) + } + + /// Replace an existing tool with a new implementation. + /// + /// The factory is called immediately and the resulting tool overwrites + /// the entry with the same name. Returns `ToolNotFound` if the name + /// produced by the factory does not match any registered tool. + pub fn replace(&self, factory: WorkerToolDefinition) -> Result<(), ToolServerError> { + let (meta, instance) = factory(); + let mut guard = self.tools.lock().unwrap_or_else(|e| e.into_inner()); + if !guard.contains_key(&meta.name) { + return Err(ToolServerError::ToolNotFound(meta.name)); + } + guard.insert(meta.name.clone(), (meta, instance)); + Ok(()) + } + /// Build deterministic tool definitions sorted by tool name. pub fn tool_definitions_sorted(&self) -> Vec { let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner()); @@ -234,4 +261,238 @@ mod tests { handle.flush_pending(); handle.flush_pending(); } + + #[test] + fn unregister_removes_tool() { + let handle = ToolServer::new().handle(); + handle.register_tool(def("alpha")); + handle.flush_pending(); + + handle.unregister("alpha").expect("unregister"); + assert!(handle.get_tool("alpha").is_none()); + } + + #[test] + fn unregister_not_found() { + let handle = ToolServer::new().handle(); + let err = handle.unregister("ghost").expect_err("should fail"); + assert_eq!(err, ToolServerError::ToolNotFound("ghost".to_string())); + } + + #[test] + fn replace_swaps_implementation() { + let handle = ToolServer::new().handle(); + handle.register_tool(def("alpha")); + handle.flush_pending(); + + // Replace with a tool that returns a fixed string. + struct FixedTool; + + #[async_trait] + impl Tool for FixedTool { + async fn execute(&self, _input_json: &str) -> Result { + Ok("replaced".to_string()) + } + } + + let replacement: ToolDefinition = Arc::new(|| { + ( + ToolMeta::new("alpha") + .description("replaced-desc") + .input_schema(json!({"type":"object"})), + Arc::new(FixedTool) as Arc, + ) + }); + handle.replace(replacement).expect("replace"); + + let (meta, _) = handle.get_tool("alpha").expect("exists"); + assert_eq!(meta.description, "replaced-desc"); + } + + #[tokio::test] + async fn replace_updates_call_result() { + let handle = ToolServer::new().handle(); + handle.register_tool(def("echo")); + handle.flush_pending(); + + struct ConstTool; + + #[async_trait] + impl Tool for ConstTool { + async fn execute(&self, _input_json: &str) -> Result { + Ok("const".to_string()) + } + } + + let replacement: ToolDefinition = Arc::new(|| { + ( + ToolMeta::new("echo") + .description("const") + .input_schema(json!({"type":"object"})), + Arc::new(ConstTool) as Arc, + ) + }); + handle.replace(replacement).expect("replace"); + + let out = handle.call_tool("echo", "{}").await.expect("call"); + assert_eq!(out, "const"); + } + + #[tokio::test] + async fn unregister_during_execution_does_not_affect_inflight() { + use tokio::sync::Notify; + + let started = Arc::new(Notify::new()); + let finish = Arc::new(Notify::new()); + + struct GatedTool { + started: Arc, + finish: Arc, + } + + #[async_trait] + impl Tool for GatedTool { + async fn execute(&self, _input_json: &str) -> Result { + self.started.notify_one(); + self.finish.notified().await; + Ok("done".to_string()) + } + } + + let handle = ToolServer::new().handle(); + let s = Arc::clone(&started); + let f = Arc::clone(&finish); + handle.register_tool(Arc::new(move || { + ( + ToolMeta::new("slow") + .description("slow") + .input_schema(json!({"type":"object"})), + Arc::new(GatedTool { + started: Arc::clone(&s), + finish: Arc::clone(&f), + }) as Arc, + ) + })); + handle.flush_pending(); + + let h = handle.clone(); + let call = tokio::spawn(async move { h.call_tool("slow", "{}").await }); + + // Wait until the tool is actually executing. + started.notified().await; + + // Unregister while the tool is mid-execution. + handle.unregister("slow").expect("unregister"); + assert!(handle.get_tool("slow").is_none()); + + // Let the in-flight call finish. + finish.notify_one(); + let result = call.await.expect("join"); + assert_eq!(result.expect("call"), "done"); + } + + #[tokio::test] + async fn replace_during_execution_inflight_uses_old_impl() { + use tokio::sync::Notify; + + let started = Arc::new(Notify::new()); + let finish = Arc::new(Notify::new()); + + struct OldTool { + started: Arc, + finish: Arc, + } + + #[async_trait] + impl Tool for OldTool { + async fn execute(&self, _input_json: &str) -> Result { + self.started.notify_one(); + self.finish.notified().await; + Ok("old".to_string()) + } + } + + let handle = ToolServer::new().handle(); + let s = Arc::clone(&started); + let f = Arc::clone(&finish); + handle.register_tool(Arc::new(move || { + ( + ToolMeta::new("t") + .description("d") + .input_schema(json!({"type":"object"})), + Arc::new(OldTool { + started: Arc::clone(&s), + finish: Arc::clone(&f), + }) as Arc, + ) + })); + handle.flush_pending(); + + let h = handle.clone(); + let call = tokio::spawn(async move { h.call_tool("t", "{}").await }); + + // Wait until the old tool is mid-execution. + started.notified().await; + + // Replace while the old tool is executing. + struct NewTool; + + #[async_trait] + impl Tool for NewTool { + async fn execute(&self, _input_json: &str) -> Result { + Ok("new".to_string()) + } + } + + handle + .replace(Arc::new(|| { + ( + ToolMeta::new("t") + .description("d") + .input_schema(json!({"type":"object"})), + Arc::new(NewTool) as Arc, + ) + })) + .expect("replace"); + + // Let the old in-flight call finish — it should return "old". + finish.notify_one(); + let result = call.await.expect("join"); + assert_eq!(result.expect("call"), "old"); + + // New calls use the replacement. + let out = handle.call_tool("t", "{}").await.expect("call"); + assert_eq!(out, "new"); + } + + #[test] + fn unregister_reflects_in_tool_definitions() { + let handle = ToolServer::new().handle(); + handle.register_tool(def("alpha")); + handle.register_tool(def("beta")); + handle.flush_pending(); + + handle.unregister("alpha").expect("unregister"); + let names: Vec<_> = handle + .tool_definitions_sorted() + .into_iter() + .map(|d| d.name) + .collect(); + assert_eq!(names, vec!["beta"]); + } + + #[test] + fn replace_not_found() { + let handle = ToolServer::new().handle(); + let factory: ToolDefinition = Arc::new(|| { + ( + ToolMeta::new("ghost") + .description("x") + .input_schema(json!({"type":"object"})), + Arc::new(EchoTool) as Arc, + ) + }); + let err = handle.replace(factory).expect_err("should fail"); + assert_eq!(err, ToolServerError::ToolNotFound("ghost".to_string())); + } } diff --git a/tickets/tool-dynamic-registry.md b/tickets/tool-dynamic-registry.md deleted file mode 100644 index bf8e1835..00000000 --- a/tickets/tool-dynamic-registry.md +++ /dev/null @@ -1,24 +0,0 @@ -# ツールの動的追加/削除 - -## 背景 - -現状の `ToolServer` はツールの登録のみで、実行中の unregister / replace ができない。 -エージェントが状況に応じてツールセットを切り替えるユースケース(例: フェーズ遷移、権限変更)に対応できない。 - -## 方針 - -`ToolServer` / `ToolServerHandle` に動的操作を追加する。 - -```rust -// 削除 -tool_server.unregister("tool_name")?; - -// 置換(同名ツールを新しい実装で上書き) -tool_server.replace(new_tool_definition)?; -``` - -## 設計ポイント - -- 実行中のツール呼び出しとの競合を考慮(呼び出し中のツールは削除をブロックするか、完了を待つか) -- LLM に渡すツール定義リストは次の `PreLlmRequest` 時点で反映される(遅延反映で十分) -- Builder API(worker-builder-api.md)との整合: `reconfigure()` で静的に差し替える方法と、動的に差し替える方法の使い分け