worker: separate subworkers from workspace workers
This commit is contained in:
@@ -8,9 +8,7 @@ use session_store::WorkerMetadataStore;
|
||||
use session_store::{LogEntry, Store};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
use crate::discovery::{
|
||||
WorkerDiscovery, list_workers_tool, restore_worker_tool, send_to_peer_worker_tool,
|
||||
};
|
||||
use crate::discovery::WorkerDiscovery;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
|
||||
use crate::ipc::alerter::Alerter;
|
||||
@@ -23,9 +21,11 @@ use crate::shutdown_after_idle::{
|
||||
ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role,
|
||||
take_shutdown_request_after_status,
|
||||
};
|
||||
use crate::spawn::comm_tools::{read_worker_output_tool, send_to_worker_tool, stop_worker_tool};
|
||||
use crate::spawn::comm_tools::{
|
||||
sub_worker_list_tool, sub_worker_read_output_tool, sub_worker_send_tool, sub_worker_stop_tool,
|
||||
};
|
||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||
use crate::spawn::tool::spawn_worker_tool;
|
||||
use crate::spawn::tool::sub_worker_spawn_tool;
|
||||
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
|
||||
@@ -582,7 +582,7 @@ fn wire_event_bridges_on_engine<C, St>(
|
||||
}
|
||||
|
||||
/// Register the builtin file-manipulation tools, optional memory tools,
|
||||
/// and the Worker-orchestration tools (SpawnWorker + comm) on the Worker's
|
||||
/// and the Worker-orchestration tools (SubWorkerSpawn + comm) on the Worker's
|
||||
/// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to
|
||||
/// the shared state.
|
||||
async fn register_worker_tools<C, St>(
|
||||
@@ -610,7 +610,6 @@ where
|
||||
let spawner_name = worker.manifest().worker.name.clone();
|
||||
let spawner_manifest = worker.manifest().clone();
|
||||
let prompts = worker.prompts().clone();
|
||||
let worker_metadata_store = worker.store().clone();
|
||||
let self_parent_socket = worker.callback_socket().cloned();
|
||||
|
||||
// Resolve the existing Worker–Workdir binding into the domain provider.
|
||||
@@ -684,6 +683,21 @@ where
|
||||
crate::feature::builtin::manage_workdir::manage_workdir_feature(workspace_client),
|
||||
);
|
||||
}
|
||||
if feature_config.worker.enabled {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let has_workspace_identity = workspace_client.workspace_id().is_some_and(|workspace_id| {
|
||||
!workspace_id.is_empty() && !workspace_id.chars().any(char::is_control)
|
||||
});
|
||||
if !workspace_client.is_available() || !has_workspace_identity {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Worker tools require Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::manage_worker::manage_worker_feature(workspace_client),
|
||||
);
|
||||
}
|
||||
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
|
||||
feature_config.plugins.enabled,
|
||||
&worker.manifest().plugins,
|
||||
@@ -698,7 +712,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
if feature_config.workers.enabled {
|
||||
if feature_config.sub_worker.enabled {
|
||||
worker.register_worker_orchestration_instruction();
|
||||
}
|
||||
|
||||
@@ -756,16 +770,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
|
||||
// Worker-orchestration tools (SubWorkerSpawn + the four comm tools) share
|
||||
// the Worker-scoped `SpawnedWorkerRegistry` (also consumed by the main
|
||||
// loop's `WorkerEvent` handler). Expose them only behind the explicit
|
||||
// profile feature and require delegation authority up front so enabling
|
||||
// the surface cannot imply broad child scope by accident.
|
||||
if feature_config.workers.enabled {
|
||||
if feature_config.sub_worker.enabled {
|
||||
if spawner_manifest.delegation_scope.allow.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"[feature.workers].enabled = true requires non-empty [[delegation_scope.allow]]",
|
||||
"[feature.sub_worker].enabled = true requires non-empty [[delegation_scope.allow]]",
|
||||
));
|
||||
}
|
||||
let spawner_cwd = local_filesystem
|
||||
@@ -783,7 +797,7 @@ where
|
||||
"worker spawn tools require local Worker filesystem authority",
|
||||
)
|
||||
})?;
|
||||
engine.register_tool(spawn_worker_tool(
|
||||
engine.register_tool(sub_worker_spawn_tool(
|
||||
spawner_name.clone(),
|
||||
spawner_socket,
|
||||
runtime_base.clone(),
|
||||
@@ -795,19 +809,10 @@ where
|
||||
scope_handle,
|
||||
prompts,
|
||||
));
|
||||
engine.register_tool(send_to_worker_tool(spawned_registry.clone()));
|
||||
engine.register_tool(read_worker_output_tool(spawned_registry.clone()));
|
||||
engine.register_tool(stop_worker_tool(spawned_registry.clone()));
|
||||
let discovery = WorkerDiscovery::new(
|
||||
worker_metadata_store,
|
||||
spawner_name,
|
||||
runtime_base,
|
||||
Some(spawner_cwd),
|
||||
spawned_registry,
|
||||
);
|
||||
engine.register_tool(list_workers_tool(discovery.clone()));
|
||||
engine.register_tool(restore_worker_tool(discovery.clone()));
|
||||
engine.register_tool(send_to_peer_worker_tool(discovery));
|
||||
engine.register_tool(sub_worker_list_tool(spawned_registry.clone()));
|
||||
engine.register_tool(sub_worker_send_tool(spawned_registry.clone()));
|
||||
engine.register_tool(sub_worker_read_output_tool(spawned_registry.clone()));
|
||||
engine.register_tool(sub_worker_stop_tool(spawned_registry));
|
||||
}
|
||||
}
|
||||
let _feature_install_report = worker.install_features(feature_registry);
|
||||
|
||||
@@ -56,7 +56,7 @@ struct Cli {
|
||||
|
||||
/// Claim a scope allocation pre-registered by a spawning Worker, rather
|
||||
/// than installing a new top-level allocation. Used only when this
|
||||
/// process is launched by `SpawnWorker`; end users should never pass it.
|
||||
/// process is launched by `SubWorkerSpawn`; end users should never pass it.
|
||||
#[arg(long)]
|
||||
adopt: bool,
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod manage_workdir;
|
||||
pub mod manage_worker;
|
||||
pub mod memory;
|
||||
pub mod objective;
|
||||
pub mod session_explore;
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Workspace-authority-backed Worker session management tools.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
const FEATURE_ID: &str = "worker";
|
||||
const FEATURE_NAME: &str = "Worker";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Workspace-authority tools for managing Workdir-bound Backend/Runtime Worker sessions.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManageWorkerFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
pub fn manage_worker_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkerFeature {
|
||||
ManageWorkerFeature { client }
|
||||
}
|
||||
|
||||
impl FeatureModule for ManageWorkerFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||
.with_description(FEATURE_DESCRIPTION);
|
||||
for operation in WorkerOperation::ALL {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||
operation.tool_name(),
|
||||
operation.description(),
|
||||
));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let workspace_id = self
|
||||
.client
|
||||
.workspace_id()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
FeatureInstallError::InvalidDescriptor(
|
||||
"worker feature requires a Workspace id".to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
for operation in WorkerOperation::ALL {
|
||||
let definition = match operation {
|
||||
WorkerOperation::List => definition::<WorkerListInput>(
|
||||
operation,
|
||||
self.client.clone(),
|
||||
workspace_id.clone(),
|
||||
),
|
||||
WorkerOperation::Spawn => definition::<WorkerSpawnInput>(
|
||||
operation,
|
||||
self.client.clone(),
|
||||
workspace_id.clone(),
|
||||
),
|
||||
WorkerOperation::Stop => definition::<WorkerStopInput>(
|
||||
operation,
|
||||
self.client.clone(),
|
||||
workspace_id.clone(),
|
||||
),
|
||||
WorkerOperation::Restore => definition::<WorkerTargetInput>(
|
||||
operation,
|
||||
self.client.clone(),
|
||||
workspace_id.clone(),
|
||||
),
|
||||
};
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(operation.tool_name(), definition))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkerListInput {}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkerSpawnInput {
|
||||
runtime_id: String,
|
||||
working_directory_id: String,
|
||||
profile: String,
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
initial_text: Option<String>,
|
||||
#[serde(default)]
|
||||
relative_cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkerSpawnRequest {
|
||||
runtime_id: String,
|
||||
display_name: String,
|
||||
profile: String,
|
||||
initial_text: String,
|
||||
working_directory: WorkerWorkingDirectorySelection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkerWorkingDirectorySelection {
|
||||
working_directory_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
relative_cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkerTargetInput {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkerStopInput {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
struct WorkspaceWorkerTool {
|
||||
operation: WorkerOperation,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum WorkerOperation {
|
||||
List,
|
||||
Spawn,
|
||||
Stop,
|
||||
Restore,
|
||||
}
|
||||
|
||||
impl WorkerOperation {
|
||||
const ALL: [Self; 4] = [Self::List, Self::Spawn, Self::Stop, Self::Restore];
|
||||
|
||||
fn tool_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::List => "WorkerList",
|
||||
Self::Spawn => "WorkerSpawn",
|
||||
Self::Stop => "WorkerStop",
|
||||
Self::Restore => "WorkerRestore",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::List => {
|
||||
"List Backend/Runtime Worker sessions in the current Workspace. SubWorkers are excluded."
|
||||
}
|
||||
Self::Spawn => {
|
||||
"Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted."
|
||||
}
|
||||
Self::Stop => "Stop a Backend/Runtime Worker session in the current Workspace.",
|
||||
Self::Restore => {
|
||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkspaceWorkerTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let request = match self.operation {
|
||||
WorkerOperation::List => {
|
||||
parse::<WorkerListInput>(input_json, "WorkerList")?;
|
||||
WorkspaceRequest::get(format!("/api/w/{}/workers", self.workspace_id))
|
||||
}
|
||||
WorkerOperation::Spawn => {
|
||||
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
|
||||
let request = WorkerSpawnRequest {
|
||||
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
|
||||
display_name: input
|
||||
.display_name
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "Workspace Worker".to_string()),
|
||||
profile: non_empty(input.profile, "profile")?,
|
||||
initial_text: input.initial_text.unwrap_or_default(),
|
||||
working_directory: WorkerWorkingDirectorySelection {
|
||||
working_directory_id: authority_id(
|
||||
&input.working_directory_id,
|
||||
"working_directory_id",
|
||||
)?,
|
||||
relative_cwd: input
|
||||
.relative_cwd
|
||||
.map(|value| validate_relative_cwd(&value))
|
||||
.transpose()?,
|
||||
},
|
||||
};
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{}/workers", self.workspace_id),
|
||||
serde_json::to_string(&request)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
||||
)
|
||||
}
|
||||
WorkerOperation::Stop => {
|
||||
let input = parse::<WorkerStopInput>(input_json, "WorkerStop")?;
|
||||
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
|
||||
let worker_id = authority_id(&input.worker_id, "worker_id")?;
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/stop",
|
||||
self.workspace_id
|
||||
),
|
||||
serde_json::json!({ "reason": input.reason }).to_string(),
|
||||
)
|
||||
}
|
||||
WorkerOperation::Restore => {
|
||||
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
|
||||
let runtime_id = authority_id(&input.runtime_id, "runtime_id")?;
|
||||
let worker_id = authority_id(&input.worker_id, "worker_id")?;
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"/api/w/{}/runtimes/{runtime_id}/workers/{worker_id}/restore",
|
||||
self.workspace_id
|
||||
),
|
||||
"{}",
|
||||
)
|
||||
}
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.execute(request)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Worker operation returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: format!("{} completed", self.operation.tool_name()),
|
||||
content: Some(response.body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn definition<I: JsonSchema + 'static>(
|
||||
operation: WorkerOperation,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
workspace_id: String,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(I);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new(operation.tool_name())
|
||||
.description(operation.description())
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(WorkspaceWorkerTool {
|
||||
operation,
|
||||
client: client.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse<T: for<'de> Deserialize<'de>>(input: &str, tool: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||
}
|
||||
|
||||
fn authority_id(value: &str, field: &str) -> Result<String, ToolError> {
|
||||
let value = non_empty(value.to_string(), field)?;
|
||||
if value.contains('/') || value.contains('?') || value.contains('#') {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{field} must be an authority id, not a path or URL"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn non_empty(value: String, field: &str) -> Result<String, ToolError> {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{field} must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_relative_cwd(value: &str) -> Result<String, ToolError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty()
|
||||
|| value.starts_with('/')
|
||||
|| value.split('/').any(|part| matches!(part, "" | "." | ".."))
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"relative_cwd must be a normalized relative path inside the Workdir".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
|
||||
assert_eq!(
|
||||
WorkerOperation::ALL.map(WorkerOperation::tool_name),
|
||||
["WorkerList", "WorkerSpawn", "WorkerStop", "WorkerRestore"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_spawn_request_uses_authority_ids_without_runtime_paths() {
|
||||
let request = WorkerSpawnRequest {
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
display_name: "Coder".to_string(),
|
||||
profile: "builtin:coder".to_string(),
|
||||
initial_text: "Implement the Ticket".to_string(),
|
||||
working_directory: WorkerWorkingDirectorySelection {
|
||||
working_directory_id: "wd-1".to_string(),
|
||||
relative_cwd: Some("repo".to_string()),
|
||||
},
|
||||
};
|
||||
let value = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(value["runtime_id"], "runtime-1");
|
||||
assert_eq!(value["working_directory"]["working_directory_id"], "wd-1");
|
||||
assert!(value.get("cwd").is_none());
|
||||
assert!(value.get("runtime_url").is_none());
|
||||
assert!(value["working_directory"].get("mode").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_inputs_reject_paths_and_parent_traversal() {
|
||||
assert!(authority_id("https://runtime.example", "runtime_id").is_err());
|
||||
assert!(authority_id("runtime/id", "runtime_id").is_err());
|
||||
assert!(validate_relative_cwd("../repo").is_err());
|
||||
assert!(validate_relative_cwd("/repo").is_err());
|
||||
assert_eq!(validate_relative_cwd("repo/src").unwrap(), "repo/src");
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ pub fn fire_and_forget(socket: Option<PathBuf>, event: WorkerEvent) {
|
||||
/// Only events classified by `WorkerEvent::should_notify_agent` are injected
|
||||
/// into the parent's LLM context as system messages; control-plane-only events
|
||||
/// keep this renderer for diagnostics/tests. Agent-visible summaries are kept
|
||||
/// deliberately short — the LLM can always call `ReadWorkerOutput` to fetch more
|
||||
/// deliberately short — the LLM can always call `SubWorkerReadOutput` to fetch more
|
||||
/// detail if the event summary is not enough.
|
||||
pub fn render_event(event: &WorkerEvent) -> String {
|
||||
match event {
|
||||
|
||||
@@ -88,9 +88,9 @@ pub enum WorkerPrompt {
|
||||
WorkerOrchestrationGuidanceSection,
|
||||
/// Weak Companion Notify payload for explicit Orchestrator Ticket events.
|
||||
TicketEventCompanionNotice,
|
||||
/// LLM-facing description for the SpawnWorker tool, including discovered
|
||||
/// LLM-facing description for the SubWorkerSpawn tool, including discovered
|
||||
/// profile selectors.
|
||||
SpawnWorkerToolDescription,
|
||||
SubWorkerSpawnToolDescription,
|
||||
}
|
||||
|
||||
impl WorkerPrompt {
|
||||
@@ -107,7 +107,7 @@ impl WorkerPrompt {
|
||||
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
|
||||
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
|
||||
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
|
||||
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
|
||||
Self::SubWorkerSpawnToolDescription => "sub_worker_spawn_tool_description",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::ResidentMemorySummarySection,
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
WorkerPrompt::TicketEventCompanionNotice,
|
||||
WorkerPrompt::SpawnWorkerToolDescription,
|
||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||
];
|
||||
|
||||
pub const KEYS: &'static [&'static str] = &[
|
||||
@@ -141,7 +141,7 @@ impl WorkerPrompt {
|
||||
"resident_memory_summary_section",
|
||||
"worker_orchestration_guidance_section",
|
||||
"ticket_event_companion_notice",
|
||||
"spawn_worker_tool_description",
|
||||
"sub_worker_spawn_tool_description",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -384,8 +384,8 @@ impl PromptCatalog {
|
||||
)
|
||||
}
|
||||
|
||||
/// Render `WorkerPrompt::SpawnWorkerToolDescription`.
|
||||
pub fn spawn_worker_tool_description(
|
||||
/// Render `WorkerPrompt::SubWorkerSpawnToolDescription`.
|
||||
pub fn sub_worker_spawn_tool_description(
|
||||
&self,
|
||||
available_profiles: &str,
|
||||
default_profile: &str,
|
||||
@@ -396,7 +396,7 @@ impl PromptCatalog {
|
||||
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(WorkerPrompt::SpawnWorkerToolDescription, Value::from(m))
|
||||
self.render(WorkerPrompt::SubWorkerSpawnToolDescription, Value::from(m))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,8 +722,8 @@ compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
|
||||
fn worker_orchestration_guidance_section_renders_resource_body() {
|
||||
let cat = PromptCatalog::builtins_only().unwrap();
|
||||
let rendered = cat.worker_orchestration_guidance_section().unwrap();
|
||||
assert!(rendered.contains("## Worker orchestration"));
|
||||
assert!(rendered.contains("spawned Worker notifications are background signals"));
|
||||
assert!(rendered.contains("## SubWorker orchestration"));
|
||||
assert!(rendered.contains("SubWorker notifications are background signals"));
|
||||
assert!(rendered.contains("does not need to keep a turn open"));
|
||||
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||
@@ -732,10 +732,10 @@ compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_worker_tool_description_renders_profile_block() {
|
||||
fn sub_worker_spawn_tool_description_renders_profile_block() {
|
||||
let cat = PromptCatalog::builtins_only().unwrap();
|
||||
let rendered = cat
|
||||
.spawn_worker_tool_description(
|
||||
.sub_worker_spawn_tool_description(
|
||||
"- `project:coder` — Coder\n- `project:reviewer` — Reviewer",
|
||||
"project:coder",
|
||||
"",
|
||||
|
||||
@@ -206,12 +206,12 @@ struct ToolCapabilities {
|
||||
memory_query: bool,
|
||||
memory_read_document: bool,
|
||||
memory_update_document: bool,
|
||||
worker_spawn: bool,
|
||||
worker_send: bool,
|
||||
worker_read_output: bool,
|
||||
worker_stop: bool,
|
||||
worker_list: bool,
|
||||
worker_restore: bool,
|
||||
sub_worker_spawn: bool,
|
||||
sub_worker_send: bool,
|
||||
sub_worker_read_output: bool,
|
||||
sub_worker_stop: bool,
|
||||
sub_worker_list: bool,
|
||||
sub_worker_restore: bool,
|
||||
}
|
||||
|
||||
impl ToolCapabilities {
|
||||
@@ -222,12 +222,11 @@ impl ToolCapabilities {
|
||||
"MemoryQuery" => capabilities.memory_query = true,
|
||||
"MemoryReadDocument" => capabilities.memory_read_document = true,
|
||||
"MemoryUpdateDocument" => capabilities.memory_update_document = true,
|
||||
"SpawnWorker" => capabilities.worker_spawn = true,
|
||||
"SendToWorker" => capabilities.worker_send = true,
|
||||
"ReadWorkerOutput" => capabilities.worker_read_output = true,
|
||||
"StopWorker" => capabilities.worker_stop = true,
|
||||
"ListWorkers" => capabilities.worker_list = true,
|
||||
"RestoreWorker" => capabilities.worker_restore = true,
|
||||
"SubWorkerSpawn" => capabilities.sub_worker_spawn = true,
|
||||
"SubWorkerSend" => capabilities.sub_worker_send = true,
|
||||
"SubWorkerReadOutput" => capabilities.sub_worker_read_output = true,
|
||||
"SubWorkerStop" => capabilities.sub_worker_stop = true,
|
||||
"SubWorkerList" => capabilities.sub_worker_list = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -246,13 +245,13 @@ impl ToolCapabilities {
|
||||
self.memory_update_document
|
||||
}
|
||||
|
||||
fn worker_management(self) -> bool {
|
||||
self.worker_spawn
|
||||
|| self.worker_send
|
||||
|| self.worker_read_output
|
||||
|| self.worker_stop
|
||||
|| self.worker_list
|
||||
|| self.worker_restore
|
||||
fn sub_worker_management(self) -> bool {
|
||||
self.sub_worker_spawn
|
||||
|| self.sub_worker_send
|
||||
|| self.sub_worker_read_output
|
||||
|| self.sub_worker_stop
|
||||
|| self.sub_worker_list
|
||||
|| self.sub_worker_restore
|
||||
}
|
||||
|
||||
fn to_minijinja_value(self) -> Value {
|
||||
@@ -269,7 +268,10 @@ impl ToolCapabilities {
|
||||
Value::from(self.memory_update_document),
|
||||
);
|
||||
map.insert("memory_mutation", Value::from(self.memory_mutation()));
|
||||
map.insert("worker_management", Value::from(self.worker_management()));
|
||||
map.insert(
|
||||
"sub_worker_management",
|
||||
Value::from(self.sub_worker_management()),
|
||||
);
|
||||
Value::from(map)
|
||||
}
|
||||
}
|
||||
@@ -419,7 +421,7 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||
fn sub_worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("worker.orchestration"),
|
||||
"$yoi/common/worker-orchestration",
|
||||
@@ -595,13 +597,13 @@ mod tests {
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [worker_orchestration_instruction()];
|
||||
let instructions = [sub_worker_orchestration_instruction()];
|
||||
let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.contains("## Worker orchestration"));
|
||||
assert!(rendered.contains("spawned Worker notifications are background signals"));
|
||||
assert!(rendered.contains("## SubWorker orchestration"));
|
||||
assert!(rendered.contains("SubWorker notifications are background signals"));
|
||||
assert!(rendered.contains("does not need to keep a turn open"));
|
||||
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||
@@ -610,7 +612,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_orchestration_guidance_is_omitted_without_worker_management_tools() {
|
||||
fn worker_orchestration_guidance_is_omitted_without_sub_worker_management_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Worker-to-Worker communication tools.
|
||||
//!
|
||||
//! Three tools in one module: `SendToWorker`, `ReadWorkerOutput`, `StopWorker`,
|
||||
//! Three tools in one module: `SubWorkerSend`, `SubWorkerReadOutput`, `SubWorkerStop`,
|
||||
//! all built on the same `SpawnedWorkerRegistry` handed in by
|
||||
//! the controller. Each operation is request-response: connect to the
|
||||
//! target's Unix socket, perform one method exchange, disconnect.
|
||||
@@ -18,7 +18,7 @@ use llm_engine::llm_client::types::{ContentPart, Item, Role};
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use protocol::{ErrorCode, Event, InvokeKind, Method};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::LogEntry;
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
@@ -35,40 +35,96 @@ const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct NameInput {
|
||||
/// Name of a previously spawned Worker.
|
||||
/// Name of a previously spawned SubWorker.
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SendToWorker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SEND_TO_POD_DESCRIPTION: &str = "Send a text message to a previously spawned Worker. The spawned Worker \
|
||||
processes it as a user turn. Fails if the Worker is already executing a \
|
||||
turn — retry after it finishes. Does not wait for the turn to complete; \
|
||||
use `ReadWorkerOutput` to fetch results afterwards.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct SendToWorkerInput {
|
||||
/// Target Worker name.
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubWorkerListInput {}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SubWorkerListItem {
|
||||
name: String,
|
||||
/// Text delivered to the Worker as the next user message.
|
||||
message: String,
|
||||
}
|
||||
|
||||
struct SendToWorkerTool {
|
||||
struct SubWorkerListTool {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SendToWorkerTool {
|
||||
impl Tool for SubWorkerListTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SendToWorkerInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SendToWorker input: {e}")))?;
|
||||
let _input: SubWorkerListInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("invalid SubWorkerList input: {error}"))
|
||||
})?;
|
||||
let items = self
|
||||
.registry
|
||||
.list()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|record| SubWorkerListItem {
|
||||
name: record.worker_name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let count = items.len();
|
||||
let content = serde_json::to_string_pretty(&serde_json::json!({ "sub_workers": items }))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
Ok(ToolOutput {
|
||||
summary: format!("listed {count} child SubWorker(s)"),
|
||||
content: Some(content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sub_worker_list_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SubWorkerListInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("SubWorkerList")
|
||||
.description("List child SubWorkers owned by this Worker. Peer Workers and general Runtime Workers are excluded.")
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerListTool {
|
||||
registry: registry.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubWorkerSend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SEND_TO_POD_DESCRIPTION: &str = "Send a text message to a previously spawned SubWorker. The SubWorker \
|
||||
processes it as a user turn. Fails if the SubWorker is already executing a \
|
||||
turn — retry after it finishes. Does not wait for the turn to complete; \
|
||||
use `SubWorkerReadOutput` to fetch results afterwards.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct SubWorkerSendInput {
|
||||
/// Target SubWorker name.
|
||||
name: String,
|
||||
/// Text delivered to the SubWorker as the next user message.
|
||||
message: String,
|
||||
}
|
||||
|
||||
struct SubWorkerSendTool {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SubWorkerSendTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SubWorkerSendInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerSend input: {e}")))?;
|
||||
let record = self
|
||||
.registry
|
||||
.get(&input.name)
|
||||
@@ -98,14 +154,14 @@ impl Tool for SendToWorkerTool {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_to_worker_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
pub fn sub_worker_send_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SendToWorkerInput);
|
||||
let schema = schemars::schema_for!(SubWorkerSendInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("SendToWorker")
|
||||
let meta = ToolMeta::new("SubWorkerSend")
|
||||
.description(SEND_TO_POD_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SendToWorkerTool {
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerSendTool {
|
||||
registry: registry.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -113,27 +169,27 @@ pub fn send_to_worker_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefiniti
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadWorkerOutput
|
||||
// SubWorkerReadOutput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const READ_POD_OUTPUT_DESCRIPTION: &str = "Fetch new assistant text from a spawned Worker since the last read. \
|
||||
Uses an internal cursor per-Worker so consecutive calls return only \
|
||||
newly-produced output. Returns the Worker's current status and the new \
|
||||
text, or reports `stopped` if the Worker can no longer be reached.";
|
||||
const READ_POD_OUTPUT_DESCRIPTION: &str = "Fetch new assistant text from a SubWorker since the last read. \
|
||||
Uses an internal cursor per-SubWorker so consecutive calls return only \
|
||||
newly-produced output. Returns the SubWorker's current status and the new \
|
||||
text, or reports `stopped` if the SubWorker can no longer be reached.";
|
||||
|
||||
struct ReadWorkerOutputTool {
|
||||
struct SubWorkerReadOutputTool {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ReadWorkerOutputTool {
|
||||
impl Tool for SubWorkerReadOutputTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: NameInput = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid ReadWorkerOutput input: {e}"))
|
||||
ToolError::InvalidArgument(format!("invalid SubWorkerReadOutput input: {e}"))
|
||||
})?;
|
||||
let record = self
|
||||
.registry
|
||||
@@ -178,14 +234,14 @@ impl Tool for ReadWorkerOutputTool {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_worker_output_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
pub fn sub_worker_read_output_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(NameInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("ReadWorkerOutput")
|
||||
let meta = ToolMeta::new("SubWorkerReadOutput")
|
||||
.description(READ_POD_OUTPUT_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadWorkerOutputTool {
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerReadOutputTool {
|
||||
registry: registry.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -193,26 +249,26 @@ pub fn read_worker_output_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefi
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StopWorker
|
||||
// SubWorkerStop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STOP_POD_DESCRIPTION: &str = "Terminate a spawned Worker and reclaim the delegated scope. The Worker \
|
||||
const STOP_POD_DESCRIPTION: &str = "Terminate a spawned SubWorker and reclaim the delegated scope. The SubWorker \
|
||||
receives `Shutdown`; its scope entry is released in the machine-wide \
|
||||
registry so the spawner can spawn a new Worker over the same paths.";
|
||||
registry so the parent Worker can spawn a new SubWorker over the same paths.";
|
||||
|
||||
struct StopWorkerTool {
|
||||
struct SubWorkerStopTool {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for StopWorkerTool {
|
||||
impl Tool for SubWorkerStopTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: NameInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid StopWorker input: {e}")))?;
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?;
|
||||
let record = self
|
||||
.registry
|
||||
.get(&input.name)
|
||||
@@ -244,14 +300,14 @@ impl Tool for StopWorkerTool {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_worker_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
pub fn sub_worker_stop_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(NameInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("StopWorker")
|
||||
let meta = ToolMeta::new("SubWorkerStop")
|
||||
.description(STOP_POD_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(StopWorkerTool {
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerStopTool {
|
||||
registry: registry.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -335,13 +391,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes distinguished by `SendToWorker`.
|
||||
/// Failure modes distinguished by `SubWorkerSend`.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum SendRunError {
|
||||
/// Target Worker responded with `Error { AlreadyRunning }` — the
|
||||
/// Target SubWorker responded with `Error { AlreadyRunning }` — the
|
||||
/// caller can retry once the current turn ends.
|
||||
AlreadyRunning,
|
||||
/// Target Worker explicitly rejected the run after delivery reached the
|
||||
/// Target SubWorker explicitly rejected the run after delivery reached the
|
||||
/// controller.
|
||||
Rejected { code: ErrorCode, message: String },
|
||||
/// Transport, protocol, timeout, or unexpected EOF before acceptance
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Shared registry of Workers spawned by this Worker.
|
||||
//!
|
||||
//! `SpawnWorker` writes here; the worker-comm tools (`SendToWorker`,
|
||||
//! `ReadWorkerOutput`, `StopWorker`) read and mutate the same instance. Discovery
|
||||
//! `SubWorkerSpawn` writes here; the worker-comm tools (`SubWorkerSend`,
|
||||
//! `SubWorkerReadOutput`, `SubWorkerStop`) read and mutate the same instance. Discovery
|
||||
//! tools consult this registry together with durable Worker state. Runtime
|
||||
//! write-through still materialises `spawned_workers.json`, but durable state lives
|
||||
//! in the spawner's Worker metadata.
|
||||
//!
|
||||
//! `ReadWorkerOutput` additionally owns a per-spawned-worker cursor here so
|
||||
//! `SubWorkerReadOutput` additionally owns a per-spawned-worker cursor here so
|
||||
//! two consecutive reads yield only new assistant text. The cursor is
|
||||
//! an item-index into the child's history; push-only history makes
|
||||
//! index stable across reads.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! `SpawnWorker` tool — launch a new Worker process as a child of this one.
|
||||
//! `SubWorkerSpawn` tool — launch a new SubWorker process as a child of this one.
|
||||
//!
|
||||
//! Wires worker-allocation delegation, child manifest-config construction, subprocess
|
||||
//! launch, and socket handoff into a single `Tool` implementation. When
|
||||
//! the LLM calls `SpawnWorker`, a fresh Worker runtime command is exec'd in its own
|
||||
//! the LLM calls `SubWorkerSpawn`, a fresh SubWorker runtime command is exec'd in its own
|
||||
//! process group, the worker-allocation is updated atomically, and the child's
|
||||
//! first turn is kicked off by handing its socket a `Method::Run`.
|
||||
|
||||
@@ -34,13 +34,13 @@ use crate::spawn::comm_tools::{SendRunError, send_run_and_confirm};
|
||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||
use protocol::WorkerEvent;
|
||||
|
||||
/// How long we will wait for the spawned Worker's socket to become
|
||||
/// How long we will wait for the spawned SubWorker's socket to become
|
||||
/// connectable before treating the spawn as failed.
|
||||
const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct SpawnWorkerInput {
|
||||
/// Identifier for the spawned Worker. Must be unique machine-wide.
|
||||
struct SubWorkerSpawnInput {
|
||||
/// Identifier for the spawned SubWorker. 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
|
||||
@@ -53,13 +53,13 @@ struct SpawnWorkerInput {
|
||||
#[serde(default)]
|
||||
instruction: Option<String>,
|
||||
/// Child process/tool working directory. This is not the runtime workspace
|
||||
/// root and grants no filesystem authority. When omitted, the spawned Worker
|
||||
/// root and grants no filesystem authority. When omitted, the spawned SubWorker
|
||||
/// starts in the spawner's current working directory.
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
/// First message sent to the spawned Worker via `Method::Run`.
|
||||
/// First message sent to the spawned SubWorker via `Method::Run`.
|
||||
task: String,
|
||||
/// Allow rules delegated to the spawned Worker. Must be a subset of the
|
||||
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the
|
||||
/// spawner's explicit delegation authority; direct tool scope alone is not
|
||||
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
||||
scope: Vec<ScopeRuleInput>,
|
||||
@@ -189,7 +189,7 @@ fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelecto
|
||||
|| raw.ends_with(".nix")
|
||||
{
|
||||
return Err(format!(
|
||||
"SpawnWorker.profile accepts `default`, `inherit`, or registry selectors only; path-like selector `{raw}` is not allowed"
|
||||
"SubWorkerSpawn.profile accepts `default`, `inherit`, or registry selectors only; path-like selector `{raw}` is not allowed"
|
||||
));
|
||||
}
|
||||
if let Some((prefix, name)) = raw.split_once(':') {
|
||||
@@ -199,12 +199,14 @@ fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelecto
|
||||
"project" => ProfileRegistrySource::Project,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"unsupported SpawnWorker.profile selector prefix `{prefix}`; use builtin:, user:, project:, default, or inherit"
|
||||
"unsupported SubWorkerSpawn.profile selector prefix `{prefix}`; use builtin:, user:, project:, default, or inherit"
|
||||
));
|
||||
}
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err("SpawnWorker.profile registry selector has an empty profile name".into());
|
||||
return Err(
|
||||
"SubWorkerSpawn.profile registry selector has an empty profile name".into(),
|
||||
);
|
||||
}
|
||||
return Ok(SpawnProfileSelector::Registry(
|
||||
ProfileSelector::source_named(source, name),
|
||||
@@ -213,30 +215,30 @@ fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelecto
|
||||
Ok(SpawnProfileSelector::Registry(ProfileSelector::named(raw)))
|
||||
}
|
||||
|
||||
/// Runtime dependencies the `SpawnWorker` tool needs in order to launch a
|
||||
/// child Worker and record the handoff locally. Constructed by the Worker
|
||||
/// Runtime dependencies the `SubWorkerSpawn` tool needs in order to launch a
|
||||
/// child SubWorker and record the handoff locally. Constructed by the Worker
|
||||
/// controller once per Worker lifetime.
|
||||
pub struct SpawnWorkerTool {
|
||||
/// Spawner's own worker name — becomes the spawned Worker's
|
||||
pub struct SubWorkerSpawnTool {
|
||||
/// Spawner's own worker name — becomes the spawned SubWorker's
|
||||
/// `delegated_from` in the worker-allocation.
|
||||
spawner_name: String,
|
||||
/// Path to the spawner's Unix socket. Handed to the child via
|
||||
/// `--callback` so its `WorkerEvent` callbacks have somewhere to land.
|
||||
callback_socket: PathBuf,
|
||||
/// Root of the `$XDG_RUNTIME_DIR/yoi/` tree, used to predict
|
||||
/// the spawned Worker's socket path before the child has bound it.
|
||||
/// the spawned SubWorker's socket path before the child has bound it.
|
||||
runtime_base: PathBuf,
|
||||
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
||||
/// memory context. SpawnWorker `cwd` must not affect this value.
|
||||
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
||||
workspace_root: PathBuf,
|
||||
/// Directory the spawned Worker's tools should use when the LLM did not
|
||||
/// Directory the spawned SubWorker's tools should use when the LLM did not
|
||||
/// override it. Defaults to the spawner's cwd.
|
||||
spawner_cwd: PathBuf,
|
||||
/// Optional typed runtime command injected by tests. Production resolves
|
||||
/// the runtime command from `std::env::current_exe()` at launch time.
|
||||
runtime_command: Option<WorkerRuntimeCommand>,
|
||||
/// Shared registry of spawned children, also used by the
|
||||
/// worker-comm tools (`SendToWorker` / `ReadWorkerOutput` / `StopWorker`) and by
|
||||
/// worker-comm tools (`SubWorkerSend` / `SubWorkerReadOutput` / `SubWorkerStop`) and by
|
||||
/// Worker discovery. Writes the list to runtime and durable Worker state on
|
||||
/// each add.
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -266,7 +268,7 @@ pub struct SpawnWorkerTool {
|
||||
delegation_scope: DelegationScope,
|
||||
}
|
||||
|
||||
impl SpawnWorkerTool {
|
||||
impl SubWorkerSpawnTool {
|
||||
fn new(
|
||||
spawner_name: String,
|
||||
callback_socket: PathBuf,
|
||||
@@ -299,14 +301,15 @@ impl SpawnWorkerTool {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SpawnWorkerTool {
|
||||
impl Tool for SubWorkerSpawnTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SpawnWorkerInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SpawnWorker input: {e}")))?;
|
||||
let input: SubWorkerSpawnInput = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid SubWorkerSpawn input: {e}"))
|
||||
})?;
|
||||
|
||||
// `delegate_scope` catches this too (as `DuplicateWorkerName`), but
|
||||
// the dedicated message is kinder to the LLM — which gets the
|
||||
@@ -438,7 +441,7 @@ impl Tool for SpawnWorkerTool {
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnWorkerTool {
|
||||
impl SubWorkerSpawnTool {
|
||||
async fn exec_child(
|
||||
&self,
|
||||
worker_name: &str,
|
||||
@@ -508,7 +511,7 @@ impl SpawnWorkerTool {
|
||||
fn validate_delegation_scope(&self, scope_allow: &[ScopeRule]) -> Result<(), ToolError> {
|
||||
if self.delegation_scope.is_empty() && !scope_allow.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"SpawnWorker requires delegation authority, but this Worker has no delegation scope grant; direct filesystem scope only authorizes this Worker's own tools".into(),
|
||||
"SubWorkerSpawn requires delegation authority, but this Worker has no delegation scope grant; direct filesystem scope only authorizes this Worker's own tools".into(),
|
||||
));
|
||||
}
|
||||
for rule in scope_allow {
|
||||
@@ -566,29 +569,32 @@ fn validate_spawn_cwd(
|
||||
};
|
||||
if !cwd.is_absolute() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"SpawnWorker.cwd must be absolute: {}",
|
||||
"SubWorkerSpawn.cwd must be absolute: {}",
|
||||
cwd.display()
|
||||
)));
|
||||
}
|
||||
let metadata = std::fs::metadata(cwd).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
ToolError::InvalidArgument(format!("SpawnWorker.cwd does not exist: {}", cwd.display()))
|
||||
ToolError::InvalidArgument(format!(
|
||||
"SubWorkerSpawn.cwd does not exist: {}",
|
||||
cwd.display()
|
||||
))
|
||||
} else {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"SpawnWorker.cwd is not usable: {}: {e}",
|
||||
"SubWorkerSpawn.cwd is not usable: {}: {e}",
|
||||
cwd.display()
|
||||
))
|
||||
}
|
||||
})?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"SpawnWorker.cwd must be a directory: {}",
|
||||
"SubWorkerSpawn.cwd must be a directory: {}",
|
||||
cwd.display()
|
||||
)));
|
||||
}
|
||||
let canonical = std::fs::canonicalize(cwd).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"SpawnWorker.cwd is not usable: {}: {e}",
|
||||
"SubWorkerSpawn.cwd is not usable: {}: {e}",
|
||||
cwd.display()
|
||||
))
|
||||
})?;
|
||||
@@ -598,12 +604,12 @@ fn validate_spawn_cwd(
|
||||
})
|
||||
.map_err(|e| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"requested child scope cannot validate SpawnWorker.cwd: {e}"
|
||||
"requested child scope cannot validate SubWorkerSpawn.cwd: {e}"
|
||||
))
|
||||
})?;
|
||||
if !child_scope.is_readable(&canonical) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"SpawnWorker.cwd {} is outside the child's delegated readable scope; cwd grants no authority, so add an explicit read or write scope rule covering it",
|
||||
"SubWorkerSpawn.cwd {} is outside the child's delegated readable scope; cwd grants no authority, so add an explicit read or write scope rule covering it",
|
||||
cwd.display()
|
||||
)));
|
||||
}
|
||||
@@ -617,7 +623,7 @@ fn validate_spawn_cwd(
|
||||
///
|
||||
/// The child's tool working directory is carried separately through
|
||||
/// the child runtime entrypoint; it is not part of the manifest.
|
||||
impl SpawnWorkerTool {
|
||||
impl SubWorkerSpawnTool {
|
||||
fn build_spawn_config_json(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -651,7 +657,7 @@ fn build_spawn_config_json_for_profile(
|
||||
SpawnProfileSelector::Default | SpawnProfileSelector::Registry(_) => {
|
||||
let registry = available_profiles.registry.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"profile discovery failed for SpawnWorker: {}{}",
|
||||
"profile discovery failed for SubWorkerSpawn: {}{}",
|
||||
available_profiles.diagnostic().if_empty("unknown error"),
|
||||
available_profiles.error_suffix()
|
||||
)
|
||||
@@ -728,7 +734,7 @@ impl IfEmpty for str {
|
||||
|
||||
fn profile_error_with_available(error: ProfileError, available: &AvailableProfiles) -> String {
|
||||
format!(
|
||||
"invalid SpawnWorker.profile: {error}{}",
|
||||
"invalid SubWorkerSpawn.profile: {error}{}",
|
||||
available.error_suffix()
|
||||
)
|
||||
}
|
||||
@@ -878,8 +884,8 @@ fn worker_allocation_err_to_tool(e: ScopeLockError) -> ToolError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for the `SpawnWorker` tool.
|
||||
pub fn spawn_worker_tool(
|
||||
/// Factory for the `SubWorkerSpawn` tool.
|
||||
pub fn sub_worker_spawn_tool(
|
||||
spawner_name: String,
|
||||
callback_socket: PathBuf,
|
||||
runtime_base: PathBuf,
|
||||
@@ -891,7 +897,7 @@ pub fn spawn_worker_tool(
|
||||
spawner_scope: SharedScope,
|
||||
prompts: Arc<PromptCatalog>,
|
||||
) -> ToolDefinition {
|
||||
spawn_worker_tool_impl(
|
||||
sub_worker_spawn_tool_impl(
|
||||
spawner_name,
|
||||
callback_socket,
|
||||
runtime_base,
|
||||
@@ -907,7 +913,7 @@ pub fn spawn_worker_tool(
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn spawn_worker_tool_with_runtime_command(
|
||||
pub fn sub_worker_spawn_tool_with_runtime_command(
|
||||
spawner_name: String,
|
||||
callback_socket: PathBuf,
|
||||
runtime_base: PathBuf,
|
||||
@@ -920,7 +926,7 @@ pub fn spawn_worker_tool_with_runtime_command(
|
||||
prompts: Arc<PromptCatalog>,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> ToolDefinition {
|
||||
spawn_worker_tool_impl(
|
||||
sub_worker_spawn_tool_impl(
|
||||
spawner_name,
|
||||
callback_socket,
|
||||
runtime_base,
|
||||
@@ -935,7 +941,7 @@ pub fn spawn_worker_tool_with_runtime_command(
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_worker_tool_impl(
|
||||
fn sub_worker_spawn_tool_impl(
|
||||
spawner_name: String,
|
||||
callback_socket: PathBuf,
|
||||
runtime_base: PathBuf,
|
||||
@@ -949,25 +955,25 @@ fn spawn_worker_tool_impl(
|
||||
runtime_command: Option<WorkerRuntimeCommand>,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SpawnWorkerInput);
|
||||
let schema = schemars::schema_for!(SubWorkerSpawnInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let available_profiles = AvailableProfiles::discover(&workspace_root);
|
||||
let description = prompts
|
||||
.spawn_worker_tool_description(
|
||||
.sub_worker_spawn_tool_description(
|
||||
&available_profiles.compact_list(),
|
||||
&available_profiles.default_label(),
|
||||
available_profiles.diagnostic(),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
format!(
|
||||
"Spawn a new Worker process to work on a delegated task. Profile description rendering failed: {e}. Available profiles:\n{}",
|
||||
"Spawn a new SubWorker process to split context for a delegated task. Profile description rendering failed: {e}. Available profiles:\n{}",
|
||||
available_profiles.compact_list()
|
||||
)
|
||||
});
|
||||
let meta = ToolMeta::new("SpawnWorker")
|
||||
let meta = ToolMeta::new("SubWorkerSpawn")
|
||||
.description(description)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnWorkerTool::new(
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerSpawnTool::new(
|
||||
spawner_name.clone(),
|
||||
callback_socket.clone(),
|
||||
runtime_base.clone(),
|
||||
@@ -1002,7 +1008,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn spawn_worker_input_schema_includes_optional_cwd() {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnWorkerInput)).unwrap();
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SubWorkerSpawnInput)).unwrap();
|
||||
let properties = schema
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
|
||||
@@ -654,7 +654,7 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
/// and compaction so updates propagate at the next permission check.
|
||||
scope: SharedScope,
|
||||
/// Filesystem authority this Worker may pass to spawned children. Direct tools
|
||||
/// continue to use `scope`; SpawnWorker validates requested child scope here.
|
||||
/// continue to use `scope`; SubWorkerSpawn validates requested child scope here.
|
||||
delegation_scope: DelegationScope,
|
||||
hook_builder: HookRegistryBuilder,
|
||||
interceptor_installed: bool,
|
||||
@@ -3794,7 +3794,7 @@ where
|
||||
/// The Worker's working directory is captured once here from the
|
||||
/// process's `std::env::current_dir()` — callers that want a
|
||||
/// different cwd must `cd` before constructing the Worker (e.g. the
|
||||
/// `SpawnWorker` tool sets `Command::current_dir` on the child). The
|
||||
/// `SubWorkerSpawn` tool sets `Command::current_dir` on the child). The
|
||||
/// captured cwd is canonicalised and validated against
|
||||
/// `manifest.scope`.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user