From e65c023d4fc213b5af514200bc222d3b04c2f179 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 30 May 2026 14:06:28 +0900 Subject: [PATCH 1/3] feat: add SpawnPod profile selection --- crates/manifest/src/profile.rs | 19 ++ crates/pod/src/controller.rs | 8 +- crates/pod/src/prompt/catalog.rs | 37 +++ crates/pod/src/spawn/tool.rs | 394 +++++++++++++++++++++++++---- crates/pod/tests/spawn_pod_test.rs | 64 ++++- resources/prompts/internal.toml | 13 + 6 files changed, 475 insertions(+), 60 deletions(-) diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index ec82bd8c..05dc37ec 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -352,6 +352,24 @@ impl ProfileResolver { source, })?; let registry = ProfileDiscovery::for_cwd(&cwd).discover()?; + self.resolve_from_registry(selector, ®istry, options) + } + } + } + /// Resolve a registry/default selector against an already-discovered + /// registry. Callers such as SpawnPod use this to bind discovery to the + /// Pod's cwd instead of the process current directory. + pub fn resolve_from_registry( + &self, + selector: &ProfileSelector, + registry: &ProfileRegistry, + options: ProfileResolveOptions, + ) -> Result { + match selector { + ProfileSelector::Path { .. } => Err(ProfileError::InvalidProfile( + "path selectors are not registry entries".into(), + )), + ProfileSelector::Named { .. } | ProfileSelector::Default => { let entry = registry.select(selector)?.clone(); self.resolve_path( &entry.path, @@ -365,6 +383,7 @@ impl ProfileResolver { } } } + fn resolve_path( &self, path: &Path, diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 8efb66f6..3224aca1 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -501,8 +501,8 @@ where let memory_config = pod.manifest().memory.clone(); let web_config = pod.manifest().web.clone(); let spawner_name = pod.manifest().pod.name.clone(); - let spawner_model = pod.manifest().model.clone(); - let spawner_record_event_trace = pod.manifest().session.record_event_trace; + let spawner_manifest = pod.manifest().clone(); + let prompts = pod.prompts().clone(); let pod_store = pod.store().clone(); let self_parent_socket = pod.callback_socket().cloned(); @@ -556,9 +556,9 @@ where pwd.clone(), spawned_registry.clone(), self_parent_socket, - spawner_model, - spawner_record_event_trace, + spawner_manifest, scope_handle, + prompts, )); 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/prompt/catalog.rs b/crates/pod/src/prompt/catalog.rs index e3c25410..918c3dba 100644 --- a/crates/pod/src/prompt/catalog.rs +++ b/crates/pod/src/prompt/catalog.rs @@ -92,6 +92,9 @@ pub enum PodPrompt { /// knowledge when Workflow resident injection is enabled and at least one /// workflow advertises `model_invokation: true`. ResidentWorkflowsSection, + /// LLM-facing description for the SpawnPod tool, including discovered + /// profile selectors. + SpawnPodToolDescription, } impl PodPrompt { @@ -108,6 +111,7 @@ impl PodPrompt { Self::ResidentMemorySummarySection => "resident_memory_summary_section", Self::ResidentKnowledgeSection => "resident_knowledge_section", Self::ResidentWorkflowsSection => "resident_workflows_section", + Self::SpawnPodToolDescription => "spawn_pod_tool_description", } } @@ -126,6 +130,7 @@ impl PodPrompt { PodPrompt::ResidentMemorySummarySection, PodPrompt::ResidentKnowledgeSection, PodPrompt::ResidentWorkflowsSection, + PodPrompt::SpawnPodToolDescription, ]; pub const KEYS: &'static [&'static str] = &[ @@ -140,6 +145,7 @@ impl PodPrompt { "resident_memory_summary_section", "resident_knowledge_section", "resident_workflows_section", + "spawn_pod_tool_description", ]; } @@ -385,6 +391,21 @@ impl PromptCatalog { single("entries", entries), ) } + + /// Render `PodPrompt::SpawnPodToolDescription`. + pub fn spawn_pod_tool_description( + &self, + available_profiles: &str, + default_profile: &str, + profile_diagnostic: &str, + ) -> Result { + use std::collections::BTreeMap; + let mut m: BTreeMap<&'static str, Value> = BTreeMap::new(); + m.insert("available_profiles", Value::from(available_profiles)); + m.insert("default_profile", Value::from(default_profile)); + m.insert("profile_diagnostic", Value::from(profile_diagnostic)); + self.render(PodPrompt::SpawnPodToolDescription, Value::from(m)) + } } fn single(key: &'static str, value: &str) -> Value { @@ -682,4 +703,20 @@ compact_system = "PREFIX\n{% include \"$insomnia/internal/compact_system\" %}" assert!(rendered.starts_with("PREFIX\n")); assert!(rendered.contains("write_summary")); } + + #[test] + fn spawn_pod_tool_description_renders_profile_block() { + let cat = PromptCatalog::builtins_only().unwrap(); + let rendered = cat + .spawn_pod_tool_description( + "- `project:coder` — Coder\n- `project:reviewer` — Reviewer", + "project:coder", + "", + ) + .unwrap(); + assert!(rendered.contains("Profile selection")); + assert!(rendered.contains("Default profile: project:coder")); + assert!(rendered.contains("`project:reviewer`")); + assert!(rendered.contains("Special selector: inherit")); + } } diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 8c50bc63..feca8b16 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -14,8 +14,10 @@ use std::time::Duration; use async_trait::async_trait; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{ - ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule, - SessionConfigPartial, SharedScope, WorkerManifestConfig, + CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial, + PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery, ProfileError, ProfileRegistry, + ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, + ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig, }; use serde::Deserialize; use tokio::net::UnixStream; @@ -23,20 +25,13 @@ use tokio::process::Command; use tokio::time::sleep; use crate::ipc::event; +use crate::prompt::catalog::PromptCatalog; use crate::runtime::dir::SpawnedPodRecord; use crate::runtime::pod_registry::{self, LockFileGuard, ScopeLockError}; use crate::spawn::comm_tools::{SendRunError, send_run_and_confirm}; use crate::spawn::registry::SpawnedPodRegistry; use protocol::PodEvent; -const DESCRIPTION: &str = "Spawn a new Pod process to work on a delegated task. \ -The spawner's write scope is reduced by the scope passed here; the spawned \ -Pod receives its own socket and starts running `task` immediately. The \ -spawned Pod outlives the spawner's current turn and can be contacted again \ -through its socket path."; - -const DEFAULT_INSTRUCTION: &str = "$insomnia/default"; - /// How long we will wait for the spawned Pod's socket to become /// connectable before treating the spawn as failed. const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10); @@ -45,6 +40,13 @@ const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10); struct SpawnPodInput { /// Identifier for the spawned Pod. Must be unique machine-wide. name: String, + /// Profile selector for child role configuration. Omit or use `default` + /// for the effective child default profile, use `inherit` to derive + /// reusable config from the spawner, or use a registry selector such as + /// `project:coder`, `project:reviewer`, `builtin:default`, or an + /// unambiguous profile slug. Raw/path selectors are rejected. + #[serde(default)] + profile: Option, /// Instruction-file reference (e.g. `$insomnia/default`, `$user/my-agent`). #[serde(default)] instruction: Option, @@ -87,6 +89,120 @@ impl From for Permission { } } +#[derive(Debug, Clone)] +struct AvailableProfiles { + registry: Option, + diagnostic: Option, +} + +impl AvailableProfiles { + fn discover(cwd: &Path) -> Self { + match ProfileDiscovery::for_cwd(cwd).discover() { + Ok(registry) => Self { + registry: Some(registry), + diagnostic: None, + }, + Err(error) => Self { + registry: None, + diagnostic: Some(error.to_string()), + }, + } + } + + fn compact_list(&self) -> String { + let Some(registry) = &self.registry else { + return "- profile discovery failed; use `inherit` or retry after fixing discovery" + .into(); + }; + if registry.entries().is_empty() { + return "- no registry profiles discovered; `inherit` is still available".into(); + } + registry + .entries() + .iter() + .map(|entry| { + let default = if entry.is_default { " (default)" } else { "" }; + let desc = entry + .description + .as_deref() + .map(|d| format!(" — {d}")) + .unwrap_or_default(); + format!("- `{}`{}{}", entry.qualified_name(), default, desc) + }) + .collect::>() + .join("\n") + } + + fn default_label(&self) -> String { + self.registry + .as_ref() + .and_then(|registry| registry.default_entry().ok()) + .map(|entry| entry.qualified_name()) + .unwrap_or_else(|| "none resolved".into()) + } + + fn diagnostic(&self) -> &str { + self.diagnostic.as_deref().unwrap_or("") + } + + fn error_suffix(&self) -> String { + format!( + "\nUse `default`, `inherit`, or one of these registry selectors:\n{}", + self.compact_list() + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SpawnProfileSelector { + Default, + Inherit, + Registry(ProfileSelector), +} + +fn parse_spawn_profile_selector(raw: Option<&str>) -> Result { + let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(SpawnProfileSelector::Default); + }; + if raw == "default" { + return Ok(SpawnProfileSelector::Default); + } + if raw == "inherit" { + return Ok(SpawnProfileSelector::Inherit); + } + if raw.starts_with("path:") + || raw.starts_with('/') + || raw.starts_with("./") + || raw.starts_with("../") + || raw.contains('/') + || raw.ends_with(".lua") + || raw.ends_with(".nix") + { + return Err(format!( + "SpawnPod.profile accepts `default`, `inherit`, or registry selectors only; path-like selector `{raw}` is not allowed" + )); + } + if let Some((prefix, name)) = raw.split_once(':') { + let source = match prefix { + "builtin" => ProfileRegistrySource::Builtin, + "user" => ProfileRegistrySource::User, + "project" => ProfileRegistrySource::Project, + _ => { + return Err(format!( + "unsupported SpawnPod.profile selector prefix `{prefix}`; use builtin:, user:, project:, default, or inherit" + )); + } + }; + if name.is_empty() { + return Err("SpawnPod.profile registry selector has an empty profile name".into()); + } + return Ok(SpawnProfileSelector::Registry( + ProfileSelector::source_named(source, name), + )); + } + Ok(SpawnProfileSelector::Registry(ProfileSelector::named(raw))) +} + /// Runtime dependencies the `SpawnPod` tool needs in order to launch a /// child Pod and record the handoff locally. Constructed by the Pod /// controller once per Pod lifetime. @@ -114,14 +230,12 @@ 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 internal manifest config so the child does not need its own provider - /// configuration. Per-spawn override is - /// out of scope here (see `tickets/spawn-inherit-provider.md`). - spawner_model: ModelManifest, - /// Spawner's session diagnostics policy. Preserved for spawned Pods so - /// opt-in provider event traces continue across delegation. - spawner_record_event_trace: bool, + /// Spawner's resolved Manifest. `profile = "inherit"` derives the + /// child config from reusable fields here, and selected profiles are + /// merged into the same internal handoff shape before launch. + spawner_manifest: PodManifest, + /// Compact selector list shared by tool description and diagnostics. + available_profiles: AvailableProfiles, /// Spawner's runtime scope. After a successful spawn, the /// `Permission::Write` rules in the delegated scope are revoked /// from the spawner's in-memory view (a `deny(Write, target)` is @@ -133,15 +247,15 @@ pub struct SpawnPodTool { } impl SpawnPodTool { - pub fn new( + fn new( spawner_name: String, callback_socket: PathBuf, runtime_base: PathBuf, spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, - spawner_model: ModelManifest, - spawner_record_event_trace: bool, + spawner_manifest: PodManifest, + available_profiles: AvailableProfiles, spawner_scope: SharedScope, ) -> Self { Self { @@ -151,8 +265,8 @@ impl SpawnPodTool { spawner_pwd, registry, parent_socket, - spawner_model, - spawner_record_event_trace, + spawner_manifest, + available_profiles, spawner_scope, } } @@ -176,10 +290,21 @@ impl Tool for SpawnPodTool { let scope_allow = parse_scope(&input.scope)?; - let instruction = input - .instruction - .clone() - .unwrap_or_else(|| DEFAULT_INSTRUCTION.to_string()); + let spawn_selector = + parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| { + ToolError::InvalidArgument(format!( + "{msg}{}", + self.available_profiles.error_suffix() + )) + })?; + let spawn_config_json = self + .build_spawn_config_json( + &input.name, + input.instruction.as_deref(), + &scope_allow, + spawn_selector, + ) + .map_err(|e| ToolError::InvalidArgument(format!("{e}")))?; let predicted_socket = self.runtime_base.join(&input.name).join("sock"); let lock_path = pod_registry::default_registry_path() @@ -207,21 +332,6 @@ 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 spawn_config_json = match build_spawn_config_json( - &input.name, - &instruction, - &scope_allow, - &self.spawner_model, - self.spawner_record_event_trace, - ) { - Ok(s) => s, - Err(e) => { - self.release_reservation(&lock_path, &input.name); - return Err(ToolError::ExecutionFailed(format!( - "spawn config serialisation: {e}" - ))); - } - }; let start_outcome = self .exec_child(&input.name, &spawn_config_json, &predicted_socket) @@ -385,11 +495,60 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result, ToolError> { /// The child's working directory is set separately via /// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is /// not part of the manifest. +impl SpawnPodTool { + fn build_spawn_config_json( + &self, + name: &str, + instruction_override: Option<&str>, + scope_allow: &[ScopeRule], + selector: SpawnProfileSelector, + ) -> Result { + let mut config = match selector { + SpawnProfileSelector::Inherit => manifest_to_reusable_config(&self.spawner_manifest), + SpawnProfileSelector::Default | SpawnProfileSelector::Registry(_) => { + let registry = self.available_profiles.registry.as_ref().ok_or_else(|| { + format!( + "profile discovery failed for SpawnPod: {}{}", + self.available_profiles + .diagnostic() + .if_empty("unknown error"), + self.available_profiles.error_suffix() + ) + })?; + let profile_selector = match selector { + SpawnProfileSelector::Default => ProfileSelector::Default, + SpawnProfileSelector::Registry(selector) => selector, + SpawnProfileSelector::Inherit => unreachable!(), + }; + let resolved = ProfileResolver::new() + .with_workspace_base(&self.spawner_pwd) + .resolve_from_registry( + &profile_selector, + registry, + ProfileResolveOptions::with_pod_name(name), + ) + .map_err(|e| profile_error_with_available(e, &self.available_profiles))?; + manifest_to_reusable_config(&resolved.manifest) + } + }; + config.pod.name = Some(name.to_string()); + config.scope = ScopeConfig { + allow: scope_allow.to_vec(), + deny: Vec::new(), + }; + if let Some(instruction) = instruction_override { + config.worker.instruction = Some(instruction.to_string()); + } + serde_json::to_string(&config).map_err(|e| format!("spawn config serialisation: {e}")) + } +} + +#[cfg(test)] fn build_spawn_config_json( name: &str, instruction: &str, scope_allow: &[ScopeRule], - model: &ModelManifest, + model: &manifest::ModelManifest, record_event_trace: bool, ) -> Result { let config = PodManifestConfig { @@ -414,6 +573,94 @@ fn build_spawn_config_json( serde_json::to_string(&config) } +trait IfEmpty { + fn if_empty(&self, fallback: &str) -> String; +} +impl IfEmpty for str { + fn if_empty(&self, fallback: &str) -> String { + if self.is_empty() { + fallback.into() + } else { + self.into() + } + } +} + +fn profile_error_with_available(error: ProfileError, available: &AvailableProfiles) -> String { + format!( + "invalid SpawnPod.profile: {error}{}", + available.error_suffix() + ) +} + +fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig { + PodManifestConfig { + pod: PodMetaConfig { + name: Some(manifest.pod.name.clone()), + prompt_pack: manifest.pod.prompt_pack.clone(), + }, + model: manifest.model.clone(), + worker: WorkerManifestConfig { + instruction: Some(manifest.worker.instruction.clone()), + language: Some(manifest.worker.language.clone()), + max_tokens: manifest.worker.max_tokens, + max_turns: manifest.worker.max_turns, + temperature: manifest.worker.temperature, + top_p: manifest.worker.top_p, + top_k: manifest.worker.top_k, + stop_sequences: (!manifest.worker.stop_sequences.is_empty()) + .then_some(manifest.worker.stop_sequences.clone()), + reasoning: manifest.worker.reasoning.clone(), + tool_output: ToolOutputLimitsPartial { + default_max_bytes: Some(manifest.worker.tool_output.default_max_bytes), + per_tool: manifest.worker.tool_output.per_tool.clone(), + }, + file_upload: FileUploadLimitsPartial { + max_bytes: Some(manifest.worker.file_upload.max_bytes), + }, + }, + scope: ScopeConfig { + allow: manifest.scope.allow.clone(), + deny: manifest.scope.deny.clone(), + }, + session: Some(SessionConfigPartial { + record_event_trace: Some(manifest.session.record_event_trace), + }), + permissions: manifest + .permissions + .as_ref() + .map(|p| PermissionConfigPartial { + default_action: Some(p.default_action), + rules: p.rules.clone(), + }), + compaction: manifest + .compaction + .as_ref() + .map(|c| CompactionConfigPartial { + prune_protected_tokens: Some(c.prune_protected_tokens), + prune_min_savings: Some(c.prune_min_savings), + threshold: c.threshold, + request_threshold: c.request_threshold, + retained_tokens: Some(c.retained_tokens), + overview_target_tokens: Some(c.overview_target_tokens), + overview_warning_tokens: Some(c.overview_warning_tokens), + overview_deadline_tokens: Some(c.overview_deadline_tokens), + worker_context_max_tokens: Some(c.worker_context_max_tokens), + finish_warning_remaining_tokens: Some(c.finish_warning_remaining_tokens), + final_reserve_tokens: Some(c.final_reserve_tokens), + worker_max_turns: c.worker_max_turns, + summary_target_tokens: Some(c.summary_target_tokens), + summary_max_tokens: Some(c.summary_max_tokens), + auto_read_budget_tokens: Some(c.auto_read_budget_tokens), + result_context_max_tokens: Some(c.result_context_max_tokens), + model: c.model.clone(), + }), + web: manifest.web.clone(), + memory: manifest.memory.clone(), + skills: manifest.skills.clone(), + } +} + /// Tail of the spawned child's `stderr.log` to splice into a startup /// failure message. Capped so a chatty child can't blow up the LLM's /// tool-result budget — debugging beyond this should read the file @@ -493,15 +740,28 @@ pub fn spawn_pod_tool( spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, - spawner_model: ModelManifest, - spawner_record_event_trace: bool, + spawner_manifest: PodManifest, spawner_scope: SharedScope, + prompts: Arc, ) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(SpawnPodInput); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); + let available_profiles = AvailableProfiles::discover(&spawner_pwd); + let description = prompts + .spawn_pod_tool_description( + &available_profiles.compact_list(), + &available_profiles.default_label(), + available_profiles.diagnostic(), + ) + .unwrap_or_else(|e| { + format!( + "Spawn a new Pod process to work on a delegated task. Profile description rendering failed: {e}. Available profiles:\n{}", + available_profiles.compact_list() + ) + }); let meta = ToolMeta::new("SpawnPod") - .description(DESCRIPTION) + .description(description) .input_schema(schema_value); let tool: Arc = Arc::new(SpawnPodTool::new( spawner_name.clone(), @@ -510,8 +770,8 @@ pub fn spawn_pod_tool( spawner_pwd.clone(), registry.clone(), parent_socket.clone(), - spawner_model.clone(), - spawner_record_event_trace, + spawner_manifest.clone(), + available_profiles, spawner_scope.clone(), )); (meta, tool) @@ -521,7 +781,7 @@ pub fn spawn_pod_tool( #[cfg(test)] mod tests { use super::*; - use manifest::{AuthRef, PodManifest, SchemeKind}; + use manifest::{AuthRef, ModelManifest, PodManifest, SchemeKind}; #[test] fn spawn_config_inherits_inline_spawner_model() { @@ -607,4 +867,40 @@ mod tests { assert!(parsed.session.is_none()); } + + #[test] + fn spawn_profile_selector_rejects_path_like_values() { + for raw in [ + "./reviewer.lua", + "path:./reviewer.lua", + "/tmp/reviewer.lua", + "legacy.nix", + ] { + let err = parse_spawn_profile_selector(Some(raw)).unwrap_err(); + assert!(err.contains("registry selectors only"), "{raw}: {err}"); + } + } + + #[test] + fn spawn_profile_selector_accepts_default_inherit_and_registry() { + assert_eq!( + parse_spawn_profile_selector(None).unwrap(), + SpawnProfileSelector::Default + ); + assert_eq!( + parse_spawn_profile_selector(Some("inherit")).unwrap(), + SpawnProfileSelector::Inherit + ); + assert_eq!( + parse_spawn_profile_selector(Some("project:reviewer")).unwrap(), + SpawnProfileSelector::Registry(ProfileSelector::source_named( + ProfileRegistrySource::Project, + "reviewer" + )) + ); + assert_eq!( + parse_spawn_profile_selector(Some("coder")).unwrap(), + SpawnProfileSelector::Registry(ProfileSelector::named("coder")) + ); + } } diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index d4c4cd7f..87e68c98 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -11,7 +11,10 @@ use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; use llm_worker::tool::{ToolError, ToolOutput}; -use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, Scope, ScopeRule, SharedScope}; +use manifest::{ + AuthRef, ModelManifest, Permission, PodManifest, PodManifestConfig, PodMetaConfig, SchemeKind, + Scope, ScopeConfig, ScopeRule, SharedScope, +}; use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord}; use pod::runtime::pod_registry::{self, LockFileGuard}; use pod::spawn::registry::SpawnedPodRegistry; @@ -107,6 +110,25 @@ fn accept_one_method(listener: UnixListener) -> tokio::task::JoinHandle().await { w.write(&Event::UserMessage { segments: vec![protocol::Segment::text("accepted")], @@ -155,6 +177,31 @@ fn dummy_model() -> ModelManifest { } } +fn dummy_manifest(allow_root: &Path) -> PodManifest { + PodManifestConfig { + pod: PodMetaConfig { + name: Some("root".into()), + prompt_pack: None, + }, + model: dummy_model(), + scope: ScopeConfig { + allow: vec![ScopeRule { + target: allow_root.to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: Vec::new(), + }, + ..Default::default() + } + .try_into() + .unwrap() +} + +fn builtin_prompts() -> Arc { + pod::PromptCatalog::builtins_only().unwrap() +} + /// Spawner-side `SharedScope` mirroring the `allow_root` granted by /// `setup_spawner`. The tool revokes Write rules from this scope on /// successful spawn — tests can `load()` it to assert the @@ -191,15 +238,16 @@ async fn spawn_pod_delegates_scope_and_sends_run() { allow_root.path().to_path_buf(), registry, None, - dummy_model(), - false, + dummy_manifest(allow_root.path()), spawner_scope.clone(), + builtin_prompts(), ); let (_meta, tool) = def(); let input = json!({ "name": "child", "task": "hello", + "profile": "inherit", "scope": [{ "target": allow_root.path().to_str().unwrap(), "permission": "write" @@ -280,9 +328,9 @@ async fn spawn_pod_rejects_scope_outside_spawner() { allow_root.path().to_path_buf(), registry, None, - dummy_model(), - false, + dummy_manifest(allow_root.path()), spawner_scope.clone(), + builtin_prompts(), ); let (_meta, tool) = def(); @@ -290,6 +338,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() { let input = json!({ "name": "child", "task": "nope", + "profile": "inherit", "scope": [{ "target": outside.path().to_str().unwrap(), "permission": "write" @@ -352,15 +401,16 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() { allow_root.path().to_path_buf(), registry, None, - dummy_model(), - false, + dummy_manifest(allow_root.path()), spawner_scope.clone(), + builtin_prompts(), ); let (_meta, tool) = def(); let input = json!({ "name": "ghost", "task": "will never be delivered", + "profile": "inherit", "scope": [{ "target": allow_root.path().to_str().unwrap(), "permission": "write" diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml index 325bd305..482e7085 100644 --- a/resources/prompts/internal.toml +++ b/resources/prompts/internal.toml @@ -65,3 +65,16 @@ The following workflows are advertised resident. When a user request matches one {{ entries }}\ """ + +spawn_pod_tool_description = """\ +Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path. + +Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Pod, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SpawnPod. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SpawnPod scope. + +Default profile: {{ default_profile }} +Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing pod.name and scope. +Available registry profiles: +{{ available_profiles }}{% if profile_diagnostic %} + +Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}\ +""" From 6e73c0f700546b58ffb79990bf59f50a41d66dfb Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 30 May 2026 14:07:08 +0900 Subject: [PATCH 2/3] docs: document SpawnPod profile selectors --- docs/pod-factory.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pod-factory.md b/docs/pod-factory.md index d6d966c5..15462900 100644 --- a/docs/pod-factory.md +++ b/docs/pod-factory.md @@ -179,6 +179,8 @@ insomnia-pod --session Spawn children use hidden `--spawn-config-json`, `--adopt`, and `--callback ` flags. These are internal handoff details used by `SpawnPod` after the parent has allocated scope and prepared the child config. +`SpawnPod.profile` accepts registry/default selectors and the special `inherit` selector. Typical orchestration calls are `SpawnPod(profile = "project:coder")` for implementation work, `SpawnPod(profile = "project:reviewer")` for independent review, optionally `SpawnPod(profile = "project:orchestrator")` for lower-level coordination, or `SpawnPod(profile = "inherit")` when the child should reuse the parent role configuration. The explicit `SpawnPod.scope` remains the only delegated filesystem capability; selected or inherited profile scope is replaced by the tool argument. + ## Programmatic boundary New code should resolve profiles through the profile resolver and then construct Pods from the resulting `PodManifest` and `PromptLoader`. One-file Manifest helpers remain for tests/debugging. Avoid reintroducing user/project manifest cascade APIs as normal startup behavior. From b94891ed1bf56be24b5e6fa9c9d7a7da6ad48a1c Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 30 May 2026 14:16:33 +0900 Subject: [PATCH 3/3] test: cover SpawnPod profile config building --- crates/pod/src/spawn/tool.rs | 480 ++++++++++++++++++++++++++++++++--- 1 file changed, 443 insertions(+), 37 deletions(-) diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index feca8b16..925bce85 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -503,46 +503,64 @@ impl SpawnPodTool { scope_allow: &[ScopeRule], selector: SpawnProfileSelector, ) -> Result { - let mut config = match selector { - SpawnProfileSelector::Inherit => manifest_to_reusable_config(&self.spawner_manifest), - SpawnProfileSelector::Default | SpawnProfileSelector::Registry(_) => { - let registry = self.available_profiles.registry.as_ref().ok_or_else(|| { - format!( - "profile discovery failed for SpawnPod: {}{}", - self.available_profiles - .diagnostic() - .if_empty("unknown error"), - self.available_profiles.error_suffix() - ) - })?; - let profile_selector = match selector { - SpawnProfileSelector::Default => ProfileSelector::Default, - SpawnProfileSelector::Registry(selector) => selector, - SpawnProfileSelector::Inherit => unreachable!(), - }; - let resolved = ProfileResolver::new() - .with_workspace_base(&self.spawner_pwd) - .resolve_from_registry( - &profile_selector, - registry, - ProfileResolveOptions::with_pod_name(name), - ) - .map_err(|e| profile_error_with_available(e, &self.available_profiles))?; - manifest_to_reusable_config(&resolved.manifest) - } - }; - config.pod.name = Some(name.to_string()); - config.scope = ScopeConfig { - allow: scope_allow.to_vec(), - deny: Vec::new(), - }; - if let Some(instruction) = instruction_override { - config.worker.instruction = Some(instruction.to_string()); - } - serde_json::to_string(&config).map_err(|e| format!("spawn config serialisation: {e}")) + build_spawn_config_json_for_profile( + &self.spawner_manifest, + &self.available_profiles, + &self.spawner_pwd, + name, + instruction_override, + scope_allow, + selector, + ) } } +fn build_spawn_config_json_for_profile( + spawner_manifest: &PodManifest, + available_profiles: &AvailableProfiles, + spawner_pwd: &Path, + name: &str, + instruction_override: Option<&str>, + scope_allow: &[ScopeRule], + selector: SpawnProfileSelector, +) -> Result { + let mut config = match selector { + SpawnProfileSelector::Inherit => manifest_to_reusable_config(spawner_manifest), + SpawnProfileSelector::Default | SpawnProfileSelector::Registry(_) => { + let registry = available_profiles.registry.as_ref().ok_or_else(|| { + format!( + "profile discovery failed for SpawnPod: {}{}", + available_profiles.diagnostic().if_empty("unknown error"), + available_profiles.error_suffix() + ) + })?; + let profile_selector = match selector { + SpawnProfileSelector::Default => ProfileSelector::Default, + SpawnProfileSelector::Registry(selector) => selector, + SpawnProfileSelector::Inherit => unreachable!(), + }; + let resolved = ProfileResolver::new() + .with_workspace_base(spawner_pwd) + .resolve_from_registry( + &profile_selector, + registry, + ProfileResolveOptions::with_pod_name(name), + ) + .map_err(|e| profile_error_with_available(e, available_profiles))?; + manifest_to_reusable_config(&resolved.manifest) + } + }; + config.pod.name = Some(name.to_string()); + config.scope = ScopeConfig { + allow: scope_allow.to_vec(), + deny: Vec::new(), + }; + if let Some(instruction) = instruction_override { + config.worker.instruction = Some(instruction.to_string()); + } + serde_json::to_string(&config).map_err(|e| format!("spawn config serialisation: {e}")) +} + #[cfg(test)] fn build_spawn_config_json( name: &str, @@ -782,6 +800,122 @@ pub fn spawn_pod_tool( mod tests { use super::*; use manifest::{AuthRef, ModelManifest, PodManifest, SchemeKind}; + use tempfile::TempDir; + + fn abs_rule(path: &Path, permission: Permission) -> ScopeRule { + ScopeRule { + target: path.to_path_buf(), + permission, + recursive: true, + } + } + + fn parent_manifest(root: &Path, deny: Option<&Path>) -> PodManifest { + PodManifestConfig { + pod: PodMetaConfig { + name: Some("parent".into()), + prompt_pack: None, + }, + model: ModelManifest { + scheme: Some(SchemeKind::Anthropic), + model_id: Some("parent-model".into()), + auth: Some(AuthRef::None), + ..Default::default() + }, + worker: WorkerManifestConfig { + instruction: Some("$insomnia/parent".into()), + language: Some("Parentish".into()), + max_tokens: Some(1234), + stop_sequences: Some(vec!["STOP".into()]), + ..Default::default() + }, + scope: ScopeConfig { + allow: vec![abs_rule(root, Permission::Write)], + deny: deny + .map(|path| vec![abs_rule(path, Permission::Read)]) + .unwrap_or_default(), + }, + session: Some(SessionConfigPartial { + record_event_trace: Some(true), + }), + ..Default::default() + } + .try_into() + .unwrap() + } + + fn write_project_profile_registry( + project: &Path, + default: Option<&str>, + profiles: &[(&str, &str, &str)], + ) -> AvailableProfiles { + let insomnia = project.join(".insomnia"); + let profile_dir = insomnia.join("profiles"); + std::fs::create_dir_all(&profile_dir).unwrap(); + let mut registry_toml = String::new(); + if let Some(default) = default { + registry_toml.push_str(&format!("default = \"{default}\"\n")); + } + registry_toml.push_str("[profile]\n"); + for (name, file, body) in profiles { + std::fs::write(profile_dir.join(file), body).unwrap(); + registry_toml.push_str(&format!("{name} = \"profiles/{file}\"\n")); + } + let registry_path = insomnia.join("profiles.toml"); + std::fs::write(®istry_path, registry_toml).unwrap(); + AvailableProfiles { + registry: Some( + ProfileDiscovery::with_sources(None, None, Some(registry_path)) + .discover() + .unwrap(), + ), + diagnostic: None, + } + } + + fn child_config_from_profile( + spawner_manifest: &PodManifest, + available: &AvailableProfiles, + cwd: &Path, + name: &str, + instruction_override: Option<&str>, + scope: &[ScopeRule], + selector: SpawnProfileSelector, + ) -> PodManifestConfig { + let json = build_spawn_config_json_for_profile( + spawner_manifest, + available, + cwd, + name, + instruction_override, + scope, + selector, + ) + .unwrap(); + serde_json::from_str(&json).unwrap() + } + + const CODER_PROFILE: &str = r#" +local profile = require("insomnia.profile") +local scope = require("insomnia.scope") +return profile { + slug = "coder", + model = { scheme = "anthropic", model_id = "coder-model" }, + worker = { instruction = "$insomnia/coder", language = "Coderish", max_tokens = 2222 }, + scope = scope.workspace_write(), +} +"#; + + const REVIEWER_PROFILE: &str = r#" +local profile = require("insomnia.profile") +local scope = require("insomnia.scope") +return profile { + slug = "reviewer", + model = { scheme = "anthropic", model_id = "reviewer-model" }, + worker = { instruction = "$insomnia/reviewer", language = "Reviewerish", max_tokens = 3333 }, + scope = scope.workspace_write(), +} +"#; #[test] fn spawn_config_inherits_inline_spawner_model() { @@ -868,6 +1002,278 @@ mod tests { assert!(parsed.session.is_none()); } + #[test] + fn omitted_profile_resolves_effective_registry_default() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + let delegated = tmp.path().join("delegated"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&delegated).unwrap(); + let available = write_project_profile_registry( + &project, + Some("reviewer"), + &[ + ("coder", "coder.lua", CODER_PROFILE), + ("reviewer", "reviewer.lua", REVIEWER_PROFILE), + ], + ); + let parent = parent_manifest(&project, None); + let scope = vec![abs_rule(&delegated, Permission::Read)]; + + let config = child_config_from_profile( + &parent, + &available, + &project, + "child-default", + None, + &scope, + SpawnProfileSelector::Default, + ); + + assert_eq!(config.pod.name.as_deref(), Some("child-default")); + assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); + assert_eq!( + config.worker.instruction.as_deref(), + Some("$insomnia/reviewer") + ); + assert_eq!(config.worker.language.as_deref(), Some("Reviewerish")); + assert_eq!(config.scope.allow, scope); + assert!(config.scope.deny.is_empty()); + } + + #[test] + fn source_qualified_profile_role_config_reaches_spawn_config() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + let delegated = tmp.path().join("delegated"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&delegated).unwrap(); + let available = write_project_profile_registry( + &project, + Some("coder"), + &[ + ("coder", "coder.lua", CODER_PROFILE), + ("reviewer", "reviewer.lua", REVIEWER_PROFILE), + ], + ); + let parent = parent_manifest(&project, None); + let scope = vec![abs_rule(&delegated, Permission::Write)]; + + let config = child_config_from_profile( + &parent, + &available, + &project, + "review-child", + None, + &scope, + SpawnProfileSelector::Registry(ProfileSelector::source_named( + ProfileRegistrySource::Project, + "reviewer", + )), + ); + + assert_eq!(config.pod.name.as_deref(), Some("review-child")); + assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); + assert_eq!( + config.worker.instruction.as_deref(), + Some("$insomnia/reviewer") + ); + assert_eq!(config.worker.language.as_deref(), Some("Reviewerish")); + assert_eq!(config.worker.max_tokens, Some(3333)); + assert_eq!(config.scope.allow, scope); + assert!(config.scope.deny.is_empty()); + } + + #[test] + fn inherit_copies_reusable_parent_fields_and_replaces_runtime_authority() { + let tmp = TempDir::new().unwrap(); + let parent_root = tmp.path().join("parent-root"); + let parent_deny = parent_root.join("secret"); + let delegated = tmp.path().join("delegated"); + std::fs::create_dir_all(&parent_deny).unwrap(); + std::fs::create_dir_all(&delegated).unwrap(); + let parent = parent_manifest(&parent_root, Some(&parent_deny)); + let scope = vec![abs_rule(&delegated, Permission::Read)]; + let available = AvailableProfiles { + registry: None, + diagnostic: None, + }; + + let config = child_config_from_profile( + &parent, + &available, + tmp.path(), + "inherited-child", + None, + &scope, + SpawnProfileSelector::Inherit, + ); + + assert_eq!(config.pod.name.as_deref(), Some("inherited-child")); + assert_eq!(config.model.model_id.as_deref(), Some("parent-model")); + assert_eq!( + config.worker.instruction.as_deref(), + Some("$insomnia/parent") + ); + assert_eq!(config.worker.language.as_deref(), Some("Parentish")); + assert_eq!(config.worker.max_tokens, Some(1234)); + assert_eq!( + config.worker.stop_sequences.as_deref(), + Some(&["STOP".to_string()][..]) + ); + assert_eq!( + config.session.as_ref().and_then(|s| s.record_event_trace), + Some(true) + ); + assert_eq!(config.scope.allow, scope); + assert!(config.scope.deny.is_empty()); + } + + #[test] + fn instruction_override_changes_only_worker_instruction() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + let delegated = tmp.path().join("delegated"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&delegated).unwrap(); + let available = write_project_profile_registry( + &project, + Some("reviewer"), + &[("reviewer", "reviewer.lua", REVIEWER_PROFILE)], + ); + let parent = parent_manifest(&project, None); + let scope = vec![abs_rule(&delegated, Permission::Write)]; + + let config = child_config_from_profile( + &parent, + &available, + &project, + "override-child", + Some("$user/custom-reviewer"), + &scope, + SpawnProfileSelector::Default, + ); + + assert_eq!( + config.worker.instruction.as_deref(), + Some("$user/custom-reviewer") + ); + assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); + assert_eq!(config.worker.language.as_deref(), Some("Reviewerish")); + assert_eq!(config.worker.max_tokens, Some(3333)); + assert_eq!(config.scope.allow, scope); + } + + #[test] + fn profile_and_inherited_scope_are_replaced_by_delegated_scope() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + let delegated = tmp.path().join("delegated"); + let parent_root = tmp.path().join("parent-root"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&delegated).unwrap(); + std::fs::create_dir_all(&parent_root).unwrap(); + let available = write_project_profile_registry( + &project, + Some("reviewer"), + &[("reviewer", "reviewer.lua", REVIEWER_PROFILE)], + ); + let parent = parent_manifest(&parent_root, Some(&parent_root.join("deny"))); + let scope = vec![abs_rule(&delegated, Permission::Read)]; + + let profile_config = child_config_from_profile( + &parent, + &available, + &project, + "profile-child", + None, + &scope, + SpawnProfileSelector::Default, + ); + let inherit_config = child_config_from_profile( + &parent, + &available, + &project, + "inherit-child", + None, + &scope, + SpawnProfileSelector::Inherit, + ); + + for config in [profile_config, inherit_config] { + assert_eq!(config.scope.allow, scope); + assert!(config.scope.deny.is_empty()); + assert!(!config.scope.allow.iter().any(|rule| rule.target == project)); + assert!( + !config + .scope + .allow + .iter() + .any(|rule| rule.target == parent_root) + ); + } + } + + #[test] + fn invalid_ambiguous_and_no_default_diagnostics_include_available_selectors() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let available = write_project_profile_registry( + &project, + None, + &[("coder", "coder.lua", CODER_PROFILE)], + ); + let parent = parent_manifest(&project, None); + let scope = vec![abs_rule(&project, Permission::Read)]; + + let invalid = parse_spawn_profile_selector(Some("./reviewer.lua")) + .map_err(|msg| format!("{msg}{}", available.error_suffix())) + .unwrap_err(); + assert!(invalid.contains("Use `default`, `inherit`")); + assert!(invalid.contains("`project:coder`")); + + let no_default = build_spawn_config_json_for_profile( + &parent, + &available, + &project, + "child", + None, + &scope, + SpawnProfileSelector::Default, + ) + .unwrap_err(); + assert!(no_default.contains("no default profile"), "{no_default}"); + assert!(no_default.contains("Use `default`, `inherit`")); + assert!(no_default.contains("`project:coder`")); + + let user_config = tmp.path().join("user-profiles.toml"); + std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.lua\"\n").unwrap(); + let project_config = project.join(".insomnia/profiles.toml"); + let ambiguous = AvailableProfiles { + registry: Some( + ProfileDiscovery::with_sources(None, Some(user_config), Some(project_config)) + .discover() + .unwrap(), + ), + diagnostic: None, + }; + let ambiguous_error = build_spawn_config_json_for_profile( + &parent, + &ambiguous, + &project, + "child", + None, + &scope, + SpawnProfileSelector::Registry(ProfileSelector::named("coder")), + ) + .unwrap_err(); + assert!(ambiguous_error.contains("ambiguous"), "{ambiguous_error}"); + assert!(ambiguous_error.contains("user:coder")); + assert!(ambiguous_error.contains("project:coder")); + assert!(ambiguous_error.contains("Use `default`, `inherit`")); + } + #[test] fn spawn_profile_selector_rejects_path_like_values() { for raw in [