diff --git a/TODO.md b/TODO.md index 916948c7..a6846208 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,6 @@ - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) - [ ] Pod オーケストレーション - [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md) - - [ ] SpawnPod が親の provider 設定を引き継ぐ → [tickets/spawn-inherit-provider.md](tickets/spawn-inherit-provider.md) - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) - [ ] TUI 拡充 - [ ] 新しい Pod を spawn する UI の設計 → [tickets/tui-pod-spawn-ui.md](tickets/tui-pod-spawn-ui.md) diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 3b4fd3e5..4e4d83a3 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -113,6 +113,7 @@ impl PodController { let scope_for_tools = pod.scope().clone(); let pwd_for_tools = pod.pwd().to_path_buf(); let spawner_name = pod.manifest().pod.name.clone(); + let spawner_provider = pod.manifest().provider.clone(); // Parent callback socket (this Pod's own parent, used for // `PodEvent` upward reports). `None` for top-level Pods. @@ -229,6 +230,7 @@ impl PodController { pwd_for_tools, spawned_registry.clone(), self_parent_socket.clone(), + spawner_provider.clone(), )); worker.register_tool(send_to_pod_tool(spawned_registry.clone())); worker.register_tool(read_pod_output_tool(spawned_registry.clone())); diff --git a/crates/pod/src/spawn_pod.rs b/crates/pod/src/spawn_pod.rs index 731de656..95492297 100644 --- a/crates/pod/src/spawn_pod.rs +++ b/crates/pod/src/spawn_pod.rs @@ -14,7 +14,8 @@ use std::time::Duration; use async_trait::async_trait; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{ - Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule, WorkerManifestConfig, + Permission, PodManifestConfig, PodMetaConfig, ProviderConfig, ProviderConfigPartial, + ScopeConfig, ScopeRule, WorkerManifestConfig, }; use protocol::Method; use protocol::stream::JsonLineWriter; @@ -113,6 +114,11 @@ pub struct SpawnPodTool { /// `None` for top-level Pods — in that case the re-emission is a /// no-op. parent_socket: Option, + /// Spawner's resolved provider config — copied into every spawned + /// Pod's overlay TOML so the child does not need its own provider + /// configuration in the manifest cascade. Per-spawn override is + /// out of scope here (see `tickets/spawn-inherit-provider.md`). + spawner_provider: ProviderConfig, } impl SpawnPodTool { @@ -123,6 +129,7 @@ impl SpawnPodTool { spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, + spawner_provider: ProviderConfig, ) -> Self { Self { spawner_name, @@ -131,6 +138,7 @@ impl SpawnPodTool { spawner_pwd, registry, parent_socket, + spawner_provider, } } } @@ -184,7 +192,12 @@ impl Tool for SpawnPodTool { // it back — even if later steps (Method::Run delivery, record // write) fail, the child is running and will release its own // entry on exit. - let overlay_toml = match build_overlay_toml(&input.name, &instruction, &scope_allow) { + let overlay_toml = match build_overlay_toml( + &input.name, + &instruction, + &scope_allow, + &self.spawner_provider, + ) { Ok(s) => s, Err(e) => { self.release_reservation(&lock_path, &input.name); @@ -337,11 +350,18 @@ fn build_overlay_toml( name: &str, instruction: &str, scope_allow: &[ScopeRule], + provider: &ProviderConfig, ) -> Result { let overlay = PodManifestConfig { pod: PodMetaConfig { name: Some(name.to_string()), }, + provider: ProviderConfigPartial { + kind: Some(provider.kind), + model: Some(provider.model.clone()), + api_key_file: provider.api_key_file.clone(), + base_url: provider.base_url.clone(), + }, worker: WorkerManifestConfig { instruction: Some(instruction.to_string()), ..Default::default() @@ -438,6 +458,7 @@ pub fn spawn_pod_tool( spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, + spawner_provider: ProviderConfig, ) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(SpawnPodInput); @@ -452,7 +473,38 @@ pub fn spawn_pod_tool( spawner_pwd.clone(), registry.clone(), parent_socket.clone(), + spawner_provider.clone(), )); (meta, tool) }) } + +#[cfg(test)] +mod tests { + use super::*; + use manifest::ProviderKind; + + #[test] + fn overlay_inherits_spawner_provider() { + let provider = ProviderConfig { + kind: ProviderKind::Anthropic, + model: "claude-sonnet-4".into(), + api_key_file: Some(PathBuf::from("/etc/keys/anthropic")), + base_url: Some("https://example.test".into()), + }; + + let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &provider).unwrap(); + let parsed = PodManifestConfig::from_toml(&toml_str).unwrap(); + + assert_eq!(parsed.provider.kind, Some(ProviderKind::Anthropic)); + assert_eq!(parsed.provider.model.as_deref(), Some("claude-sonnet-4")); + assert_eq!( + parsed.provider.api_key_file.as_deref(), + Some(Path::new("/etc/keys/anthropic")) + ); + assert_eq!( + parsed.provider.base_url.as_deref(), + Some("https://example.test") + ); + } +} diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index a8509556..cad5d102 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; use llm_worker::tool::{ToolError, ToolOutput}; -use manifest::{Permission, ScopeRule}; +use manifest::{Permission, ProviderConfig, ProviderKind, ScopeRule}; use pod::runtime_dir::{RuntimeDir, SpawnedPodRecord}; use pod::scope_lock::{self, LockFileGuard}; use pod::spawn_pod::spawn_pod_tool; @@ -132,6 +132,18 @@ fn which_true() -> String { "/bin/true".into() } +/// Tests don't exercise the provider — they intercept the spawned +/// child via a mock socket — but `spawn_pod_tool` needs a value to +/// embed in the overlay TOML. Any well-formed `ProviderConfig` works. +fn dummy_provider() -> ProviderConfig { + ProviderConfig { + kind: ProviderKind::Anthropic, + model: "claude-test".into(), + api_key_file: None, + base_url: None, + } +} + fn clear_env() { unsafe { std::env::remove_var("INSOMNIA_SCOPE_LOCK"); @@ -159,6 +171,7 @@ async fn spawn_pod_delegates_scope_and_sends_run() { allow_root.path().to_path_buf(), registry, None, + dummy_provider(), ); let (_meta, tool) = def(); @@ -221,6 +234,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() { allow_root.path().to_path_buf(), registry, None, + dummy_provider(), ); let (_meta, tool) = def(); @@ -279,6 +293,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() { allow_root.path().to_path_buf(), registry, None, + dummy_provider(), ); let (_meta, tool) = def(); diff --git a/tickets/spawn-inherit-provider.md b/tickets/spawn-inherit-provider.md deleted file mode 100644 index 5471c833..00000000 --- a/tickets/spawn-inherit-provider.md +++ /dev/null @@ -1,28 +0,0 @@ -# SpawnPod が親の provider 設定を引き継ぐ - -## 背景 - -`SpawnPod` ツールは子 Pod に渡す overlay TOML に `pod.name` / `worker.instruction` / `scope` のみを含め、`provider` を含めない(`crates/pod/src/spawn_pod.rs::build_overlay_toml`)。 - -子 Pod は起動時に manifest cascade を一からやり直すため、デフォルトのインストラクション (`$insomnia/default`) を使うと `provider.kind` が見つからず `failed to resolve manifest cascade: missing required field: provider.kind` で起動失敗する。結果として `SpawnPod` ツールがデフォルト引数では事実上使えない状態になっている。 - -## ゴール - -`SpawnPod` で起動した子 Pod が、特に指定なしで親 Pod と同じ provider 設定(kind, model, api_key 等)を使って起動できる。 - -## 必要な変更 - -- 親 Pod の resolved provider を `SpawnPodTool` から参照できるようにする(`SpawnPodTool::new` の引数追加 等) -- `build_overlay_toml` が overlay に provider を含める -- ツール入力 (`SpawnPodInput`) からの provider 指定は今回は不要(範囲外を参照) - -## 完了条件 - -- `SpawnPod` を `provider` 指定なしで呼び出すと、子 Pod が親と同じ provider 設定で起動し、`Method::Run` を受けて応答できる -- 既存の spawn_pod 統合テスト (`crates/pod/tests/spawn_pod_test.rs`) が引き続きパス -- 親が provider を持たないケース(あり得るなら)の挙動が定まっている - -## 範囲外 - -- ツール入力からの provider override(モデルだけ差し替えたい等のマルチエージェント用途)。将来別チケットで `SpawnPodInput` に optional provider override を追加する形で扱う -- インストラクションファイル側で provider を指定する仕組み