feat: add SpawnPod profile selection
This commit is contained in:
parent
08397f3f3b
commit
e65c023d4f
|
|
@ -352,6 +352,24 @@ impl ProfileResolver {
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
|
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
|
||||||
|
self.resolve_from_registry(selector, ®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();
|
let entry = registry.select(selector)?.clone();
|
||||||
self.resolve_path(
|
self.resolve_path(
|
||||||
&entry.path,
|
&entry.path,
|
||||||
|
|
@ -365,6 +383,7 @@ impl ProfileResolver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_path(
|
fn resolve_path(
|
||||||
&self,
|
&self,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
|
|
||||||
|
|
@ -501,8 +501,8 @@ where
|
||||||
let memory_config = pod.manifest().memory.clone();
|
let memory_config = pod.manifest().memory.clone();
|
||||||
let web_config = pod.manifest().web.clone();
|
let web_config = pod.manifest().web.clone();
|
||||||
let spawner_name = pod.manifest().pod.name.clone();
|
let spawner_name = pod.manifest().pod.name.clone();
|
||||||
let spawner_model = pod.manifest().model.clone();
|
let spawner_manifest = pod.manifest().clone();
|
||||||
let spawner_record_event_trace = pod.manifest().session.record_event_trace;
|
let prompts = pod.prompts().clone();
|
||||||
let pod_store = pod.store().clone();
|
let pod_store = pod.store().clone();
|
||||||
let self_parent_socket = pod.callback_socket().cloned();
|
let self_parent_socket = pod.callback_socket().cloned();
|
||||||
|
|
||||||
|
|
@ -556,9 +556,9 @@ where
|
||||||
pwd.clone(),
|
pwd.clone(),
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
self_parent_socket,
|
self_parent_socket,
|
||||||
spawner_model,
|
spawner_manifest,
|
||||||
spawner_record_event_trace,
|
|
||||||
scope_handle,
|
scope_handle,
|
||||||
|
prompts,
|
||||||
));
|
));
|
||||||
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
|
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
|
||||||
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
|
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,9 @@ pub enum PodPrompt {
|
||||||
/// knowledge when Workflow resident injection is enabled and at least one
|
/// knowledge when Workflow resident injection is enabled and at least one
|
||||||
/// workflow advertises `model_invokation: true`.
|
/// workflow advertises `model_invokation: true`.
|
||||||
ResidentWorkflowsSection,
|
ResidentWorkflowsSection,
|
||||||
|
/// LLM-facing description for the SpawnPod tool, including discovered
|
||||||
|
/// profile selectors.
|
||||||
|
SpawnPodToolDescription,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodPrompt {
|
impl PodPrompt {
|
||||||
|
|
@ -108,6 +111,7 @@ impl PodPrompt {
|
||||||
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
|
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
|
||||||
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
||||||
Self::ResidentWorkflowsSection => "resident_workflows_section",
|
Self::ResidentWorkflowsSection => "resident_workflows_section",
|
||||||
|
Self::SpawnPodToolDescription => "spawn_pod_tool_description",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +130,7 @@ impl PodPrompt {
|
||||||
PodPrompt::ResidentMemorySummarySection,
|
PodPrompt::ResidentMemorySummarySection,
|
||||||
PodPrompt::ResidentKnowledgeSection,
|
PodPrompt::ResidentKnowledgeSection,
|
||||||
PodPrompt::ResidentWorkflowsSection,
|
PodPrompt::ResidentWorkflowsSection,
|
||||||
|
PodPrompt::SpawnPodToolDescription,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const KEYS: &'static [&'static str] = &[
|
pub const KEYS: &'static [&'static str] = &[
|
||||||
|
|
@ -140,6 +145,7 @@ impl PodPrompt {
|
||||||
"resident_memory_summary_section",
|
"resident_memory_summary_section",
|
||||||
"resident_knowledge_section",
|
"resident_knowledge_section",
|
||||||
"resident_workflows_section",
|
"resident_workflows_section",
|
||||||
|
"spawn_pod_tool_description",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,6 +391,21 @@ impl PromptCatalog {
|
||||||
single("entries", entries),
|
single("entries", entries),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render `PodPrompt::SpawnPodToolDescription`.
|
||||||
|
pub fn spawn_pod_tool_description(
|
||||||
|
&self,
|
||||||
|
available_profiles: &str,
|
||||||
|
default_profile: &str,
|
||||||
|
profile_diagnostic: &str,
|
||||||
|
) -> Result<String, CatalogError> {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
|
||||||
|
m.insert("available_profiles", Value::from(available_profiles));
|
||||||
|
m.insert("default_profile", Value::from(default_profile));
|
||||||
|
m.insert("profile_diagnostic", Value::from(profile_diagnostic));
|
||||||
|
self.render(PodPrompt::SpawnPodToolDescription, Value::from(m))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn single(key: &'static str, value: &str) -> Value {
|
fn single(key: &'static str, value: &str) -> Value {
|
||||||
|
|
@ -682,4 +703,20 @@ compact_system = "PREFIX\n{% include \"$insomnia/internal/compact_system\" %}"
|
||||||
assert!(rendered.starts_with("PREFIX\n"));
|
assert!(rendered.starts_with("PREFIX\n"));
|
||||||
assert!(rendered.contains("write_summary"));
|
assert!(rendered.contains("write_summary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_pod_tool_description_renders_profile_block() {
|
||||||
|
let cat = PromptCatalog::builtins_only().unwrap();
|
||||||
|
let rendered = cat
|
||||||
|
.spawn_pod_tool_description(
|
||||||
|
"- `project:coder` — Coder\n- `project:reviewer` — Reviewer",
|
||||||
|
"project:coder",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(rendered.contains("Profile selection"));
|
||||||
|
assert!(rendered.contains("Default profile: project:coder"));
|
||||||
|
assert!(rendered.contains("`project:reviewer`"));
|
||||||
|
assert!(rendered.contains("Special selector: inherit"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ use std::time::Duration;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use manifest::{
|
use manifest::{
|
||||||
ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule,
|
CompactionConfigPartial, FileUploadLimitsPartial, Permission, PermissionConfigPartial,
|
||||||
SessionConfigPartial, SharedScope, WorkerManifestConfig,
|
PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery, ProfileError, ProfileRegistry,
|
||||||
|
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
|
||||||
|
ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
|
|
@ -23,20 +25,13 @@ use tokio::process::Command;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use crate::ipc::event;
|
use crate::ipc::event;
|
||||||
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
use crate::runtime::dir::SpawnedPodRecord;
|
use crate::runtime::dir::SpawnedPodRecord;
|
||||||
use crate::runtime::pod_registry::{self, LockFileGuard, ScopeLockError};
|
use crate::runtime::pod_registry::{self, LockFileGuard, ScopeLockError};
|
||||||
use crate::spawn::comm_tools::{SendRunError, send_run_and_confirm};
|
use crate::spawn::comm_tools::{SendRunError, send_run_and_confirm};
|
||||||
use crate::spawn::registry::SpawnedPodRegistry;
|
use crate::spawn::registry::SpawnedPodRegistry;
|
||||||
use protocol::PodEvent;
|
use protocol::PodEvent;
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Spawn a new Pod process to work on a delegated task. \
|
|
||||||
The spawner's write scope is reduced by the scope passed here; the spawned \
|
|
||||||
Pod receives its own socket and starts running `task` immediately. The \
|
|
||||||
spawned Pod outlives the spawner's current turn and can be contacted again \
|
|
||||||
through its socket path.";
|
|
||||||
|
|
||||||
const DEFAULT_INSTRUCTION: &str = "$insomnia/default";
|
|
||||||
|
|
||||||
/// How long we will wait for the spawned Pod's socket to become
|
/// How long we will wait for the spawned Pod's socket to become
|
||||||
/// connectable before treating the spawn as failed.
|
/// connectable before treating the spawn as failed.
|
||||||
const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
|
const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
@ -45,6 +40,13 @@ const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
struct SpawnPodInput {
|
struct SpawnPodInput {
|
||||||
/// Identifier for the spawned Pod. Must be unique machine-wide.
|
/// Identifier for the spawned Pod. Must be unique machine-wide.
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Profile selector for child role configuration. Omit or use `default`
|
||||||
|
/// for the effective child default profile, use `inherit` to derive
|
||||||
|
/// reusable config from the spawner, or use a registry selector such as
|
||||||
|
/// `project:coder`, `project:reviewer`, `builtin:default`, or an
|
||||||
|
/// unambiguous profile slug. Raw/path selectors are rejected.
|
||||||
|
#[serde(default)]
|
||||||
|
profile: Option<String>,
|
||||||
/// Instruction-file reference (e.g. `$insomnia/default`, `$user/my-agent`).
|
/// Instruction-file reference (e.g. `$insomnia/default`, `$user/my-agent`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
instruction: Option<String>,
|
instruction: Option<String>,
|
||||||
|
|
@ -87,6 +89,120 @@ impl From<PermissionInput> for Permission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct AvailableProfiles {
|
||||||
|
registry: Option<ProfileRegistry>,
|
||||||
|
diagnostic: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AvailableProfiles {
|
||||||
|
fn discover(cwd: &Path) -> Self {
|
||||||
|
match ProfileDiscovery::for_cwd(cwd).discover() {
|
||||||
|
Ok(registry) => Self {
|
||||||
|
registry: Some(registry),
|
||||||
|
diagnostic: None,
|
||||||
|
},
|
||||||
|
Err(error) => Self {
|
||||||
|
registry: None,
|
||||||
|
diagnostic: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_list(&self) -> String {
|
||||||
|
let Some(registry) = &self.registry else {
|
||||||
|
return "- profile discovery failed; use `inherit` or retry after fixing discovery"
|
||||||
|
.into();
|
||||||
|
};
|
||||||
|
if registry.entries().is_empty() {
|
||||||
|
return "- no registry profiles discovered; `inherit` is still available".into();
|
||||||
|
}
|
||||||
|
registry
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let default = if entry.is_default { " (default)" } else { "" };
|
||||||
|
let desc = entry
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.map(|d| format!(" — {d}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("- `{}`{}{}", entry.qualified_name(), default, desc)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_label(&self) -> String {
|
||||||
|
self.registry
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|registry| registry.default_entry().ok())
|
||||||
|
.map(|entry| entry.qualified_name())
|
||||||
|
.unwrap_or_else(|| "none resolved".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic(&self) -> &str {
|
||||||
|
self.diagnostic.as_deref().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_suffix(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"\nUse `default`, `inherit`, or one of these registry selectors:\n{}",
|
||||||
|
self.compact_list()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum SpawnProfileSelector {
|
||||||
|
Default,
|
||||||
|
Inherit,
|
||||||
|
Registry(ProfileSelector),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelector, String> {
|
||||||
|
let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||||
|
return Ok(SpawnProfileSelector::Default);
|
||||||
|
};
|
||||||
|
if raw == "default" {
|
||||||
|
return Ok(SpawnProfileSelector::Default);
|
||||||
|
}
|
||||||
|
if raw == "inherit" {
|
||||||
|
return Ok(SpawnProfileSelector::Inherit);
|
||||||
|
}
|
||||||
|
if raw.starts_with("path:")
|
||||||
|
|| raw.starts_with('/')
|
||||||
|
|| raw.starts_with("./")
|
||||||
|
|| raw.starts_with("../")
|
||||||
|
|| raw.contains('/')
|
||||||
|
|| raw.ends_with(".lua")
|
||||||
|
|| raw.ends_with(".nix")
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"SpawnPod.profile accepts `default`, `inherit`, or registry selectors only; path-like selector `{raw}` is not allowed"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some((prefix, name)) = raw.split_once(':') {
|
||||||
|
let source = match prefix {
|
||||||
|
"builtin" => ProfileRegistrySource::Builtin,
|
||||||
|
"user" => ProfileRegistrySource::User,
|
||||||
|
"project" => ProfileRegistrySource::Project,
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported SpawnPod.profile selector prefix `{prefix}`; use builtin:, user:, project:, default, or inherit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("SpawnPod.profile registry selector has an empty profile name".into());
|
||||||
|
}
|
||||||
|
return Ok(SpawnProfileSelector::Registry(
|
||||||
|
ProfileSelector::source_named(source, name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(SpawnProfileSelector::Registry(ProfileSelector::named(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Runtime dependencies the `SpawnPod` tool needs in order to launch a
|
/// Runtime dependencies the `SpawnPod` tool needs in order to launch a
|
||||||
/// child Pod and record the handoff locally. Constructed by the Pod
|
/// child Pod and record the handoff locally. Constructed by the Pod
|
||||||
/// controller once per Pod lifetime.
|
/// controller once per Pod lifetime.
|
||||||
|
|
@ -114,14 +230,12 @@ pub struct SpawnPodTool {
|
||||||
/// `None` for top-level Pods — in that case the re-emission is a
|
/// `None` for top-level Pods — in that case the re-emission is a
|
||||||
/// no-op.
|
/// no-op.
|
||||||
parent_socket: Option<PathBuf>,
|
parent_socket: Option<PathBuf>,
|
||||||
/// Spawner's resolved provider config — copied into every spawned
|
/// Spawner's resolved Manifest. `profile = "inherit"` derives the
|
||||||
/// Pod's internal manifest config so the child does not need its own provider
|
/// child config from reusable fields here, and selected profiles are
|
||||||
/// configuration. Per-spawn override is
|
/// merged into the same internal handoff shape before launch.
|
||||||
/// out of scope here (see `tickets/spawn-inherit-provider.md`).
|
spawner_manifest: PodManifest,
|
||||||
spawner_model: ModelManifest,
|
/// Compact selector list shared by tool description and diagnostics.
|
||||||
/// Spawner's session diagnostics policy. Preserved for spawned Pods so
|
available_profiles: AvailableProfiles,
|
||||||
/// opt-in provider event traces continue across delegation.
|
|
||||||
spawner_record_event_trace: bool,
|
|
||||||
/// Spawner's runtime scope. After a successful spawn, the
|
/// Spawner's runtime scope. After a successful spawn, the
|
||||||
/// `Permission::Write` rules in the delegated scope are revoked
|
/// `Permission::Write` rules in the delegated scope are revoked
|
||||||
/// from the spawner's in-memory view (a `deny(Write, target)` is
|
/// from the spawner's in-memory view (a `deny(Write, target)` is
|
||||||
|
|
@ -133,15 +247,15 @@ pub struct SpawnPodTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnPodTool {
|
impl SpawnPodTool {
|
||||||
pub fn new(
|
fn new(
|
||||||
spawner_name: String,
|
spawner_name: String,
|
||||||
callback_socket: PathBuf,
|
callback_socket: PathBuf,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
spawner_pwd: PathBuf,
|
spawner_pwd: PathBuf,
|
||||||
registry: Arc<SpawnedPodRegistry>,
|
registry: Arc<SpawnedPodRegistry>,
|
||||||
parent_socket: Option<PathBuf>,
|
parent_socket: Option<PathBuf>,
|
||||||
spawner_model: ModelManifest,
|
spawner_manifest: PodManifest,
|
||||||
spawner_record_event_trace: bool,
|
available_profiles: AvailableProfiles,
|
||||||
spawner_scope: SharedScope,
|
spawner_scope: SharedScope,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -151,8 +265,8 @@ impl SpawnPodTool {
|
||||||
spawner_pwd,
|
spawner_pwd,
|
||||||
registry,
|
registry,
|
||||||
parent_socket,
|
parent_socket,
|
||||||
spawner_model,
|
spawner_manifest,
|
||||||
spawner_record_event_trace,
|
available_profiles,
|
||||||
spawner_scope,
|
spawner_scope,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -176,10 +290,21 @@ impl Tool for SpawnPodTool {
|
||||||
|
|
||||||
let scope_allow = parse_scope(&input.scope)?;
|
let scope_allow = parse_scope(&input.scope)?;
|
||||||
|
|
||||||
let instruction = input
|
let spawn_selector =
|
||||||
.instruction
|
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
|
||||||
.clone()
|
ToolError::InvalidArgument(format!(
|
||||||
.unwrap_or_else(|| DEFAULT_INSTRUCTION.to_string());
|
"{msg}{}",
|
||||||
|
self.available_profiles.error_suffix()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let spawn_config_json = self
|
||||||
|
.build_spawn_config_json(
|
||||||
|
&input.name,
|
||||||
|
input.instruction.as_deref(),
|
||||||
|
&scope_allow,
|
||||||
|
spawn_selector,
|
||||||
|
)
|
||||||
|
.map_err(|e| ToolError::InvalidArgument(format!("{e}")))?;
|
||||||
|
|
||||||
let predicted_socket = self.runtime_base.join(&input.name).join("sock");
|
let predicted_socket = self.runtime_base.join(&input.name).join("sock");
|
||||||
let lock_path = pod_registry::default_registry_path()
|
let lock_path = pod_registry::default_registry_path()
|
||||||
|
|
@ -207,21 +332,6 @@ impl Tool for SpawnPodTool {
|
||||||
// it back — even if later steps (Method::Run delivery, record
|
// it back — even if later steps (Method::Run delivery, record
|
||||||
// write) fail, the child is running and will release its own
|
// write) fail, the child is running and will release its own
|
||||||
// entry on exit.
|
// entry on exit.
|
||||||
let spawn_config_json = match build_spawn_config_json(
|
|
||||||
&input.name,
|
|
||||||
&instruction,
|
|
||||||
&scope_allow,
|
|
||||||
&self.spawner_model,
|
|
||||||
self.spawner_record_event_trace,
|
|
||||||
) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
self.release_reservation(&lock_path, &input.name);
|
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
|
||||||
"spawn config serialisation: {e}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let start_outcome = self
|
let start_outcome = self
|
||||||
.exec_child(&input.name, &spawn_config_json, &predicted_socket)
|
.exec_child(&input.name, &spawn_config_json, &predicted_socket)
|
||||||
|
|
@ -385,11 +495,60 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
|
||||||
/// The child's working directory is set separately via
|
/// The child's working directory is set separately via
|
||||||
/// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is
|
/// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is
|
||||||
/// not part of the manifest.
|
/// not part of the manifest.
|
||||||
|
impl SpawnPodTool {
|
||||||
|
fn build_spawn_config_json(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
instruction_override: Option<&str>,
|
||||||
|
scope_allow: &[ScopeRule],
|
||||||
|
selector: SpawnProfileSelector,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut config = match selector {
|
||||||
|
SpawnProfileSelector::Inherit => manifest_to_reusable_config(&self.spawner_manifest),
|
||||||
|
SpawnProfileSelector::Default | SpawnProfileSelector::Registry(_) => {
|
||||||
|
let registry = self.available_profiles.registry.as_ref().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"profile discovery failed for SpawnPod: {}{}",
|
||||||
|
self.available_profiles
|
||||||
|
.diagnostic()
|
||||||
|
.if_empty("unknown error"),
|
||||||
|
self.available_profiles.error_suffix()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let profile_selector = match selector {
|
||||||
|
SpawnProfileSelector::Default => ProfileSelector::Default,
|
||||||
|
SpawnProfileSelector::Registry(selector) => selector,
|
||||||
|
SpawnProfileSelector::Inherit => unreachable!(),
|
||||||
|
};
|
||||||
|
let resolved = ProfileResolver::new()
|
||||||
|
.with_workspace_base(&self.spawner_pwd)
|
||||||
|
.resolve_from_registry(
|
||||||
|
&profile_selector,
|
||||||
|
registry,
|
||||||
|
ProfileResolveOptions::with_pod_name(name),
|
||||||
|
)
|
||||||
|
.map_err(|e| profile_error_with_available(e, &self.available_profiles))?;
|
||||||
|
manifest_to_reusable_config(&resolved.manifest)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
config.pod.name = Some(name.to_string());
|
||||||
|
config.scope = ScopeConfig {
|
||||||
|
allow: scope_allow.to_vec(),
|
||||||
|
deny: Vec::new(),
|
||||||
|
};
|
||||||
|
if let Some(instruction) = instruction_override {
|
||||||
|
config.worker.instruction = Some(instruction.to_string());
|
||||||
|
}
|
||||||
|
serde_json::to_string(&config).map_err(|e| format!("spawn config serialisation: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn build_spawn_config_json(
|
fn build_spawn_config_json(
|
||||||
name: &str,
|
name: &str,
|
||||||
instruction: &str,
|
instruction: &str,
|
||||||
scope_allow: &[ScopeRule],
|
scope_allow: &[ScopeRule],
|
||||||
model: &ModelManifest,
|
model: &manifest::ModelManifest,
|
||||||
record_event_trace: bool,
|
record_event_trace: bool,
|
||||||
) -> Result<String, serde_json::Error> {
|
) -> Result<String, serde_json::Error> {
|
||||||
let config = PodManifestConfig {
|
let config = PodManifestConfig {
|
||||||
|
|
@ -414,6 +573,94 @@ fn build_spawn_config_json(
|
||||||
serde_json::to_string(&config)
|
serde_json::to_string(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trait IfEmpty {
|
||||||
|
fn if_empty(&self, fallback: &str) -> String;
|
||||||
|
}
|
||||||
|
impl IfEmpty for str {
|
||||||
|
fn if_empty(&self, fallback: &str) -> String {
|
||||||
|
if self.is_empty() {
|
||||||
|
fallback.into()
|
||||||
|
} else {
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_error_with_available(error: ProfileError, available: &AvailableProfiles) -> String {
|
||||||
|
format!(
|
||||||
|
"invalid SpawnPod.profile: {error}{}",
|
||||||
|
available.error_suffix()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
|
||||||
|
PodManifestConfig {
|
||||||
|
pod: PodMetaConfig {
|
||||||
|
name: Some(manifest.pod.name.clone()),
|
||||||
|
prompt_pack: manifest.pod.prompt_pack.clone(),
|
||||||
|
},
|
||||||
|
model: manifest.model.clone(),
|
||||||
|
worker: WorkerManifestConfig {
|
||||||
|
instruction: Some(manifest.worker.instruction.clone()),
|
||||||
|
language: Some(manifest.worker.language.clone()),
|
||||||
|
max_tokens: manifest.worker.max_tokens,
|
||||||
|
max_turns: manifest.worker.max_turns,
|
||||||
|
temperature: manifest.worker.temperature,
|
||||||
|
top_p: manifest.worker.top_p,
|
||||||
|
top_k: manifest.worker.top_k,
|
||||||
|
stop_sequences: (!manifest.worker.stop_sequences.is_empty())
|
||||||
|
.then_some(manifest.worker.stop_sequences.clone()),
|
||||||
|
reasoning: manifest.worker.reasoning.clone(),
|
||||||
|
tool_output: ToolOutputLimitsPartial {
|
||||||
|
default_max_bytes: Some(manifest.worker.tool_output.default_max_bytes),
|
||||||
|
per_tool: manifest.worker.tool_output.per_tool.clone(),
|
||||||
|
},
|
||||||
|
file_upload: FileUploadLimitsPartial {
|
||||||
|
max_bytes: Some(manifest.worker.file_upload.max_bytes),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scope: ScopeConfig {
|
||||||
|
allow: manifest.scope.allow.clone(),
|
||||||
|
deny: manifest.scope.deny.clone(),
|
||||||
|
},
|
||||||
|
session: Some(SessionConfigPartial {
|
||||||
|
record_event_trace: Some(manifest.session.record_event_trace),
|
||||||
|
}),
|
||||||
|
permissions: manifest
|
||||||
|
.permissions
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| PermissionConfigPartial {
|
||||||
|
default_action: Some(p.default_action),
|
||||||
|
rules: p.rules.clone(),
|
||||||
|
}),
|
||||||
|
compaction: manifest
|
||||||
|
.compaction
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| CompactionConfigPartial {
|
||||||
|
prune_protected_tokens: Some(c.prune_protected_tokens),
|
||||||
|
prune_min_savings: Some(c.prune_min_savings),
|
||||||
|
threshold: c.threshold,
|
||||||
|
request_threshold: c.request_threshold,
|
||||||
|
retained_tokens: Some(c.retained_tokens),
|
||||||
|
overview_target_tokens: Some(c.overview_target_tokens),
|
||||||
|
overview_warning_tokens: Some(c.overview_warning_tokens),
|
||||||
|
overview_deadline_tokens: Some(c.overview_deadline_tokens),
|
||||||
|
worker_context_max_tokens: Some(c.worker_context_max_tokens),
|
||||||
|
finish_warning_remaining_tokens: Some(c.finish_warning_remaining_tokens),
|
||||||
|
final_reserve_tokens: Some(c.final_reserve_tokens),
|
||||||
|
worker_max_turns: c.worker_max_turns,
|
||||||
|
summary_target_tokens: Some(c.summary_target_tokens),
|
||||||
|
summary_max_tokens: Some(c.summary_max_tokens),
|
||||||
|
auto_read_budget_tokens: Some(c.auto_read_budget_tokens),
|
||||||
|
result_context_max_tokens: Some(c.result_context_max_tokens),
|
||||||
|
model: c.model.clone(),
|
||||||
|
}),
|
||||||
|
web: manifest.web.clone(),
|
||||||
|
memory: manifest.memory.clone(),
|
||||||
|
skills: manifest.skills.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tail of the spawned child's `stderr.log` to splice into a startup
|
/// Tail of the spawned child's `stderr.log` to splice into a startup
|
||||||
/// failure message. Capped so a chatty child can't blow up the LLM's
|
/// failure message. Capped so a chatty child can't blow up the LLM's
|
||||||
/// tool-result budget — debugging beyond this should read the file
|
/// tool-result budget — debugging beyond this should read the file
|
||||||
|
|
@ -493,15 +740,28 @@ pub fn spawn_pod_tool(
|
||||||
spawner_pwd: PathBuf,
|
spawner_pwd: PathBuf,
|
||||||
registry: Arc<SpawnedPodRegistry>,
|
registry: Arc<SpawnedPodRegistry>,
|
||||||
parent_socket: Option<PathBuf>,
|
parent_socket: Option<PathBuf>,
|
||||||
spawner_model: ModelManifest,
|
spawner_manifest: PodManifest,
|
||||||
spawner_record_event_trace: bool,
|
|
||||||
spawner_scope: SharedScope,
|
spawner_scope: SharedScope,
|
||||||
|
prompts: Arc<PromptCatalog>,
|
||||||
) -> ToolDefinition {
|
) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(SpawnPodInput);
|
let schema = schemars::schema_for!(SpawnPodInput);
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||||
|
let available_profiles = AvailableProfiles::discover(&spawner_pwd);
|
||||||
|
let description = prompts
|
||||||
|
.spawn_pod_tool_description(
|
||||||
|
&available_profiles.compact_list(),
|
||||||
|
&available_profiles.default_label(),
|
||||||
|
available_profiles.diagnostic(),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
format!(
|
||||||
|
"Spawn a new Pod process to work on a delegated task. Profile description rendering failed: {e}. Available profiles:\n{}",
|
||||||
|
available_profiles.compact_list()
|
||||||
|
)
|
||||||
|
});
|
||||||
let meta = ToolMeta::new("SpawnPod")
|
let meta = ToolMeta::new("SpawnPod")
|
||||||
.description(DESCRIPTION)
|
.description(description)
|
||||||
.input_schema(schema_value);
|
.input_schema(schema_value);
|
||||||
let tool: Arc<dyn Tool> = Arc::new(SpawnPodTool::new(
|
let tool: Arc<dyn Tool> = Arc::new(SpawnPodTool::new(
|
||||||
spawner_name.clone(),
|
spawner_name.clone(),
|
||||||
|
|
@ -510,8 +770,8 @@ pub fn spawn_pod_tool(
|
||||||
spawner_pwd.clone(),
|
spawner_pwd.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
parent_socket.clone(),
|
parent_socket.clone(),
|
||||||
spawner_model.clone(),
|
spawner_manifest.clone(),
|
||||||
spawner_record_event_trace,
|
available_profiles,
|
||||||
spawner_scope.clone(),
|
spawner_scope.clone(),
|
||||||
));
|
));
|
||||||
(meta, tool)
|
(meta, tool)
|
||||||
|
|
@ -521,7 +781,7 @@ pub fn spawn_pod_tool(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use manifest::{AuthRef, PodManifest, SchemeKind};
|
use manifest::{AuthRef, ModelManifest, PodManifest, SchemeKind};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_config_inherits_inline_spawner_model() {
|
fn spawn_config_inherits_inline_spawner_model() {
|
||||||
|
|
@ -607,4 +867,40 @@ mod tests {
|
||||||
|
|
||||||
assert!(parsed.session.is_none());
|
assert!(parsed.session.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_profile_selector_rejects_path_like_values() {
|
||||||
|
for raw in [
|
||||||
|
"./reviewer.lua",
|
||||||
|
"path:./reviewer.lua",
|
||||||
|
"/tmp/reviewer.lua",
|
||||||
|
"legacy.nix",
|
||||||
|
] {
|
||||||
|
let err = parse_spawn_profile_selector(Some(raw)).unwrap_err();
|
||||||
|
assert!(err.contains("registry selectors only"), "{raw}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_profile_selector_accepts_default_inherit_and_registry() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_spawn_profile_selector(None).unwrap(),
|
||||||
|
SpawnProfileSelector::Default
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_spawn_profile_selector(Some("inherit")).unwrap(),
|
||||||
|
SpawnProfileSelector::Inherit
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_spawn_profile_selector(Some("project:reviewer")).unwrap(),
|
||||||
|
SpawnProfileSelector::Registry(ProfileSelector::source_named(
|
||||||
|
ProfileRegistrySource::Project,
|
||||||
|
"reviewer"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_spawn_profile_selector(Some("coder")).unwrap(),
|
||||||
|
SpawnProfileSelector::Registry(ProfileSelector::named("coder"))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ use std::path::{Path, PathBuf};
|
||||||
use std::sync::{LazyLock, Mutex};
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
use llm_worker::tool::{ToolError, ToolOutput};
|
use llm_worker::tool::{ToolError, ToolOutput};
|
||||||
use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, Scope, ScopeRule, SharedScope};
|
use manifest::{
|
||||||
|
AuthRef, ModelManifest, Permission, PodManifest, PodManifestConfig, PodMetaConfig, SchemeKind,
|
||||||
|
Scope, ScopeConfig, ScopeRule, SharedScope,
|
||||||
|
};
|
||||||
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
||||||
use pod::runtime::pod_registry::{self, LockFileGuard};
|
use pod::runtime::pod_registry::{self, LockFileGuard};
|
||||||
use pod::spawn::registry::SpawnedPodRegistry;
|
use pod::spawn::registry::SpawnedPodRegistry;
|
||||||
|
|
@ -107,6 +110,25 @@ fn accept_one_method(listener: UnixListener) -> tokio::task::JoinHandle<Option<M
|
||||||
let (reader, writer) = stream.into_split();
|
let (reader, writer) = stream.into_split();
|
||||||
let mut r = JsonLineReader::new(reader);
|
let mut r = JsonLineReader::new(reader);
|
||||||
let mut w = JsonLineWriter::new(writer);
|
let mut w = JsonLineWriter::new(writer);
|
||||||
|
if w.write(&Event::Snapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
greeting: protocol::Greeting {
|
||||||
|
pod_name: "child".into(),
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
provider: "test".into(),
|
||||||
|
model: "test".into(),
|
||||||
|
scope_summary: String::new(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
context_window: 200_000,
|
||||||
|
context_tokens: 0,
|
||||||
|
},
|
||||||
|
status: protocol::PodStatus::Idle,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Ok(Some(method)) = r.next::<Method>().await {
|
if let Ok(Some(method)) = r.next::<Method>().await {
|
||||||
w.write(&Event::UserMessage {
|
w.write(&Event::UserMessage {
|
||||||
segments: vec![protocol::Segment::text("accepted")],
|
segments: vec![protocol::Segment::text("accepted")],
|
||||||
|
|
@ -155,6 +177,31 @@ fn dummy_model() -> ModelManifest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dummy_manifest(allow_root: &Path) -> PodManifest {
|
||||||
|
PodManifestConfig {
|
||||||
|
pod: PodMetaConfig {
|
||||||
|
name: Some("root".into()),
|
||||||
|
prompt_pack: None,
|
||||||
|
},
|
||||||
|
model: dummy_model(),
|
||||||
|
scope: ScopeConfig {
|
||||||
|
allow: vec![ScopeRule {
|
||||||
|
target: allow_root.to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
}],
|
||||||
|
deny: Vec::new(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.try_into()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn builtin_prompts() -> Arc<pod::PromptCatalog> {
|
||||||
|
pod::PromptCatalog::builtins_only().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawner-side `SharedScope` mirroring the `allow_root` granted by
|
/// Spawner-side `SharedScope` mirroring the `allow_root` granted by
|
||||||
/// `setup_spawner`. The tool revokes Write rules from this scope on
|
/// `setup_spawner`. The tool revokes Write rules from this scope on
|
||||||
/// successful spawn — tests can `load()` it to assert the
|
/// successful spawn — tests can `load()` it to assert the
|
||||||
|
|
@ -191,15 +238,16 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
allow_root.path().to_path_buf(),
|
allow_root.path().to_path_buf(),
|
||||||
registry,
|
registry,
|
||||||
None,
|
None,
|
||||||
dummy_model(),
|
dummy_manifest(allow_root.path()),
|
||||||
false,
|
|
||||||
spawner_scope.clone(),
|
spawner_scope.clone(),
|
||||||
|
builtin_prompts(),
|
||||||
);
|
);
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"name": "child",
|
"name": "child",
|
||||||
"task": "hello",
|
"task": "hello",
|
||||||
|
"profile": "inherit",
|
||||||
"scope": [{
|
"scope": [{
|
||||||
"target": allow_root.path().to_str().unwrap(),
|
"target": allow_root.path().to_str().unwrap(),
|
||||||
"permission": "write"
|
"permission": "write"
|
||||||
|
|
@ -280,9 +328,9 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
allow_root.path().to_path_buf(),
|
allow_root.path().to_path_buf(),
|
||||||
registry,
|
registry,
|
||||||
None,
|
None,
|
||||||
dummy_model(),
|
dummy_manifest(allow_root.path()),
|
||||||
false,
|
|
||||||
spawner_scope.clone(),
|
spawner_scope.clone(),
|
||||||
|
builtin_prompts(),
|
||||||
);
|
);
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
|
|
||||||
|
|
@ -290,6 +338,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"name": "child",
|
"name": "child",
|
||||||
"task": "nope",
|
"task": "nope",
|
||||||
|
"profile": "inherit",
|
||||||
"scope": [{
|
"scope": [{
|
||||||
"target": outside.path().to_str().unwrap(),
|
"target": outside.path().to_str().unwrap(),
|
||||||
"permission": "write"
|
"permission": "write"
|
||||||
|
|
@ -352,15 +401,16 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
|
||||||
allow_root.path().to_path_buf(),
|
allow_root.path().to_path_buf(),
|
||||||
registry,
|
registry,
|
||||||
None,
|
None,
|
||||||
dummy_model(),
|
dummy_manifest(allow_root.path()),
|
||||||
false,
|
|
||||||
spawner_scope.clone(),
|
spawner_scope.clone(),
|
||||||
|
builtin_prompts(),
|
||||||
);
|
);
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
"name": "ghost",
|
"name": "ghost",
|
||||||
"task": "will never be delivered",
|
"task": "will never be delivered",
|
||||||
|
"profile": "inherit",
|
||||||
"scope": [{
|
"scope": [{
|
||||||
"target": allow_root.path().to_str().unwrap(),
|
"target": allow_root.path().to_str().unwrap(),
|
||||||
"permission": "write"
|
"permission": "write"
|
||||||
|
|
|
||||||
|
|
@ -65,3 +65,16 @@ The following workflows are advertised resident. When a user request matches one
|
||||||
|
|
||||||
{{ entries }}\
|
{{ entries }}\
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
spawn_pod_tool_description = """\
|
||||||
|
Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path.
|
||||||
|
|
||||||
|
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Pod, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SpawnPod. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SpawnPod scope.
|
||||||
|
|
||||||
|
Default profile: {{ default_profile }}
|
||||||
|
Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing pod.name and scope.
|
||||||
|
Available registry profiles:
|
||||||
|
{{ available_profiles }}{% if profile_diagnostic %}
|
||||||
|
|
||||||
|
Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}\
|
||||||
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user