merge: spawnpod profile tool
This commit is contained in:
commit
4c189fb0da
|
|
@ -352,6 +352,24 @@ impl ProfileResolver {
|
|||
source,
|
||||
})?;
|
||||
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
|
||||
self.resolve_from_registry(selector, ®istry, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Resolve a registry/default selector against an already-discovered
|
||||
/// registry. Callers such as SpawnPod use this to bind discovery to the
|
||||
/// Pod's cwd instead of the process current directory.
|
||||
pub fn resolve_from_registry(
|
||||
&self,
|
||||
selector: &ProfileSelector,
|
||||
registry: &ProfileRegistry,
|
||||
options: ProfileResolveOptions,
|
||||
) -> Result<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,
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(®istry_path, registry_toml).unwrap();
|
||||
AvailableProfiles {
|
||||
registry: Some(
|
||||
ProfileDiscovery::with_sources(None, None, Some(registry_path))
|
||||
.discover()
|
||||
.unwrap(),
|
||||
),
|
||||
diagnostic: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn child_config_from_profile(
|
||||
spawner_manifest: &PodManifest,
|
||||
available: &AvailableProfiles,
|
||||
cwd: &Path,
|
||||
name: &str,
|
||||
instruction_override: Option<&str>,
|
||||
scope: &[ScopeRule],
|
||||
selector: SpawnProfileSelector,
|
||||
) -> PodManifestConfig {
|
||||
let json = build_spawn_config_json_for_profile(
|
||||
spawner_manifest,
|
||||
available,
|
||||
cwd,
|
||||
name,
|
||||
instruction_override,
|
||||
scope,
|
||||
selector,
|
||||
)
|
||||
.unwrap();
|
||||
serde_json::from_str(&json).unwrap()
|
||||
}
|
||||
|
||||
const CODER_PROFILE: &str = r#"
|
||||
local profile = require("insomnia.profile")
|
||||
local scope = require("insomnia.scope")
|
||||
return profile {
|
||||
slug = "coder",
|
||||
model = { scheme = "anthropic", model_id = "coder-model" },
|
||||
worker = { instruction = "$insomnia/coder", language = "Coderish", max_tokens = 2222 },
|
||||
scope = scope.workspace_write(),
|
||||
}
|
||||
"#;
|
||||
|
||||
const REVIEWER_PROFILE: &str = r#"
|
||||
local profile = require("insomnia.profile")
|
||||
local scope = require("insomnia.scope")
|
||||
return profile {
|
||||
slug = "reviewer",
|
||||
model = { scheme = "anthropic", model_id = "reviewer-model" },
|
||||
worker = { instruction = "$insomnia/reviewer", language = "Reviewerish", max_tokens = 3333 },
|
||||
scope = scope.workspace_write(),
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn spawn_config_inherits_inline_spawner_model() {
|
||||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 %}\
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user