1use 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
23const DEFAULT_TIMEOUT_SECS: u64 = 600;
27
28const MAX_TIMEOUT_SECS: u64 = 3600;
30
31const MAX_OUTPUT_CHARS: usize = 20_000;
33
34pub struct TelekinesisTool {
35 workspace: PathBuf,
36 timeout_secs: u64,
37 policy: Arc<ExecutionPolicy>,
41 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 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 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 #[serde(alias = "task")]
122 prompt: String,
123 #[serde(alias = "workdir")]
125 cwd: Option<String>,
126 timeout: Option<u64>,
128}
129
130#[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
141fn 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
150fn 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 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 let timeout = args
258 .timeout
259 .unwrap_or(self.timeout_secs)
260 .clamp(1, MAX_TIMEOUT_SECS);
261
262 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 .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 #[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 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 #[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 #[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}