#![cfg_attr(not(test), allow(dead_code, unused_imports))] //! Parent-facing tools for in-process Internal SubWorker sessions. //! //! Legacy direct-child tool constructors are test-only; production exposes the //! registry through the unified `worker.control` service and Worker tools. //! There is no Runtime catalog lookup or child socket transport, so a Worker can operate only on //! its direct Internal children. The socket helper at the bottom remains solely for the legacy //! top-level Worker callback protocol and is not part of SubWorker communication. use std::path::Path; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use protocol::stream::{JsonLineReader, JsonLineWriter}; use protocol::{Event, Method}; use serde::{Deserialize, Serialize}; use tokio::net::UnixStream; use crate::spawn::registry::SpawnedWorkerRegistry; /// Timeout applied to each socket-level operation — connect, write, /// read. Kept short so a stuck child doesn't block the spawner's turn. const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(5); // --------------------------------------------------------------------------- // Shared input types // --------------------------------------------------------------------------- #[derive(Debug, Deserialize, schemars::JsonSchema)] struct NameInput { /// Name of a previously spawned SubWorker. name: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] struct SubWorkerListInput {} #[derive(Debug, Serialize)] struct SubWorkerListItem { name: String, } struct SubWorkerListTool { registry: Arc, } #[async_trait] impl Tool for SubWorkerListTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let _input: SubWorkerListInput = serde_json::from_str(input_json).map_err(|error| { ToolError::InvalidArgument(format!("invalid SubWorkerList input: {error}")) })?; let items = self .registry .list_internal() .into_iter() .map(|record| SubWorkerListItem { name: record.worker_name, }) .collect::>(); 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), attachments: Vec::new(), }) } } #[cfg(test)] pub fn sub_worker_list_tool(registry: Arc) -> 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 = 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 worker-observation tools to inspect its committed session."; #[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, } #[async_trait] impl Tool for SubWorkerSendTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let input: SubWorkerSendInput = serde_json::from_str(input_json) .map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerSend input: {e}")))?; if let Some(record) = self.registry.get_internal(&input.name) { record.session.send(input.message).await.map_err(|error| { ToolError::ExecutionFailed(format!("send to `{}`: {error}", input.name)) })?; return Ok(ToolOutput { summary: format!("sent message to `{}`", input.name), content: None, attachments: Vec::new(), }); } Err(unknown_worker_err(&input.name)) } } #[cfg(test)] pub fn sub_worker_send_tool(registry: Arc) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(SubWorkerSendInput); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); let meta = ToolMeta::new("SubWorkerSend") .description(SEND_TO_POD_DESCRIPTION) .input_schema(schema_value); let tool: Arc = Arc::new(SubWorkerSendTool { registry: registry.clone(), }); (meta, tool) }) } // --------------------------------------------------------------------------- // SubWorkerStop // --------------------------------------------------------------------------- const STOP_POD_DESCRIPTION: &str = "Cancel and stop a spawned Internal SubWorker session, remove it from the parent's direct-child registry, and reclaim delegated Write scope."; struct SubWorkerStopTool { registry: Arc, } #[async_trait] impl Tool for SubWorkerStopTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let input: NameInput = serde_json::from_str(input_json) .map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?; if let Some(summary) = self .registry .remove_internal(&input.name) .await .map_err(|error| ToolError::ExecutionFailed(error.to_string()))? { return Ok(ToolOutput { summary: format!( "SubWorkerStop - done\n {} tool kind{}\n {}ms", summary.tool_counts.len(), if summary.tool_counts.len() == 1 { "" } else { "s" }, summary.elapsed_ms, ), content: Some( serde_json::to_string(&summary) .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, ), attachments: Vec::new(), }); } Err(unknown_worker_err(&input.name)) } } #[cfg(test)] pub fn sub_worker_stop_tool(registry: Arc) -> 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("SubWorkerStop") .description(STOP_POD_DESCRIPTION) .input_schema(schema_value); let tool: Arc = Arc::new(SubWorkerStopTool { registry: registry.clone(), }); (meta, tool) }) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn unknown_worker_err(name: &str) -> ToolError { ToolError::InvalidArgument(format!("no spawned worker named `{name}`")) } /// Connect with a timeout, drain the server's connect-time snapshot, /// write one `Method` line, flush, and close. /// /// The Worker socket protocol sends replayed alerts and an initial /// `Event::Snapshot` before it starts reading client methods. Send-only /// callers must consume that prefix; otherwise a large snapshot can block /// the server's writer before it reaches the method-read branch. Any /// socket error maps to an `io::Error`; the caller decides whether to /// surface it to the LLM or treat it as "worker stopped". pub(crate) async fn connect_and_send(socket: &Path, method: &Method) -> std::io::Result<()> { let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket)) .await .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timed out"))??; let (r, w) = stream.into_split(); let mut reader = JsonLineReader::new(r); let mut writer = JsonLineWriter::new(w); drain_initial_snapshot(&mut reader).await?; tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(method)) .await .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "write timed out"))??; Ok(()) } async fn drain_initial_snapshot(reader: &mut JsonLineReader) -> std::io::Result<()> where R: tokio::io::AsyncBufRead + Unpin, { loop { let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::()) .await .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "read timed out"))??; match event { Some(Event::Snapshot { .. }) => return Ok(()), Some(_) => continue, None => { return Err(std::io::Error::new( std::io::ErrorKind::UnexpectedEof, "worker closed connection before Snapshot event", )); } } } } #[cfg(test)] mod tests { use super::*; use protocol::{Alert, AlertLevel, AlertSource, Greeting, WorkerEvent, WorkerStatus}; use tempfile::TempDir; use tokio::net::UnixListener; use tokio::task::JoinHandle; fn snapshot(entries: Vec) -> Event { Event::Snapshot { entries, greeting: Greeting { worker_name: "server".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: WorkerStatus::Idle, in_flight: Default::default(), internal_workers: Vec::new(), } } fn serve_initial_events_then_method( listener: UnixListener, events: Vec, ) -> JoinHandle> { tokio::spawn(async move { let (stream, _) = listener.accept().await.ok()?; let (r, w) = stream.into_split(); let mut reader = JsonLineReader::new(r); let mut writer = JsonLineWriter::new(w); for event in events { writer.write(&event).await.ok()?; } reader.next::().await.ok().flatten() }) } #[tokio::test] async fn connect_and_send_drains_initial_alert_and_snapshot_before_method() { let tmp = TempDir::new().unwrap(); let socket = tmp.path().join("worker.sock"); let listener = UnixListener::bind(&socket).unwrap(); let received = serve_initial_events_then_method( listener, vec![ Event::Alert(Alert { level: AlertLevel::Warn, source: AlertSource::Worker, message: "replayed alert".into(), timestamp_ms: 0, }), snapshot(Vec::new()), ], ); connect_and_send(&socket, &Method::Shutdown).await.unwrap(); let method = received.await.unwrap().expect("expected method"); assert!(matches!(method, Method::Shutdown)); } #[tokio::test] async fn connect_and_send_delivers_method_after_large_initial_snapshot() { let tmp = TempDir::new().unwrap(); let socket = tmp.path().join("worker.sock"); let listener = UnixListener::bind(&socket).unwrap(); let large_payload = "x".repeat(2 * 1024 * 1024); let received = serve_initial_events_then_method( listener, vec![snapshot(vec![ serde_json::json!({ "payload": large_payload }), ])], ); let expected = Method::WorkerEvent(WorkerEvent::TurnEnded { worker_name: "child".into(), }); connect_and_send(&socket, &expected).await.unwrap(); let method = received.await.unwrap().expect("expected method"); match method { Method::WorkerEvent(WorkerEvent::TurnEnded { worker_name }) => { assert_eq!(worker_name, "child") } other => panic!("expected TurnEnded WorkerEvent, got {other:?}"), } } }