Skip to main content

apollo/tools/
telekinesis.rs

1//! Telekinesis tool — delegate a self-contained task to a worker agent.
2//!
3//! apollo is the manager, telekinesis (`tk`) is the worker. This shells out to
4//! `tk exec`, telekinesis' headless one-shot mode, so apollo can hand off a
5//! whole task rather than driving it tool call by tool call.
6//!
7//! `--json` is always passed: the contract is a single
8//! `{"ok","text","error"}` object on stdout, so parsing never depends on the
9//! worker's prose. Status and tool chatter go to stderr and are surfaced only
10//! when something failed.
11
12use async_trait::async_trait;
13use serde::Deserialize;
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::Duration;
17
18use super::child_proc;
19use super::traits::*;
20use crate::policy::ExecutionPolicy;
21use crate::text::truncate_chars_counted;
22
23/// Worker turns run a whole task, not a single command, so the default is far
24/// longer than a shell tool's — but still bounded, because a hung worker must
25/// not wedge the agent loop.
26const DEFAULT_TIMEOUT_SECS: u64 = 600;
27
28/// Upper bound on a model-supplied timeout.
29const MAX_TIMEOUT_SECS: u64 = 3600;
30
31/// Cap on the text handed back to the model.
32const MAX_OUTPUT_CHARS: usize = 20_000;
33
34pub struct TelekinesisTool {
35    workspace: PathBuf,
36    timeout_secs: u64,
37    /// The worker runs arbitrary commands on this machine, so it is gated on
38    /// the same policy as `exec`. Without this, `allow_shell = false` is
39    /// bypassed by delegating the command to `tk`.
40    policy: Arc<ExecutionPolicy>,
41    /// Binary to invoke. Overridable so tests can exercise the missing-binary
42    /// path without depending on whether `tk` is installed.
43    binary: String,
44}
45
46impl TelekinesisTool {
47    pub fn new(workspace: PathBuf, policy: Arc<ExecutionPolicy>) -> Self {
48        Self {
49            workspace,
50            timeout_secs: DEFAULT_TIMEOUT_SECS,
51            policy,
52            binary: "tk".to_string(),
53        }
54    }
55
56    pub fn with_timeout(mut self, secs: u64) -> Self {
57        self.timeout_secs = secs;
58        self
59    }
60
61    #[cfg(test)]
62    fn with_binary(mut self, binary: impl Into<String>) -> Self {
63        self.binary = binary.into();
64        self
65    }
66
67    /// Locate the worker binary the way apollo locates its other sibling
68    /// binaries: next to the running executable first, then on PATH.
69    async fn find_binary(&self) -> Option<PathBuf> {
70        if let Ok(current) = std::env::current_exe() {
71            if let Some(dir) = current.parent() {
72                let sibling = dir.join(&self.binary);
73                if sibling.is_file() {
74                    return Some(sibling);
75                }
76            }
77        }
78
79        let on_path = tokio::process::Command::new("which")
80            .arg(&self.binary)
81            .output()
82            .await
83            .map(|output| output.status.success())
84            .unwrap_or(false);
85
86        on_path.then(|| PathBuf::from(&self.binary))
87    }
88
89    /// Resolve the requested working directory, refusing anything outside the
90    /// workspace exactly as the shell tool does.
91    fn resolve_cwd(&self, requested: Option<&str>) -> Result<PathBuf, String> {
92        let Some(dir) = requested else {
93            return Ok(self.workspace.clone());
94        };
95
96        let requested_path = if dir.starts_with('/') {
97            PathBuf::from(dir)
98        } else {
99            self.workspace.join(dir)
100        };
101
102        let canonical_workspace = self
103            .workspace
104            .canonicalize()
105            .unwrap_or_else(|_| self.workspace.clone());
106        let canonical_requested = requested_path.canonicalize().unwrap_or(requested_path);
107
108        if !canonical_requested.starts_with(&canonical_workspace) {
109            return Err(format!(
110                "Access denied: directory '{}' is outside the workspace.",
111                dir
112            ));
113        }
114        Ok(canonical_requested)
115    }
116}
117
118#[derive(Deserialize)]
119struct TelekinesisArgs {
120    /// The task to delegate.
121    #[serde(alias = "task")]
122    prompt: String,
123    /// Working directory (defaults to workspace).
124    #[serde(alias = "workdir")]
125    cwd: Option<String>,
126    /// Timeout in seconds.
127    timeout: Option<u64>,
128}
129
130/// `tk exec --json` contract: exactly this object on stdout.
131#[derive(Deserialize)]
132struct ExecOutput {
133    #[serde(default)]
134    ok: bool,
135    #[serde(default)]
136    text: String,
137    #[serde(default)]
138    error: Option<String>,
139}
140
141/// Cap text handed to the model, reporting the dropped count in the same unit
142/// the gate used.
143fn cap(text: &str) -> String {
144    match truncate_chars_counted(text, MAX_OUTPUT_CHARS) {
145        Some((head, dropped)) => format!("{}...\n[truncated {} chars]", head, dropped),
146        None => text.to_string(),
147    }
148}
149
150/// Turn one `tk exec --json` run into a tool result.
151///
152/// Kept free of I/O so every branch — clean success, structured failure,
153/// non-JSON stdout — is directly testable.
154fn interpret(stdout: &str, stderr: &str, exit_ok: bool) -> ToolResult {
155    match serde_json::from_str::<ExecOutput>(stdout.trim()) {
156        Ok(parsed) if parsed.ok => ToolResult::success(cap(&parsed.text)),
157        Ok(parsed) => {
158            let reason = parsed
159                .error
160                .filter(|e| !e.is_empty())
161                .unwrap_or_else(|| "worker reported failure without a message".to_string());
162            let body = if parsed.text.is_empty() {
163                reason
164            } else {
165                format!("{}\n\n{}", reason, parsed.text)
166            };
167            ToolResult::error(cap(&body))
168        }
169        Err(_) => {
170            // No parseable object. Exit status is then the only signal, and
171            // stderr carries whatever the worker managed to say.
172            let detail = if stderr.trim().is_empty() {
173                stdout.trim().to_string()
174            } else {
175                stderr.trim().to_string()
176            };
177            if exit_ok && !stdout.trim().is_empty() {
178                ToolResult::success(cap(stdout.trim()))
179            } else {
180                ToolResult::error(cap(&format!(
181                    "telekinesis produced no parseable result. {}",
182                    detail
183                )))
184            }
185        }
186    }
187}
188
189#[async_trait]
190impl Tool for TelekinesisTool {
191    fn name(&self) -> &str {
192        "telekinesis"
193    }
194
195    fn spec(&self) -> ToolSpec {
196        ToolSpec {
197            name: "telekinesis".to_string(),
198            description: "Delegate a self-contained task to a worker coding agent (telekinesis) \
199                 and get back its final answer. Give it a complete, standalone brief — it starts \
200                 with no knowledge of this conversation and reports back once, so it cannot ask \
201                 follow-up questions. Best for work that is well specified and can run \
202                 unattended, such as implementing a described change, investigating a codebase \
203                 question, or writing a set of tests. Prefer the direct tools for a single file \
204                 read or one shell command."
205                .to_string(),
206            parameters: serde_json::json!({
207                "type": "object",
208                "properties": {
209                    "prompt": {
210                        "type": "string",
211                        "description": "The complete, self-contained task for the worker agent"
212                    },
213                    "cwd": {
214                        "type": "string",
215                        "description": "Working directory, must be inside the workspace (defaults to workspace)"
216                    },
217                    "timeout": {
218                        "type": "integer",
219                        "description": "Timeout in seconds (default 600)"
220                    }
221                },
222                "required": ["prompt"]
223            }),
224        }
225    }
226
227    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
228        if !self.policy.allow_shell {
229            return ExecutionPolicy::deny(
230                "Shell execution is disabled by policy, and telekinesis runs commands",
231            );
232        }
233
234        let args: TelekinesisArgs = serde_json::from_str(arguments)?;
235
236        if args.prompt.trim().is_empty() {
237            return Ok(ToolResult::error(
238                "No task given. Provide a complete, self-contained brief in 'prompt'.",
239            ));
240        }
241
242        let cwd = match self.resolve_cwd(args.cwd.as_deref()) {
243            Ok(dir) => dir,
244            Err(message) => return Ok(ToolResult::error(message)),
245        };
246
247        let Some(binary) = self.find_binary().await else {
248            return Ok(ToolResult::error(format!(
249                "telekinesis is not installed: no '{}' binary next to apollo or on PATH. \
250                 Install it from https://github.com/tschk/telekinesis, or put `tk` on PATH.",
251                self.binary
252            )));
253        };
254
255        // Model-supplied, so bounded: a worker that never returns must not park
256        // the agent loop indefinitely.
257        let timeout = args
258            .timeout
259            .unwrap_or(self.timeout_secs)
260            .clamp(1, MAX_TIMEOUT_SECS);
261
262        // The prompt is deliberately never logged — AGENTS.md 3.4 forbids
263        // logging message content.
264        let mut command = tokio::process::Command::new(&binary);
265        command
266            .arg("exec")
267            .arg(&args.prompt)
268            .arg("--json")
269            .arg("--cwd")
270            .arg(&cwd)
271            .current_dir(&cwd)
272            .stdin(std::process::Stdio::null())
273            .stdout(std::process::Stdio::piped())
274            .stderr(std::process::Stdio::piped())
275            // Without this a timed-out worker keeps editing the workspace
276            // while the model retries, and two workers race on the same files.
277            .kill_on_drop(true);
278        child_proc::scrub(&mut command);
279
280        let child = match command.spawn() {
281            Ok(child) => child,
282            Err(e) => {
283                return Ok(ToolResult::error(format!(
284                    "Failed to start telekinesis: {e}"
285                )))
286            }
287        };
288        let mut child = child;
289
290        let output =
291            match child_proc::wait_with_timeout(&mut child, Duration::from_secs(timeout)).await {
292                Ok(Some(output)) => output,
293                Ok(None) => {
294                    return Ok(ToolResult::error(format!(
295                        "Delegated task timed out after {timeout}s"
296                    )))
297                }
298                Err(e) => return Ok(ToolResult::error(format!("telekinesis failed to run: {e}"))),
299            };
300
301        let stdout = String::from_utf8_lossy(&output.stdout);
302        let stderr = String::from_utf8_lossy(&output.stderr);
303        Ok(interpret(&stdout, &stderr, output.status.success()))
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn tool(workspace: PathBuf) -> TelekinesisTool {
312        TelekinesisTool::new(workspace, Arc::new(ExecutionPolicy::default()))
313    }
314
315    #[tokio::test]
316    async fn is_refused_when_shell_is_disabled_by_policy() {
317        let dir = tempfile::tempdir().unwrap();
318        let policy = Arc::new(ExecutionPolicy {
319            allow_shell: false,
320            ..ExecutionPolicy::default()
321        });
322        let tool = TelekinesisTool::new(dir.path().to_path_buf(), policy);
323        let args = serde_json::json!({ "prompt": "run something" }).to_string();
324        let result = Tool::execute(&tool, &args).await.unwrap();
325        assert!(result.is_error);
326        assert!(
327            result.output.to_lowercase().contains("policy"),
328            "got: {}",
329            result.output
330        );
331    }
332
333    /// A worker that ignores its deadline must be killed, not left editing the
334    /// workspace while the model retries.
335    #[tokio::test]
336    async fn a_timed_out_worker_is_killed_not_leaked() {
337        let mut command = tokio::process::Command::new("bash");
338        command
339            .arg("-c")
340            .arg("sleep 60")
341            .stdout(std::process::Stdio::piped())
342            .stderr(std::process::Stdio::piped())
343            .kill_on_drop(true);
344        let mut child = command.spawn().unwrap();
345        let pid = child.id().expect("child has a pid");
346
347        let output = child_proc::wait_with_timeout(&mut child, Duration::from_millis(200))
348            .await
349            .unwrap();
350        assert!(output.is_none(), "must report a timeout");
351
352        // The kill has been issued and awaited; reaping is what remains.
353        let status = child.wait().await.unwrap();
354        assert!(!status.success(), "killed child must not report success");
355        let alive = std::process::Command::new("kill")
356            .args(["-0", &pid.to_string()])
357            .stderr(std::process::Stdio::null())
358            .status()
359            .unwrap()
360            .success();
361        assert!(!alive, "pid {pid} must not still be running");
362    }
363
364    #[tokio::test]
365    async fn missing_binary_reports_a_clear_error_without_panicking() {
366        let dir = tempfile::tempdir().unwrap();
367        let tool =
368            tool(dir.path().to_path_buf()).with_binary("tk-definitely-not-installed-apollo-test");
369        let args = serde_json::json!({ "prompt": "do a thing" }).to_string();
370
371        let result = Tool::execute(&tool, &args).await.unwrap();
372        assert!(result.is_error);
373        assert!(
374            result.output.contains("telekinesis is not installed"),
375            "got: {}",
376            result.output
377        );
378    }
379
380    #[tokio::test]
381    async fn cwd_outside_the_workspace_is_refused() {
382        let workspace = tempfile::tempdir().unwrap();
383        let outside = tempfile::tempdir().unwrap();
384        let tool = tool(workspace.path().to_path_buf());
385        let args = serde_json::json!({
386            "prompt": "do a thing",
387            "cwd": outside.path().to_str().unwrap(),
388        })
389        .to_string();
390
391        let result = Tool::execute(&tool, &args).await.unwrap();
392        assert!(result.is_error);
393        assert!(
394            result.output.contains("outside the workspace"),
395            "got: {}",
396            result.output
397        );
398    }
399
400    /// The workspace check must run before the binary lookup, so an escape is
401    /// refused whether or not `tk` happens to be installed on this machine.
402    #[tokio::test]
403    async fn cwd_escape_is_refused_regardless_of_binary_presence() {
404        let workspace = tempfile::tempdir().unwrap();
405        let tool =
406            tool(workspace.path().to_path_buf()).with_binary("tk-definitely-not-installed-apollo");
407        let args = serde_json::json!({ "prompt": "x", "cwd": "/etc" }).to_string();
408
409        let result = Tool::execute(&tool, &args).await.unwrap();
410        assert!(result.output.contains("outside the workspace"));
411    }
412
413    #[tokio::test]
414    async fn relative_cwd_inside_the_workspace_is_allowed() {
415        let workspace = tempfile::tempdir().unwrap();
416        std::fs::create_dir(workspace.path().join("sub")).unwrap();
417        let tool = tool(workspace.path().to_path_buf());
418        assert!(tool.resolve_cwd(Some("sub")).is_ok());
419    }
420
421    #[tokio::test]
422    async fn empty_prompt_is_rejected() {
423        let dir = tempfile::tempdir().unwrap();
424        let args = serde_json::json!({ "prompt": "   " }).to_string();
425        let result = Tool::execute(&tool(dir.path().to_path_buf()), &args)
426            .await
427            .unwrap();
428        assert!(result.is_error);
429        assert!(result.output.contains("No task given"));
430    }
431
432    #[test]
433    fn parses_the_ok_shape() {
434        let result = interpret(r#"{"ok":true,"text":"all done","error":null}"#, "", true);
435        assert!(!result.is_error);
436        assert_eq!(result.output, "all done");
437    }
438
439    #[test]
440    fn parses_the_error_shape() {
441        let result = interpret(
442            r#"{"ok":false,"text":"","error":"provider auth failed"}"#,
443            "some stderr noise",
444            false,
445        );
446        assert!(result.is_error);
447        assert!(result.output.contains("provider auth failed"));
448    }
449
450    #[test]
451    fn error_shape_keeps_partial_text() {
452        let result = interpret(
453            r#"{"ok":false,"text":"got partway","error":"ran out of turns"}"#,
454            "",
455            false,
456        );
457        assert!(result.is_error);
458        assert!(result.output.contains("ran out of turns"));
459        assert!(result.output.contains("got partway"));
460    }
461
462    #[test]
463    fn failure_without_a_message_still_reads_as_an_error() {
464        let result = interpret(r#"{"ok":false}"#, "", false);
465        assert!(result.is_error);
466        assert!(result.output.contains("without a message"));
467    }
468
469    #[test]
470    fn non_json_stdout_falls_back_to_stderr_detail() {
471        let result = interpret("not json at all", "worker exploded", false);
472        assert!(result.is_error);
473        assert!(result.output.contains("worker exploded"));
474    }
475
476    #[test]
477    fn non_json_stdout_on_a_clean_exit_is_still_returned() {
478        let result = interpret("plain prose answer", "", true);
479        assert!(!result.is_error);
480        assert_eq!(result.output, "plain prose answer");
481    }
482
483    /// End-to-end against a real `tk`. Ignored by default: the test suite must
484    /// pass on a machine without telekinesis installed. A provider auth or
485    /// credit failure still exercises the contract, since it arrives as the
486    /// documented `{"ok":false,...}` object.
487    #[tokio::test]
488    #[ignore = "requires telekinesis (tk) on PATH and a working provider login"]
489    async fn delegates_to_a_real_worker() {
490        let workspace = std::env::current_dir().unwrap();
491        let tool =
492            TelekinesisTool::new(workspace, Arc::new(ExecutionPolicy::default())).with_timeout(180);
493        let args = serde_json::json!({ "prompt": "Reply with exactly the word PONG." }).to_string();
494
495        let result = Tool::execute(&tool, &args).await.unwrap();
496        assert!(
497            !result.output.is_empty(),
498            "a real run must report something back"
499        );
500        assert!(
501            !result.output.contains("no parseable result"),
502            "tk exec --json must yield the documented object, got: {}",
503            result.output
504        );
505    }
506
507    #[test]
508    fn long_output_is_truncated_by_chars_not_bytes() {
509        let long = "日".repeat(30_000);
510        let payload = serde_json::json!({ "ok": true, "text": long }).to_string();
511        let result = interpret(&payload, "", true);
512        assert!(!result.is_error);
513        assert!(result.output.chars().count() < 21_000);
514        assert!(result.output.contains("[truncated 10000 chars]"));
515    }
516}