Compare commits

...

6 Commits

12 changed files with 1199 additions and 161 deletions

View File

@ -352,6 +352,24 @@ impl ProfileResolver {
source,
})?;
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();
self.resolve_path(
&entry.path,
@ -365,6 +383,7 @@ impl ProfileResolver {
}
}
}
fn resolve_path(
&self,
path: &Path,

View File

@ -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()));

View File

@ -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<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 {
@ -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"));
}
}

View File

@ -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<String>,
/// Instruction-file reference (e.g. `$insomnia/default`, `$user/my-agent`).
#[serde(default)]
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
/// 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<PathBuf>,
/// 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<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
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,78 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, 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<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(
name: &str,
instruction: &str,
scope_allow: &[ScopeRule],
model: &ModelManifest,
model: &manifest::ModelManifest,
record_event_trace: bool,
) -> Result<String, serde_json::Error> {
let config = PodManifestConfig {
@ -414,6 +591,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 +758,28 @@ pub fn spawn_pod_tool(
spawner_pwd: PathBuf,
registry: Arc<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
spawner_model: ModelManifest,
spawner_record_event_trace: bool,
spawner_manifest: PodManifest,
spawner_scope: SharedScope,
prompts: Arc<PromptCatalog>,
) -> 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<dyn Tool> = Arc::new(SpawnPodTool::new(
spawner_name.clone(),
@ -510,8 +788,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 +799,123 @@ pub fn spawn_pod_tool(
#[cfg(test)]
mod tests {
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]
fn spawn_config_inherits_inline_spawner_model() {
@ -607,4 +1001,312 @@ 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 [
"./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 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<Option<M
let (reader, writer) = stream.into_split();
let mut r = JsonLineReader::new(reader);
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 {
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> {
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"

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.
`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.

View File

@ -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 %}\
"""

View File

@ -2,12 +2,12 @@
id: 20260529-205540-spawnpod-profile-tool-description
slug: spawnpod-profile-tool-description
title: SpawnPod profile selection and templated tool description
status: open
status: closed
kind: feature
priority: P2
labels: [pod, manifest, tools, workflow]
created_at: 2026-05-29T20:55:40Z
updated_at: 2026-05-30T05:11:43Z
updated_at: 2026-05-30T05:19:46Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,88 @@
---
id: 20260529-205540-spawnpod-profile-tool-description
slug: spawnpod-profile-tool-description
title: SpawnPod profile selection and templated tool description
status: closed
kind: feature
priority: P2
labels: [pod, manifest, tools, workflow]
created_at: 2026-05-29T20:55:40Z
updated_at: 2026-05-30T05:19:46Z
assignee: null
legacy_ticket: null
---
## Background
`SpawnPod` is becoming the main mechanism for hierarchical orchestration. The workflow model now distinguishes role-specific child Pods: lower orchestrators, coder Pods, and external reviewer Pods. These roles should be selectable by profile, but the current `SpawnPod` tool has no profile field and no LLM-visible route to discover profile selectors.
A separate `ListProfiles` tool would expose mostly static or semi-static affordance information as another model action. The desired design is instead to make profile choices part of the `SpawnPod` tool's own guidance: render the available profile selectors into the tool description, and repeat the same selector list in invalid/ambiguous/no-default diagnostics.
Current implementation notes:
- Tool metadata is defined by `llm_worker::tool::ToolMeta`; `ToolDefinition` factories return `(ToolMeta, Arc<dyn Tool>)` and `ToolServerHandle::flush_pending()` materializes them before request construction.
- Tool descriptions are currently hard-coded strings/doc comments in each tool factory. `crates/pod/src/spawn/tool.rs` has a static `DESCRIPTION` and `SpawnPodInput` only contains `name`, `instruction`, `task`, and `scope`.
- `SpawnPod` currently builds an internal `PodManifestConfig` directly from selected pieces of the parent resolved manifest plus tool input, then launches the child with hidden `--spawn-config-json`. It copies the parent model and session trace flag, applies optional instruction override, and uses the delegated scope from the tool call. It does not use profile resolution and it does not copy the parent resolved manifest wholesale.
- Prompt assets are centralized under `resources/prompts`; Pod-owned prompt injection strings are represented by `PodPrompt` and rendered by `PromptCatalog` through minijinja. `resources/prompts/internal.toml` has build-time coverage against `PodPrompt` variants.
- Profile discovery/resolution already exists in `manifest`, though the concrete profile authoring layer is being revised away from Nix-primary semantics. SpawnPod profile selection should use the same effective profile registry/default semantics as the normal launcher path once that resolver is available.
## Requirements
- Add an optional `profile` field to `SpawnPodInput`.
- `SpawnPod.profile` accepts three conceptual selector classes:
- omitted or `"default"`: resolve the effective child default profile;
- `"inherit"`: derive child config from the spawner's resolved Manifest, extracting only reusable profile-like fields;
- `<slug>` / source-qualified selectors such as `builtin:<slug>`, `user:<slug>`, `project:<slug>`: resolve a discovered role profile.
- `inherit` is distinct from reusing the profile source that created the parent. It means extracting reusable configuration from the parent resolved Manifest. Re-evaluating or reusing the parent's original Profile source is a separate concept and is not required here.
- Make `SpawnPod`'s LLM-facing tool description include the currently discoverable profile selectors, the effective default profile, and the special `inherit` selector.
- Do not add a separate `ListProfiles` tool for this feature.
- The profile list in the tool description must come from the same builtin/user/project profile discovery rules used by the profile launcher path.
- If profile discovery fails, the tool should still be registered with a clear diagnostic in its description rather than making Pod startup fail solely because the description could not list profiles.
- If `profile` is omitted, `SpawnPod` resolves the effective default profile. With the builtin default profile present, ordinary omission should keep working.
- If `profile` is invalid, ambiguous, unsupported, or omitted while no default can be resolved, the tool error must include a compact available-profile list, source-qualified suggestions, and mention `inherit` where appropriate.
- `SpawnPod.profile` should initially accept registry/default selectors and `inherit` only: `default`, `inherit`, `builtin:<name>`, `user:<name>`, `project:<name>`, and unqualified names only when unambiguous. Raw path selectors, `path:<path>`, relative paths, absolute paths, and `.nix`/`.lua` path-like values are rejected for the tool path.
- `SpawnPod.scope` remains the only delegated capability. A profile selected through `SpawnPod`, including `inherit`, must not be able to expand `scope.allow` beyond the explicit tool argument.
- `SpawnPod.name` always overrides any resolved/derived `pod.name` in the child manifest.
- `SpawnPod.instruction`, if present, is a typed override for the selected profile's or inherited config's `worker.instruction` only; it must not replace the whole profile/config.
- Profile-selected spawn should preserve profile-owned role configuration such as model, worker settings, tools/memory/web policy, and prompt pack where applicable, subject to the explicit `name` and `scope` overrides.
- `inherit` should preserve reusable parent-owned configuration such as model, worker settings, compaction, tools/memory/web policy, prompt pack, and session diagnostics where applicable, subject to the explicit `name`, `scope`, and optional `instruction` overrides.
- `inherit` must not preserve runtime-bound or authority-bearing parent fields: parent `pod.name`, concrete parent `scope.allow`/`scope.deny`, resolved runtime paths, sockets, session/pod-store state, spawned-child registry state, callback addresses, or raw resolved secret material.
- Existing hidden `--spawn-config-json` remains the internal launch handoff. `SpawnPod` resolves/merges selected profile data in the parent process and passes the resulting manifest config/snapshot to the child; it should not simply exec `insomnia-pod --profile`.
- Documentation/workflows should show `SpawnPod(profile = "project:coder")`, `SpawnPod(profile = "project:reviewer")`, optionally `project:orchestrator`, and `SpawnPod(profile = "inherit")` as explicit choices while keeping scope authority separate from profile role selection.
## Tool description templating direction
Use the existing prompt infrastructure rather than scattering another large hard-coded string.
Acceptable implementation shape:
- Add a Pod-owned prompt/catalog entry for the `SpawnPod` tool description, e.g. `spawn_pod_tool_description`, with minijinja variables such as `available_profiles`, `default_profile`, `special_selectors`, and `profile_diagnostic`.
- Render this prompt when registering `SpawnPod` in `register_pod_tools`, using the Pod cwd as the profile discovery base.
- Keep the rendered description as `ToolMeta.description`; the tool metadata still remains session-scoped after registration.
- The same formatter used for the description should be reusable by error diagnostics so invalid profile errors repeat the available selectors.
If a more general tool-description catalog is introduced, keep the initial scope narrow: it must support `SpawnPod` without forcing every built-in tool to migrate in the same ticket.
## Acceptance criteria
- `SpawnPod` schema exposes optional `profile` with clear field docs for `default`, `inherit`, and profile slug/source-qualified selectors.
- `SpawnPod` tool description includes a compact available-profile block, default-profile guidance, and the `inherit` special selector.
- `SpawnPod` invalid/ambiguous/no-default profile errors include the same compact selector list and tell the model to use a listed selector or `inherit`.
- `SpawnPod(profile = "project:reviewer")` resolves the project reviewer profile and applies its role config while replacing `pod.name` and `scope.allow` with the explicit `SpawnPod` values.
- `SpawnPod` with omitted profile resolves the effective default profile.
- `SpawnPod(profile = "inherit")` derives child config from the parent resolved Manifest's reusable fields while replacing `pod.name`, `scope.allow`, and optional `worker.instruction` with the explicit SpawnPod values.
- `SpawnPod(profile = "./reviewer.lua")`, `SpawnPod(profile = "path:./reviewer.lua")`, legacy `.nix` path-like selectors, and absolute profile paths are rejected with an explanation that the tool accepts `default`, `inherit`, or registry selectors only.
- Ambiguous unqualified profile names fail closed with source-qualified suggestions.
- A profile's or inherited config's `scope.allow` cannot grant access not present in `SpawnPod.scope`.
- Existing `SpawnPod` behavior that matters for lifecycle remains intact: registry reservation/rollback, scope revocation from the spawner, callback wiring, child socket wait, and initial `Method::Run` confirmation.
- Unit/integration tests cover description rendering, selector formatting, omitted default resolution, `inherit`, invalid selector diagnostics, profile config application, explicit instruction override, and scope authority replacement.
- Documentation and `.insomnia/workflow/` references explain profile-based coder/reviewer/orchestrator spawning without introducing a `ListProfiles` tool.
## Non-goals
- Do not introduce an LLM-callable `ListProfiles` tool.
- Do not enable arbitrary profile path evaluation through `SpawnPod`.
- Do not confuse `inherit` with reusing the parent's original Profile source. `inherit` is derived from the parent resolved Manifest; parent Profile source reuse can be a later explicit feature if needed.
- Do not revive manifest cascade or generic overlay layers.
- Do not redesign prompt-loader source selection for `$user` / `$workspace` profile prompt refs in this ticket unless it is required to keep current behavior correct.
- Do not implement encrypted secret storage; profiles may still contain unresolved typed secret refs as currently documented.

View File

@ -0,0 +1,226 @@
<!-- event: create author: tickets.sh at: 2026-05-29T20:55:40Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-05-30T02:53:19Z -->
## Decision
Clarified selector semantics:
- `default` / omitted means resolve the effective child default profile.
- `<slug>` / source-qualified selectors mean resolve a discovered role profile.
- `inherit` means derive reusable child config from the spawner's resolved Manifest.
`inherit` is explicitly not the same as reusing the Profile source that created the parent. It extracts reusable fields from the parent resolved Manifest and still replaces runtime-bound/authority fields such as `pod.name` and concrete `scope.allow` with the SpawnPod inputs. Reusing the parent's original Profile source can be considered later as a separate feature if needed.
---
<!-- event: plan author: hare at: 2026-05-30T04:54:02Z -->
## Plan
## Preflight implementation plan
Classification: implementation-ready.
No product/API decision is needed before coding. The ticket already fixes the important semantics: omitted/default uses the effective child default profile, `inherit` derives reusable config from the spawner's resolved Manifest, named/source-qualified selectors resolve discovered profiles, path selectors are rejected, and `SpawnPod.scope` remains the only delegated capability.
Important implementation notes:
- Do not rely on process `current_dir()` for SpawnPod profile discovery. Use the Pod cwd (`spawner_pwd`) explicitly by adding/exposing a resolver helper that resolves from a registry discovered for that cwd.
- Resolve profiles and build child config before pod-registry reservation where possible, so invalid profile selectors do not mutate registry/scope.
- `inherit` means derive from the parent resolved Manifest, not from the parent's original Profile source.
- Path-like values, `path:<...>`, `.lua`/legacy suffix selectors, and absolute/relative paths must fail closed in `SpawnPod.profile`.
- Existing hidden `--spawn-config-json` remains the internal handoff; do not exec child with `--profile`.
- Existing prompt-loader source limitations are out of scope; preserve current behavior.
Current code map:
- `crates/pod/src/spawn/tool.rs`: `SpawnPodInput`, static description, spawn lifecycle, `build_spawn_config_json`.
- `crates/pod/src/controller.rs`: `register_pod_tools`, currently snapshots parent model/trace and registers spawn tools.
- `crates/manifest/src/profile.rs`: `ProfileDiscovery`, `ProfileRegistry`, `ProfileSelector`, `ProfileResolver`.
- `crates/manifest/src/config.rs`: `PodManifestConfig`, merge/resolve/defaults.
- `crates/pod/src/main.rs`: hidden `--spawn-config-json` loading takes precedence and uses builtins-only prompt loader.
- `crates/pod/src/prompt/catalog.rs` and `resources/prompts/internal.toml`: central prompt catalog for templated tool description.
Implementation phases:
1. Add manifest profile resolver helper for registry/cwd-explicit selection.
2. Add `SpawnPodInput.profile` and a SpawnPod-specific selector parser for `default`, `inherit`, and registry selectors only.
3. Add shared available-profile formatter for tool description and error diagnostics.
4. Move SpawnPod tool description into prompt catalog/minijinja and render it during tool registration; discovery failures should render diagnostics, not fail Pod startup.
5. Build child `PodManifestConfig` from selected profile Manifest or inherited parent Manifest, replacing `pod.name`, replacing `scope.allow`, clearing `scope.deny`, and optionally overriding only `worker.instruction`.
6. Preserve existing lifecycle: registry reservation/rollback, scope revocation, spawned registry write, callback wiring, child socket wait, initial `Method::Run` confirmation.
7. Update docs/workflows with `project:coder`, `project:reviewer`, optional `project:orchestrator`, and `inherit` examples.
Critical risks:
- Do not merge profile/inherited scope with explicit SpawnPod scope; explicit scope replaces capability.
- Do not call CLI-style profile parser in a way that allows path profiles through SpawnPod.
- Description and diagnostic profile lists should share formatting.
- Prompt catalog key coverage is build-time enforced.
Validation plan:
- Unit tests for selector parsing, formatter, config builder override/replacement behavior.
- Manifest tests for cwd/registry-explicit resolver helper.
- Prompt catalog rendering test.
- SpawnPod integration tests for omitted default, inherit, project profile, invalid selector pre-reservation failure, ambiguity suggestions, and scope replacement.
- Run `cargo test -p manifest profile`, `cargo test -p pod spawn_pod`, relevant prompt catalog tests, `cargo fmt --check`, and `./tickets.sh doctor`.
---
<!-- event: review author: hare at: 2026-05-30T05:11:43Z status: request_changes -->
## Review: request changes
Request changes.
The implementation direction appears sound, and the reviewer did not find a concrete authority-expansion or lifecycle regression. However, the work item acceptance criteria require focused tests for the new SpawnPod profile semantics, and the submitted tests mostly cover selector parsing plus existing lifecycle tests forced to `profile = "inherit"`.
Required fixes:
- Add SpawnPod default-profile coverage proving omitted `profile` resolves the effective registry default.
- Add a source-qualified profile coverage case, e.g. `project:reviewer`, proving role config from the selected profile reaches the generated `--spawn-config-json`.
- Add `inherit` config coverage proving reusable parent fields are copied while `pod.name`, `scope.allow`, and `scope.deny` are replaced.
- Add explicit `instruction` override coverage proving only `worker.instruction` changes.
- Add invalid / ambiguous / no-default diagnostics coverage proving the available-selector block appears.
- Add profile scope replacement coverage proving profile/inherited scope cannot expand delegated scope.
Non-blocking follow-ups:
- Available profile list currently emits source-qualified selectors only; future refinement may mention unqualified names when unambiguous.
- Workflow examples can later be updated to use explicit `project:coder` / `project:reviewer` selectors.
Validation note:
- `cargo test -p pod spawn_profile --no-default-features` currently only proves parser behavior, not profile resolution or child config construction.
---
<!-- event: review author: hare at: 2026-05-30T05:18:48Z status: approve -->
## Review: approve
Approve.
The requested test coverage gaps are satisfied by `b94891e`. The new tests directly cover the previously missing SpawnPod profile/config-building behaviors, and the added seam remains private to the module/test context rather than exposing a new production API.
Coverage verified:
- Omitted/default selector resolves the effective registry default.
- Source-qualified `project:reviewer` role config reaches generated hidden spawn config / child config.
- `inherit` copies reusable parent fields while replacing `pod.name`, `scope.allow`, and `scope.deny`.
- Explicit `instruction` override changes only `worker.instruction` while preserving other selected role fields.
- Invalid / ambiguous / no-default diagnostics include available-selector guidance.
- Profile and inherited scope are replaced by delegated scope and cannot expand child authority.
Remaining blockers: none.
Non-blocking follow-ups:
- Default-selector coverage exercises `SpawnProfileSelector::Default` directly while omitted parse coverage is separate; acceptable.
- Diagnostics parser test manually composes the invalid-selector suffix instead of going through full `execute`; acceptable because production append logic is simple and builder diagnostics are covered directly.
Validation reviewed:
- Coder: `cargo fmt --check`, `cargo test -p manifest profile`, `cargo test -p pod`, `cargo test -p pod --no-default-features` passed.
- Reviewer spot-ran: `cargo test -p pod profile`, `cargo test -p manifest profile`, `cargo test -p pod --no-default-features` passed.
Final verdict: approve.
---
<!-- event: close author: hare at: 2026-05-30T05:19:46Z status: closed -->
## Closed
---
id: 20260529-205540-spawnpod-profile-tool-description
slug: spawnpod-profile-tool-description
title: SpawnPod profile selection and templated tool description
status: closed
kind: feature
priority: P2
labels: [pod, manifest, tools, workflow]
created_at: 2026-05-29T20:55:40Z
updated_at: 2026-05-30T05:19:46Z
assignee: null
legacy_ticket: null
---
## Background
`SpawnPod` is becoming the main mechanism for hierarchical orchestration. The workflow model now distinguishes role-specific child Pods: lower orchestrators, coder Pods, and external reviewer Pods. These roles should be selectable by profile, but the current `SpawnPod` tool has no profile field and no LLM-visible route to discover profile selectors.
A separate `ListProfiles` tool would expose mostly static or semi-static affordance information as another model action. The desired design is instead to make profile choices part of the `SpawnPod` tool's own guidance: render the available profile selectors into the tool description, and repeat the same selector list in invalid/ambiguous/no-default diagnostics.
Current implementation notes:
- Tool metadata is defined by `llm_worker::tool::ToolMeta`; `ToolDefinition` factories return `(ToolMeta, Arc<dyn Tool>)` and `ToolServerHandle::flush_pending()` materializes them before request construction.
- Tool descriptions are currently hard-coded strings/doc comments in each tool factory. `crates/pod/src/spawn/tool.rs` has a static `DESCRIPTION` and `SpawnPodInput` only contains `name`, `instruction`, `task`, and `scope`.
- `SpawnPod` currently builds an internal `PodManifestConfig` directly from selected pieces of the parent resolved manifest plus tool input, then launches the child with hidden `--spawn-config-json`. It copies the parent model and session trace flag, applies optional instruction override, and uses the delegated scope from the tool call. It does not use profile resolution and it does not copy the parent resolved manifest wholesale.
- Prompt assets are centralized under `resources/prompts`; Pod-owned prompt injection strings are represented by `PodPrompt` and rendered by `PromptCatalog` through minijinja. `resources/prompts/internal.toml` has build-time coverage against `PodPrompt` variants.
- Profile discovery/resolution already exists in `manifest`, though the concrete profile authoring layer is being revised away from Nix-primary semantics. SpawnPod profile selection should use the same effective profile registry/default semantics as the normal launcher path once that resolver is available.
## Requirements
- Add an optional `profile` field to `SpawnPodInput`.
- `SpawnPod.profile` accepts three conceptual selector classes:
- omitted or `"default"`: resolve the effective child default profile;
- `"inherit"`: derive child config from the spawner's resolved Manifest, extracting only reusable profile-like fields;
- `<slug>` / source-qualified selectors such as `builtin:<slug>`, `user:<slug>`, `project:<slug>`: resolve a discovered role profile.
- `inherit` is distinct from reusing the profile source that created the parent. It means extracting reusable configuration from the parent resolved Manifest. Re-evaluating or reusing the parent's original Profile source is a separate concept and is not required here.
- Make `SpawnPod`'s LLM-facing tool description include the currently discoverable profile selectors, the effective default profile, and the special `inherit` selector.
- Do not add a separate `ListProfiles` tool for this feature.
- The profile list in the tool description must come from the same builtin/user/project profile discovery rules used by the profile launcher path.
- If profile discovery fails, the tool should still be registered with a clear diagnostic in its description rather than making Pod startup fail solely because the description could not list profiles.
- If `profile` is omitted, `SpawnPod` resolves the effective default profile. With the builtin default profile present, ordinary omission should keep working.
- If `profile` is invalid, ambiguous, unsupported, or omitted while no default can be resolved, the tool error must include a compact available-profile list, source-qualified suggestions, and mention `inherit` where appropriate.
- `SpawnPod.profile` should initially accept registry/default selectors and `inherit` only: `default`, `inherit`, `builtin:<name>`, `user:<name>`, `project:<name>`, and unqualified names only when unambiguous. Raw path selectors, `path:<path>`, relative paths, absolute paths, and `.nix`/`.lua` path-like values are rejected for the tool path.
- `SpawnPod.scope` remains the only delegated capability. A profile selected through `SpawnPod`, including `inherit`, must not be able to expand `scope.allow` beyond the explicit tool argument.
- `SpawnPod.name` always overrides any resolved/derived `pod.name` in the child manifest.
- `SpawnPod.instruction`, if present, is a typed override for the selected profile's or inherited config's `worker.instruction` only; it must not replace the whole profile/config.
- Profile-selected spawn should preserve profile-owned role configuration such as model, worker settings, tools/memory/web policy, and prompt pack where applicable, subject to the explicit `name` and `scope` overrides.
- `inherit` should preserve reusable parent-owned configuration such as model, worker settings, compaction, tools/memory/web policy, prompt pack, and session diagnostics where applicable, subject to the explicit `name`, `scope`, and optional `instruction` overrides.
- `inherit` must not preserve runtime-bound or authority-bearing parent fields: parent `pod.name`, concrete parent `scope.allow`/`scope.deny`, resolved runtime paths, sockets, session/pod-store state, spawned-child registry state, callback addresses, or raw resolved secret material.
- Existing hidden `--spawn-config-json` remains the internal launch handoff. `SpawnPod` resolves/merges selected profile data in the parent process and passes the resulting manifest config/snapshot to the child; it should not simply exec `insomnia-pod --profile`.
- Documentation/workflows should show `SpawnPod(profile = "project:coder")`, `SpawnPod(profile = "project:reviewer")`, optionally `project:orchestrator`, and `SpawnPod(profile = "inherit")` as explicit choices while keeping scope authority separate from profile role selection.
## Tool description templating direction
Use the existing prompt infrastructure rather than scattering another large hard-coded string.
Acceptable implementation shape:
- Add a Pod-owned prompt/catalog entry for the `SpawnPod` tool description, e.g. `spawn_pod_tool_description`, with minijinja variables such as `available_profiles`, `default_profile`, `special_selectors`, and `profile_diagnostic`.
- Render this prompt when registering `SpawnPod` in `register_pod_tools`, using the Pod cwd as the profile discovery base.
- Keep the rendered description as `ToolMeta.description`; the tool metadata still remains session-scoped after registration.
- The same formatter used for the description should be reusable by error diagnostics so invalid profile errors repeat the available selectors.
If a more general tool-description catalog is introduced, keep the initial scope narrow: it must support `SpawnPod` without forcing every built-in tool to migrate in the same ticket.
## Acceptance criteria
- `SpawnPod` schema exposes optional `profile` with clear field docs for `default`, `inherit`, and profile slug/source-qualified selectors.
- `SpawnPod` tool description includes a compact available-profile block, default-profile guidance, and the `inherit` special selector.
- `SpawnPod` invalid/ambiguous/no-default profile errors include the same compact selector list and tell the model to use a listed selector or `inherit`.
- `SpawnPod(profile = "project:reviewer")` resolves the project reviewer profile and applies its role config while replacing `pod.name` and `scope.allow` with the explicit `SpawnPod` values.
- `SpawnPod` with omitted profile resolves the effective default profile.
- `SpawnPod(profile = "inherit")` derives child config from the parent resolved Manifest's reusable fields while replacing `pod.name`, `scope.allow`, and optional `worker.instruction` with the explicit SpawnPod values.
- `SpawnPod(profile = "./reviewer.lua")`, `SpawnPod(profile = "path:./reviewer.lua")`, legacy `.nix` path-like selectors, and absolute profile paths are rejected with an explanation that the tool accepts `default`, `inherit`, or registry selectors only.
- Ambiguous unqualified profile names fail closed with source-qualified suggestions.
- A profile's or inherited config's `scope.allow` cannot grant access not present in `SpawnPod.scope`.
- Existing `SpawnPod` behavior that matters for lifecycle remains intact: registry reservation/rollback, scope revocation from the spawner, callback wiring, child socket wait, and initial `Method::Run` confirmation.
- Unit/integration tests cover description rendering, selector formatting, omitted default resolution, `inherit`, invalid selector diagnostics, profile config application, explicit instruction override, and scope authority replacement.
- Documentation and `.insomnia/workflow/` references explain profile-based coder/reviewer/orchestrator spawning without introducing a `ListProfiles` tool.
## Non-goals
- Do not introduce an LLM-callable `ListProfiles` tool.
- Do not enable arbitrary profile path evaluation through `SpawnPod`.
- Do not confuse `inherit` with reusing the parent's original Profile source. `inherit` is derived from the parent resolved Manifest; parent Profile source reuse can be a later explicit feature if needed.
- Do not revive manifest cascade or generic overlay layers.
- Do not redesign prompt-loader source selection for `$user` / `$workspace` profile prompt refs in this ticket unless it is required to keep current behavior correct.
- Do not implement encrypted secret storage; profiles may still contain unresolved typed secret refs as currently documented.
---

View File

@ -1,99 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-29T20:55:40Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-05-30T02:53:19Z -->
## Decision
Clarified selector semantics:
- `default` / omitted means resolve the effective child default profile.
- `<slug>` / source-qualified selectors mean resolve a discovered role profile.
- `inherit` means derive reusable child config from the spawner's resolved Manifest.
`inherit` is explicitly not the same as reusing the Profile source that created the parent. It extracts reusable fields from the parent resolved Manifest and still replaces runtime-bound/authority fields such as `pod.name` and concrete `scope.allow` with the SpawnPod inputs. Reusing the parent's original Profile source can be considered later as a separate feature if needed.
---
<!-- event: plan author: hare at: 2026-05-30T04:54:02Z -->
## Plan
## Preflight implementation plan
Classification: implementation-ready.
No product/API decision is needed before coding. The ticket already fixes the important semantics: omitted/default uses the effective child default profile, `inherit` derives reusable config from the spawner's resolved Manifest, named/source-qualified selectors resolve discovered profiles, path selectors are rejected, and `SpawnPod.scope` remains the only delegated capability.
Important implementation notes:
- Do not rely on process `current_dir()` for SpawnPod profile discovery. Use the Pod cwd (`spawner_pwd`) explicitly by adding/exposing a resolver helper that resolves from a registry discovered for that cwd.
- Resolve profiles and build child config before pod-registry reservation where possible, so invalid profile selectors do not mutate registry/scope.
- `inherit` means derive from the parent resolved Manifest, not from the parent's original Profile source.
- Path-like values, `path:<...>`, `.lua`/legacy suffix selectors, and absolute/relative paths must fail closed in `SpawnPod.profile`.
- Existing hidden `--spawn-config-json` remains the internal handoff; do not exec child with `--profile`.
- Existing prompt-loader source limitations are out of scope; preserve current behavior.
Current code map:
- `crates/pod/src/spawn/tool.rs`: `SpawnPodInput`, static description, spawn lifecycle, `build_spawn_config_json`.
- `crates/pod/src/controller.rs`: `register_pod_tools`, currently snapshots parent model/trace and registers spawn tools.
- `crates/manifest/src/profile.rs`: `ProfileDiscovery`, `ProfileRegistry`, `ProfileSelector`, `ProfileResolver`.
- `crates/manifest/src/config.rs`: `PodManifestConfig`, merge/resolve/defaults.
- `crates/pod/src/main.rs`: hidden `--spawn-config-json` loading takes precedence and uses builtins-only prompt loader.
- `crates/pod/src/prompt/catalog.rs` and `resources/prompts/internal.toml`: central prompt catalog for templated tool description.
Implementation phases:
1. Add manifest profile resolver helper for registry/cwd-explicit selection.
2. Add `SpawnPodInput.profile` and a SpawnPod-specific selector parser for `default`, `inherit`, and registry selectors only.
3. Add shared available-profile formatter for tool description and error diagnostics.
4. Move SpawnPod tool description into prompt catalog/minijinja and render it during tool registration; discovery failures should render diagnostics, not fail Pod startup.
5. Build child `PodManifestConfig` from selected profile Manifest or inherited parent Manifest, replacing `pod.name`, replacing `scope.allow`, clearing `scope.deny`, and optionally overriding only `worker.instruction`.
6. Preserve existing lifecycle: registry reservation/rollback, scope revocation, spawned registry write, callback wiring, child socket wait, initial `Method::Run` confirmation.
7. Update docs/workflows with `project:coder`, `project:reviewer`, optional `project:orchestrator`, and `inherit` examples.
Critical risks:
- Do not merge profile/inherited scope with explicit SpawnPod scope; explicit scope replaces capability.
- Do not call CLI-style profile parser in a way that allows path profiles through SpawnPod.
- Description and diagnostic profile lists should share formatting.
- Prompt catalog key coverage is build-time enforced.
Validation plan:
- Unit tests for selector parsing, formatter, config builder override/replacement behavior.
- Manifest tests for cwd/registry-explicit resolver helper.
- Prompt catalog rendering test.
- SpawnPod integration tests for omitted default, inherit, project profile, invalid selector pre-reservation failure, ambiguity suggestions, and scope replacement.
- Run `cargo test -p manifest profile`, `cargo test -p pod spawn_pod`, relevant prompt catalog tests, `cargo fmt --check`, and `./tickets.sh doctor`.
---
<!-- event: review author: hare at: 2026-05-30T05:11:43Z status: request_changes -->
## Review: request changes
Request changes.
The implementation direction appears sound, and the reviewer did not find a concrete authority-expansion or lifecycle regression. However, the work item acceptance criteria require focused tests for the new SpawnPod profile semantics, and the submitted tests mostly cover selector parsing plus existing lifecycle tests forced to `profile = "inherit"`.
Required fixes:
- Add SpawnPod default-profile coverage proving omitted `profile` resolves the effective registry default.
- Add a source-qualified profile coverage case, e.g. `project:reviewer`, proving role config from the selected profile reaches the generated `--spawn-config-json`.
- Add `inherit` config coverage proving reusable parent fields are copied while `pod.name`, `scope.allow`, and `scope.deny` are replaced.
- Add explicit `instruction` override coverage proving only `worker.instruction` changes.
- Add invalid / ambiguous / no-default diagnostics coverage proving the available-selector block appears.
- Add profile scope replacement coverage proving profile/inherited scope cannot expand delegated scope.
Non-blocking follow-ups:
- Available profile list currently emits source-qualified selectors only; future refinement may mention unqualified names when unambiguous.
- Workflow examples can later be updated to use explicit `project:coder` / `project:reviewer` selectors.
Validation note:
- `cargo test -p pod spawn_profile --no-default-features` currently only proves parser behavior, not profile resolution or child config construction.
---