merge: spawnpod profile tool

This commit is contained in:
Keisuke Hirata 2026-05-30 14:18:48 +09:00
commit 4c189fb0da
No known key found for this signature in database
7 changed files with 883 additions and 60 deletions

View File

@ -352,6 +352,24 @@ impl ProfileResolver {
source, source,
})?; })?;
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?; let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
self.resolve_from_registry(selector, &registry, 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<ResolvedProfile, ProfileError> {
match selector {
ProfileSelector::Path { .. } => Err(ProfileError::InvalidProfile(
"path selectors are not registry entries".into(),
)),
ProfileSelector::Named { .. } | ProfileSelector::Default => {
let entry = registry.select(selector)?.clone(); let entry = registry.select(selector)?.clone();
self.resolve_path( self.resolve_path(
&entry.path, &entry.path,
@ -365,6 +383,7 @@ impl ProfileResolver {
} }
} }
} }
fn resolve_path( fn resolve_path(
&self, &self,
path: &Path, path: &Path,

View File

@ -501,8 +501,8 @@ where
let memory_config = pod.manifest().memory.clone(); let memory_config = pod.manifest().memory.clone();
let web_config = pod.manifest().web.clone(); let web_config = pod.manifest().web.clone();
let spawner_name = pod.manifest().pod.name.clone(); let spawner_name = pod.manifest().pod.name.clone();
let spawner_model = pod.manifest().model.clone(); let spawner_manifest = pod.manifest().clone();
let spawner_record_event_trace = pod.manifest().session.record_event_trace; let prompts = pod.prompts().clone();
let pod_store = pod.store().clone(); let pod_store = pod.store().clone();
let self_parent_socket = pod.callback_socket().cloned(); let self_parent_socket = pod.callback_socket().cloned();
@ -556,9 +556,9 @@ where
pwd.clone(), pwd.clone(),
spawned_registry.clone(), spawned_registry.clone(),
self_parent_socket, self_parent_socket,
spawner_model, spawner_manifest,
spawner_record_event_trace,
scope_handle, scope_handle,
prompts,
)); ));
worker.register_tool(send_to_pod_tool(spawned_registry.clone())); worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
worker.register_tool(read_pod_output_tool(spawned_registry.clone())); worker.register_tool(read_pod_output_tool(spawned_registry.clone()));

View File

@ -92,6 +92,9 @@ pub enum PodPrompt {
/// knowledge when Workflow resident injection is enabled and at least one /// knowledge when Workflow resident injection is enabled and at least one
/// workflow advertises `model_invokation: true`. /// workflow advertises `model_invokation: true`.
ResidentWorkflowsSection, ResidentWorkflowsSection,
/// LLM-facing description for the SpawnPod tool, including discovered
/// profile selectors.
SpawnPodToolDescription,
} }
impl PodPrompt { impl PodPrompt {
@ -108,6 +111,7 @@ impl PodPrompt {
Self::ResidentMemorySummarySection => "resident_memory_summary_section", Self::ResidentMemorySummarySection => "resident_memory_summary_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section", Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section", Self::ResidentWorkflowsSection => "resident_workflows_section",
Self::SpawnPodToolDescription => "spawn_pod_tool_description",
} }
} }
@ -126,6 +130,7 @@ impl PodPrompt {
PodPrompt::ResidentMemorySummarySection, PodPrompt::ResidentMemorySummarySection,
PodPrompt::ResidentKnowledgeSection, PodPrompt::ResidentKnowledgeSection,
PodPrompt::ResidentWorkflowsSection, PodPrompt::ResidentWorkflowsSection,
PodPrompt::SpawnPodToolDescription,
]; ];
pub const KEYS: &'static [&'static str] = &[ pub const KEYS: &'static [&'static str] = &[
@ -140,6 +145,7 @@ impl PodPrompt {
"resident_memory_summary_section", "resident_memory_summary_section",
"resident_knowledge_section", "resident_knowledge_section",
"resident_workflows_section", "resident_workflows_section",
"spawn_pod_tool_description",
]; ];
} }
@ -385,6 +391,21 @@ impl PromptCatalog {
single("entries", entries), single("entries", entries),
) )
} }
/// Render `PodPrompt::SpawnPodToolDescription`.
pub fn spawn_pod_tool_description(
&self,
available_profiles: &str,
default_profile: &str,
profile_diagnostic: &str,
) -> Result<String, CatalogError> {
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 { 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.starts_with("PREFIX\n"));
assert!(rendered.contains("write_summary")); 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"));
}
} }

View File

@ -14,8 +14,10 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{ use manifest::{
ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule, CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial,
SessionConfigPartial, SharedScope, WorkerManifestConfig, PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery, ProfileError, ProfileRegistry,
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig,
}; };
use serde::Deserialize; use serde::Deserialize;
use tokio::net::UnixStream; use tokio::net::UnixStream;
@ -23,20 +25,13 @@ use tokio::process::Command;
use tokio::time::sleep; use tokio::time::sleep;
use crate::ipc::event; use crate::ipc::event;
use crate::prompt::catalog::PromptCatalog;
use crate::runtime::dir::SpawnedPodRecord; use crate::runtime::dir::SpawnedPodRecord;
use crate::runtime::pod_registry::{self, LockFileGuard, ScopeLockError}; use crate::runtime::pod_registry::{self, LockFileGuard, ScopeLockError};
use crate::spawn::comm_tools::{SendRunError, send_run_and_confirm}; use crate::spawn::comm_tools::{SendRunError, send_run_and_confirm};
use crate::spawn::registry::SpawnedPodRegistry; use crate::spawn::registry::SpawnedPodRegistry;
use protocol::PodEvent; 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 /// How long we will wait for the spawned Pod's socket to become
/// connectable before treating the spawn as failed. /// connectable before treating the spawn as failed.
const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10); const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
@ -45,6 +40,13 @@ const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
struct SpawnPodInput { struct SpawnPodInput {
/// Identifier for the spawned Pod. Must be unique machine-wide. /// Identifier for the spawned Pod. Must be unique machine-wide.
name: String, 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<String>,
/// Instruction-file reference (e.g. `$insomnia/default`, `$user/my-agent`). /// Instruction-file reference (e.g. `$insomnia/default`, `$user/my-agent`).
#[serde(default)] #[serde(default)]
instruction: Option<String>, instruction: Option<String>,
@ -87,6 +89,120 @@ impl From<PermissionInput> for Permission {
} }
} }
#[derive(Debug, Clone)]
struct AvailableProfiles {
registry: Option<ProfileRegistry>,
diagnostic: Option<String>,
}
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::<Vec<_>>()
.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<SpawnProfileSelector, String> {
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 /// Runtime dependencies the `SpawnPod` tool needs in order to launch a
/// child Pod and record the handoff locally. Constructed by the Pod /// child Pod and record the handoff locally. Constructed by the Pod
/// controller once per Pod lifetime. /// 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 /// `None` for top-level Pods — in that case the re-emission is a
/// no-op. /// no-op.
parent_socket: Option<PathBuf>, parent_socket: Option<PathBuf>,
/// Spawner's resolved provider config — copied into every spawned /// Spawner's resolved Manifest. `profile = "inherit"` derives the
/// Pod's internal manifest config so the child does not need its own provider /// child config from reusable fields here, and selected profiles are
/// configuration. Per-spawn override is /// merged into the same internal handoff shape before launch.
/// out of scope here (see `tickets/spawn-inherit-provider.md`). spawner_manifest: PodManifest,
spawner_model: ModelManifest, /// Compact selector list shared by tool description and diagnostics.
/// Spawner's session diagnostics policy. Preserved for spawned Pods so available_profiles: AvailableProfiles,
/// opt-in provider event traces continue across delegation.
spawner_record_event_trace: bool,
/// Spawner's runtime scope. After a successful spawn, the /// Spawner's runtime scope. After a successful spawn, the
/// `Permission::Write` rules in the delegated scope are revoked /// `Permission::Write` rules in the delegated scope are revoked
/// from the spawner's in-memory view (a `deny(Write, target)` is /// from the spawner's in-memory view (a `deny(Write, target)` is
@ -133,15 +247,15 @@ pub struct SpawnPodTool {
} }
impl SpawnPodTool { impl SpawnPodTool {
pub fn new( fn new(
spawner_name: String, spawner_name: String,
callback_socket: PathBuf, callback_socket: PathBuf,
runtime_base: PathBuf, runtime_base: PathBuf,
spawner_pwd: PathBuf, spawner_pwd: PathBuf,
registry: Arc<SpawnedPodRegistry>, registry: Arc<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>, parent_socket: Option<PathBuf>,
spawner_model: ModelManifest, spawner_manifest: PodManifest,
spawner_record_event_trace: bool, available_profiles: AvailableProfiles,
spawner_scope: SharedScope, spawner_scope: SharedScope,
) -> Self { ) -> Self {
Self { Self {
@ -151,8 +265,8 @@ impl SpawnPodTool {
spawner_pwd, spawner_pwd,
registry, registry,
parent_socket, parent_socket,
spawner_model, spawner_manifest,
spawner_record_event_trace, available_profiles,
spawner_scope, spawner_scope,
} }
} }
@ -176,10 +290,21 @@ impl Tool for SpawnPodTool {
let scope_allow = parse_scope(&input.scope)?; let scope_allow = parse_scope(&input.scope)?;
let instruction = input let spawn_selector =
.instruction parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
.clone() ToolError::InvalidArgument(format!(
.unwrap_or_else(|| DEFAULT_INSTRUCTION.to_string()); "{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 predicted_socket = self.runtime_base.join(&input.name).join("sock");
let lock_path = pod_registry::default_registry_path() 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 // it back — even if later steps (Method::Run delivery, record
// write) fail, the child is running and will release its own // write) fail, the child is running and will release its own
// entry on exit. // 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 let start_outcome = self
.exec_child(&input.name, &spawn_config_json, &predicted_socket) .exec_child(&input.name, &spawn_config_json, &predicted_socket)
@ -385,11 +495,78 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
/// The child's working directory is set separately via /// The child's working directory is set separately via
/// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is /// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is
/// not part of the manifest. /// 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<String, String> {
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<String, String> {
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( fn build_spawn_config_json(
name: &str, name: &str,
instruction: &str, instruction: &str,
scope_allow: &[ScopeRule], scope_allow: &[ScopeRule],
model: &ModelManifest, model: &manifest::ModelManifest,
record_event_trace: bool, record_event_trace: bool,
) -> Result<String, serde_json::Error> { ) -> Result<String, serde_json::Error> {
let config = PodManifestConfig { let config = PodManifestConfig {
@ -414,6 +591,94 @@ fn build_spawn_config_json(
serde_json::to_string(&config) 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 /// 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 /// failure message. Capped so a chatty child can't blow up the LLM's
/// tool-result budget — debugging beyond this should read the file /// tool-result budget — debugging beyond this should read the file
@ -493,15 +758,28 @@ pub fn spawn_pod_tool(
spawner_pwd: PathBuf, spawner_pwd: PathBuf,
registry: Arc<SpawnedPodRegistry>, registry: Arc<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>, parent_socket: Option<PathBuf>,
spawner_model: ModelManifest, spawner_manifest: PodManifest,
spawner_record_event_trace: bool,
spawner_scope: SharedScope, spawner_scope: SharedScope,
prompts: Arc<PromptCatalog>,
) -> ToolDefinition { ) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(SpawnPodInput); let schema = schemars::schema_for!(SpawnPodInput);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); 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") let meta = ToolMeta::new("SpawnPod")
.description(DESCRIPTION) .description(description)
.input_schema(schema_value); .input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(SpawnPodTool::new( let tool: Arc<dyn Tool> = Arc::new(SpawnPodTool::new(
spawner_name.clone(), spawner_name.clone(),
@ -510,8 +788,8 @@ pub fn spawn_pod_tool(
spawner_pwd.clone(), spawner_pwd.clone(),
registry.clone(), registry.clone(),
parent_socket.clone(), parent_socket.clone(),
spawner_model.clone(), spawner_manifest.clone(),
spawner_record_event_trace, available_profiles,
spawner_scope.clone(), spawner_scope.clone(),
)); ));
(meta, tool) (meta, tool)
@ -521,7 +799,123 @@ pub fn spawn_pod_tool(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use manifest::{AuthRef, PodManifest, SchemeKind}; 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(&registry_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] #[test]
fn spawn_config_inherits_inline_spawner_model() { fn spawn_config_inherits_inline_spawner_model() {
@ -607,4 +1001,312 @@ mod tests {
assert!(parsed.session.is_none()); 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 [
"./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"))
);
}
} }

View File

@ -11,7 +11,10 @@ use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex};
use llm_worker::tool::{ToolError, ToolOutput}; 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::dir::{RuntimeDir, SpawnedPodRecord};
use pod::runtime::pod_registry::{self, LockFileGuard}; use pod::runtime::pod_registry::{self, LockFileGuard};
use pod::spawn::registry::SpawnedPodRegistry; use pod::spawn::registry::SpawnedPodRegistry;
@ -107,6 +110,25 @@ fn accept_one_method(listener: UnixListener) -> tokio::task::JoinHandle<Option<M
let (reader, writer) = stream.into_split(); let (reader, writer) = stream.into_split();
let mut r = JsonLineReader::new(reader); let mut r = JsonLineReader::new(reader);
let mut w = JsonLineWriter::new(writer); let mut w = JsonLineWriter::new(writer);
if w.write(&Event::Snapshot {
entries: Vec::new(),
greeting: protocol::Greeting {
pod_name: "child".into(),
cwd: "/tmp".into(),
provider: "test".into(),
model: "test".into(),
scope_summary: String::new(),
tools: Vec::new(),
context_window: 200_000,
context_tokens: 0,
},
status: protocol::PodStatus::Idle,
})
.await
.is_err()
{
continue;
}
if let Ok(Some(method)) = r.next::<Method>().await { if let Ok(Some(method)) = r.next::<Method>().await {
w.write(&Event::UserMessage { w.write(&Event::UserMessage {
segments: vec![protocol::Segment::text("accepted")], 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> {
pod::PromptCatalog::builtins_only().unwrap()
}
/// Spawner-side `SharedScope` mirroring the `allow_root` granted by /// Spawner-side `SharedScope` mirroring the `allow_root` granted by
/// `setup_spawner`. The tool revokes Write rules from this scope on /// `setup_spawner`. The tool revokes Write rules from this scope on
/// successful spawn — tests can `load()` it to assert the /// 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(), allow_root.path().to_path_buf(),
registry, registry,
None, None,
dummy_model(), dummy_manifest(allow_root.path()),
false,
spawner_scope.clone(), spawner_scope.clone(),
builtin_prompts(),
); );
let (_meta, tool) = def(); let (_meta, tool) = def();
let input = json!({ let input = json!({
"name": "child", "name": "child",
"task": "hello", "task": "hello",
"profile": "inherit",
"scope": [{ "scope": [{
"target": allow_root.path().to_str().unwrap(), "target": allow_root.path().to_str().unwrap(),
"permission": "write" "permission": "write"
@ -280,9 +328,9 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
allow_root.path().to_path_buf(), allow_root.path().to_path_buf(),
registry, registry,
None, None,
dummy_model(), dummy_manifest(allow_root.path()),
false,
spawner_scope.clone(), spawner_scope.clone(),
builtin_prompts(),
); );
let (_meta, tool) = def(); let (_meta, tool) = def();
@ -290,6 +338,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
let input = json!({ let input = json!({
"name": "child", "name": "child",
"task": "nope", "task": "nope",
"profile": "inherit",
"scope": [{ "scope": [{
"target": outside.path().to_str().unwrap(), "target": outside.path().to_str().unwrap(),
"permission": "write" "permission": "write"
@ -352,15 +401,16 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
allow_root.path().to_path_buf(), allow_root.path().to_path_buf(),
registry, registry,
None, None,
dummy_model(), dummy_manifest(allow_root.path()),
false,
spawner_scope.clone(), spawner_scope.clone(),
builtin_prompts(),
); );
let (_meta, tool) = def(); let (_meta, tool) = def();
let input = json!({ let input = json!({
"name": "ghost", "name": "ghost",
"task": "will never be delivered", "task": "will never be delivered",
"profile": "inherit",
"scope": [{ "scope": [{
"target": allow_root.path().to_str().unwrap(), "target": allow_root.path().to_str().unwrap(),
"permission": "write" "permission": "write"

View File

@ -179,6 +179,8 @@ insomnia-pod --session <uuid>
Spawn children use hidden `--spawn-config-json`, `--adopt`, and `--callback <path>` flags. These are internal handoff details used by `SpawnPod` after the parent has allocated scope and prepared the child config. Spawn children use hidden `--spawn-config-json`, `--adopt`, and `--callback <path>` 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 ## 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. 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.

View File

@ -65,3 +65,16 @@ The following workflows are advertised resident. When a user request matches one
{{ entries }}\ {{ 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 %}\
"""