aloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aloop might be problematic. Click here for more details.

Files changed (62) hide show
  1. agent/__init__.py +0 -0
  2. agent/agent.py +182 -0
  3. agent/base.py +406 -0
  4. agent/context.py +126 -0
  5. agent/todo.py +149 -0
  6. agent/tool_executor.py +54 -0
  7. agent/verification.py +135 -0
  8. aloop-0.1.0.dist-info/METADATA +246 -0
  9. aloop-0.1.0.dist-info/RECORD +62 -0
  10. aloop-0.1.0.dist-info/WHEEL +5 -0
  11. aloop-0.1.0.dist-info/entry_points.txt +2 -0
  12. aloop-0.1.0.dist-info/licenses/LICENSE +21 -0
  13. aloop-0.1.0.dist-info/top_level.txt +9 -0
  14. cli.py +19 -0
  15. config.py +146 -0
  16. interactive.py +865 -0
  17. llm/__init__.py +51 -0
  18. llm/base.py +26 -0
  19. llm/compat.py +226 -0
  20. llm/content_utils.py +309 -0
  21. llm/litellm_adapter.py +450 -0
  22. llm/message_types.py +245 -0
  23. llm/model_manager.py +265 -0
  24. llm/retry.py +95 -0
  25. main.py +246 -0
  26. memory/__init__.py +20 -0
  27. memory/compressor.py +554 -0
  28. memory/manager.py +538 -0
  29. memory/serialization.py +82 -0
  30. memory/short_term.py +88 -0
  31. memory/token_tracker.py +203 -0
  32. memory/types.py +51 -0
  33. tools/__init__.py +6 -0
  34. tools/advanced_file_ops.py +557 -0
  35. tools/base.py +51 -0
  36. tools/calculator.py +50 -0
  37. tools/code_navigator.py +975 -0
  38. tools/explore.py +254 -0
  39. tools/file_ops.py +150 -0
  40. tools/git_tools.py +791 -0
  41. tools/notify.py +69 -0
  42. tools/parallel_execute.py +420 -0
  43. tools/session_manager.py +205 -0
  44. tools/shell.py +147 -0
  45. tools/shell_background.py +470 -0
  46. tools/smart_edit.py +491 -0
  47. tools/todo.py +130 -0
  48. tools/web_fetch.py +673 -0
  49. tools/web_search.py +61 -0
  50. utils/__init__.py +15 -0
  51. utils/logger.py +105 -0
  52. utils/model_pricing.py +49 -0
  53. utils/runtime.py +75 -0
  54. utils/terminal_ui.py +422 -0
  55. utils/tui/__init__.py +39 -0
  56. utils/tui/command_registry.py +49 -0
  57. utils/tui/components.py +306 -0
  58. utils/tui/input_handler.py +393 -0
  59. utils/tui/model_ui.py +204 -0
  60. utils/tui/progress.py +292 -0
  61. utils/tui/status_bar.py +178 -0
  62. utils/tui/theme.py +165 -0
tools/shell.py ADDED
@@ -0,0 +1,147 @@
1
+ """Shell command execution tool."""
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING, Any, Dict, Optional
5
+
6
+ from .base import BaseTool
7
+
8
+ if TYPE_CHECKING:
9
+ from .shell_background import BackgroundTaskManager
10
+
11
+
12
+ class ShellTool(BaseTool):
13
+ """Execute shell commands with automatic background execution for long-running tasks."""
14
+
15
+ DEFAULT_TIMEOUT = 10.0 # Default timeout before moving to background
16
+ MAX_WAIT_TIMEOUT = 600.0 # Maximum timeout when wait_for_completion is True
17
+
18
+ def __init__(self, task_manager: Optional["BackgroundTaskManager"] = None) -> None:
19
+ """Initialize the shell tool.
20
+
21
+ Args:
22
+ task_manager: Optional background task manager for handling long-running commands.
23
+ If not provided, will use the singleton instance when needed.
24
+ """
25
+ self._task_manager = task_manager
26
+
27
+ @property
28
+ def task_manager(self) -> "BackgroundTaskManager":
29
+ """Get the task manager instance."""
30
+ if self._task_manager is None:
31
+ from .shell_background import BackgroundTaskManager
32
+
33
+ self._task_manager = BackgroundTaskManager.get_instance()
34
+ return self._task_manager
35
+
36
+ @property
37
+ def name(self) -> str:
38
+ return "shell"
39
+
40
+ @property
41
+ def description(self) -> str:
42
+ return (
43
+ "Execute shell commands. Returns stdout/stderr. "
44
+ "Commands that don't complete within the timeout are automatically "
45
+ "moved to background execution, returning a task_id for status tracking. "
46
+ "Use shell_task_status tool to check on background tasks."
47
+ )
48
+
49
+ @property
50
+ def parameters(self) -> Dict[str, Any]:
51
+ return {
52
+ "command": {
53
+ "type": "string",
54
+ "description": "Shell command to execute",
55
+ },
56
+ "timeout": {
57
+ "type": "number",
58
+ "description": (
59
+ "Timeout in seconds before moving to background execution. "
60
+ "Default is 10 seconds."
61
+ ),
62
+ "default": 10.0,
63
+ },
64
+ "wait_for_completion": {
65
+ "type": "boolean",
66
+ "description": (
67
+ "If true, wait up to 600 seconds for completion instead of "
68
+ "moving to background. Use for commands that must complete synchronously."
69
+ ),
70
+ "default": False,
71
+ },
72
+ }
73
+
74
+ async def execute(
75
+ self,
76
+ command: str,
77
+ timeout: float = 10.0,
78
+ wait_for_completion: bool = False,
79
+ ) -> str:
80
+ """Execute shell command and return output.
81
+
82
+ Args:
83
+ command: Shell command to execute
84
+ timeout: Timeout in seconds before moving to background (default: 10)
85
+ wait_for_completion: If True, wait up to 600s instead of backgrounding
86
+
87
+ Returns:
88
+ Command output, or task_id info if moved to background
89
+ """
90
+ try:
91
+ process = await asyncio.create_subprocess_shell(
92
+ command,
93
+ stdout=asyncio.subprocess.PIPE,
94
+ stderr=asyncio.subprocess.PIPE,
95
+ )
96
+
97
+ # Determine actual timeout (minimum 1 second if not waiting for completion)
98
+ actual_timeout = self.MAX_WAIT_TIMEOUT if wait_for_completion else max(timeout, 1.0)
99
+
100
+ try:
101
+ stdout, stderr = await asyncio.wait_for(
102
+ process.communicate(), timeout=actual_timeout
103
+ )
104
+ except TimeoutError:
105
+ if wait_for_completion:
106
+ # Even with wait_for_completion, we hit max timeout
107
+ process.kill()
108
+ await process.communicate()
109
+ return (
110
+ f"Error: Command timed out after {self.MAX_WAIT_TIMEOUT} seconds "
111
+ "(even with wait_for_completion=True)"
112
+ )
113
+
114
+ # Move to background execution
115
+ task_id = await self.task_manager.submit_task(
116
+ command=command,
117
+ process=process,
118
+ timeout=self.MAX_WAIT_TIMEOUT, # Background tasks get extended timeout
119
+ )
120
+
121
+ return (
122
+ f"Command is taking longer than {timeout}s and has been moved to background.\n"
123
+ f"Task ID: {task_id}\n"
124
+ f"Use shell_task_status tool with operation='status' or 'output' to check progress."
125
+ )
126
+
127
+ # Command completed within timeout
128
+ stdout_text = stdout.decode() if stdout else ""
129
+ stderr_text = stderr.decode() if stderr else ""
130
+ output = stdout_text + stderr_text if stderr_text else stdout_text
131
+
132
+ if not output:
133
+ return "Command executed successfully (no output)"
134
+
135
+ # Check output size
136
+ estimated_tokens = len(output) // self.CHARS_PER_TOKEN
137
+ if estimated_tokens > self.MAX_TOKENS:
138
+ return (
139
+ f"Error: Command output (~{estimated_tokens} tokens) exceeds "
140
+ f"maximum allowed ({self.MAX_TOKENS}). Please pipe output through "
141
+ f"head/tail/grep, or redirect to a file and read specific portions."
142
+ )
143
+
144
+ return output
145
+
146
+ except Exception as e:
147
+ return f"Error executing command: {str(e)}"
@@ -0,0 +1,470 @@
1
+ """Background task management for shell command execution."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import Any, Dict, Optional
10
+
11
+ from .base import BaseTool
12
+
13
+
14
+ class TaskStatus(Enum):
15
+ """Status of a background task."""
16
+
17
+ RUNNING = "running"
18
+ COMPLETED = "completed"
19
+ FAILED = "failed"
20
+ TIMEOUT = "timeout"
21
+ CANCELLED = "cancelled"
22
+
23
+
24
+ @dataclass
25
+ class BackgroundTask:
26
+ """Represents a background shell task."""
27
+
28
+ task_id: str
29
+ command: str
30
+ status: TaskStatus = TaskStatus.RUNNING
31
+ stdout: str = ""
32
+ stderr: str = ""
33
+ exit_code: Optional[int] = None
34
+ created_at: float = field(default_factory=time.time)
35
+ completed_at: Optional[float] = None
36
+ process: Optional[asyncio.subprocess.Process] = field(default=None, repr=False)
37
+
38
+
39
+ class BackgroundTaskManager:
40
+ """Manages background shell tasks with automatic cleanup."""
41
+
42
+ MAX_TASKS = 100
43
+ TASK_EXPIRY_SECONDS = 3600 # 1 hour
44
+
45
+ _instance: Optional["BackgroundTaskManager"] = None
46
+ _lock = asyncio.Lock()
47
+
48
+ def __init__(self) -> None:
49
+ """Initialize the task manager."""
50
+ self._tasks: Dict[str, BackgroundTask] = {}
51
+ self._monitor_tasks: Dict[str, asyncio.Task] = {}
52
+
53
+ async def shutdown(self) -> None:
54
+ """Best-effort shutdown to prevent leaking subprocess transports.
55
+
56
+ This is primarily intended for tests and graceful teardown. It cancels
57
+ any monitor tasks and awaits them so they can kill/wait subprocesses.
58
+ """
59
+
60
+ monitors = list(self._monitor_tasks.values())
61
+ for monitor in monitors:
62
+ monitor.cancel()
63
+
64
+ # Await monitor completion so their cancellation cleanup runs.
65
+ for monitor in monitors:
66
+ with contextlib.suppress(asyncio.CancelledError, Exception):
67
+ await monitor
68
+
69
+ # As an extra safety net, ensure any still-attached processes are killed.
70
+ for task in list(self._tasks.values()):
71
+ proc = task.process
72
+ if proc is None:
73
+ continue
74
+ with contextlib.suppress(Exception):
75
+ proc.kill()
76
+ with contextlib.suppress(Exception):
77
+ # Use communicate() to consume stdout/stderr pipes, preventing
78
+ # transport leaks when the event loop closes.
79
+ await asyncio.wait_for(proc.communicate(), timeout=1.0)
80
+ # Explicitly close the transport to prevent warnings when GC runs
81
+ # after the event loop is closed.
82
+ with contextlib.suppress(Exception):
83
+ if hasattr(proc, "_transport") and proc._transport:
84
+ proc._transport.close()
85
+ task.process = None
86
+
87
+ @classmethod
88
+ def get_instance(cls) -> "BackgroundTaskManager":
89
+ """Get the singleton instance of the task manager."""
90
+ if cls._instance is None:
91
+ cls._instance = cls()
92
+ return cls._instance
93
+
94
+ @classmethod
95
+ async def reset_instance(cls) -> None:
96
+ """Reset the singleton instance (for testing)."""
97
+ if cls._instance is not None:
98
+ await cls._instance.shutdown()
99
+ cls._instance = None
100
+
101
+ async def submit_task(
102
+ self,
103
+ command: str,
104
+ process: asyncio.subprocess.Process,
105
+ timeout: Optional[float] = None,
106
+ ) -> str:
107
+ """Submit a running process as a background task.
108
+
109
+ Args:
110
+ command: The command being executed
111
+ process: The running subprocess
112
+ timeout: Optional timeout for the background task (default: no timeout)
113
+
114
+ Returns:
115
+ Task ID for tracking
116
+ """
117
+ await self._cleanup_old_tasks()
118
+
119
+ task_id = str(uuid.uuid4())[:8]
120
+ task = BackgroundTask(
121
+ task_id=task_id,
122
+ command=command,
123
+ process=process,
124
+ )
125
+ self._tasks[task_id] = task
126
+
127
+ # Start monitoring the task
128
+ monitor = asyncio.create_task(self._monitor_task(task_id, timeout))
129
+ self._monitor_tasks[task_id] = monitor
130
+
131
+ return task_id
132
+
133
+ async def _monitor_task(self, task_id: str, timeout: Optional[float] = None) -> None:
134
+ """Monitor a background task until completion.
135
+
136
+ Args:
137
+ task_id: The task to monitor
138
+ timeout: Optional timeout in seconds
139
+ """
140
+ task = self._tasks.get(task_id)
141
+ if not task or not task.process:
142
+ return
143
+
144
+ try:
145
+ if timeout:
146
+ stdout, stderr = await asyncio.wait_for(task.process.communicate(), timeout=timeout)
147
+ else:
148
+ stdout, stderr = await task.process.communicate()
149
+
150
+ task.stdout = stdout.decode() if stdout else ""
151
+ task.stderr = stderr.decode() if stderr else ""
152
+ task.exit_code = task.process.returncode
153
+ task.completed_at = time.time()
154
+
155
+ if task.exit_code == 0:
156
+ task.status = TaskStatus.COMPLETED
157
+ else:
158
+ task.status = TaskStatus.FAILED
159
+
160
+ except asyncio.TimeoutError:
161
+ task.status = TaskStatus.TIMEOUT
162
+ task.completed_at = time.time()
163
+ if task.process:
164
+ task.process.kill()
165
+ with contextlib.suppress(Exception):
166
+ await task.process.communicate()
167
+ # Explicitly close the transport
168
+ with contextlib.suppress(Exception):
169
+ if hasattr(task.process, "_transport") and task.process._transport:
170
+ task.process._transport.close()
171
+
172
+ except asyncio.CancelledError:
173
+ task.status = TaskStatus.CANCELLED
174
+ task.completed_at = time.time()
175
+ if task.process:
176
+ task.process.kill()
177
+ # Use communicate() to consume stdout/stderr pipes, preventing
178
+ # transport leaks when the event loop closes.
179
+ with contextlib.suppress(Exception):
180
+ await asyncio.wait_for(task.process.communicate(), timeout=1.0)
181
+ # Explicitly close the transport
182
+ with contextlib.suppress(Exception):
183
+ if hasattr(task.process, "_transport") and task.process._transport:
184
+ task.process._transport.close()
185
+ # Re-raise to properly propagate cancellation
186
+ raise
187
+
188
+ except Exception as e:
189
+ task.status = TaskStatus.FAILED
190
+ task.stderr = str(e)
191
+ task.completed_at = time.time()
192
+
193
+ finally:
194
+ # Clear process reference
195
+ task.process = None
196
+ # Remove from monitor tasks
197
+ self._monitor_tasks.pop(task_id, None)
198
+
199
+ def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
200
+ """Get the status of a task.
201
+
202
+ Args:
203
+ task_id: The task ID to query
204
+
205
+ Returns:
206
+ Task status dict or None if not found
207
+ """
208
+ task = self._tasks.get(task_id)
209
+ if not task:
210
+ return None
211
+
212
+ return {
213
+ "task_id": task.task_id,
214
+ "command": task.command,
215
+ "status": task.status.value,
216
+ "exit_code": task.exit_code,
217
+ "created_at": task.created_at,
218
+ "completed_at": task.completed_at,
219
+ "has_output": bool(task.stdout or task.stderr),
220
+ }
221
+
222
+ def get_task_output(
223
+ self, task_id: str, max_chars: Optional[int] = None
224
+ ) -> Optional[Dict[str, Any]]:
225
+ """Get the output of a task.
226
+
227
+ Args:
228
+ task_id: The task ID to query
229
+ max_chars: Optional maximum characters to return
230
+
231
+ Returns:
232
+ Task output dict or None if not found
233
+ """
234
+ task = self._tasks.get(task_id)
235
+ if not task:
236
+ return None
237
+
238
+ stdout = task.stdout
239
+ stderr = task.stderr
240
+
241
+ if max_chars:
242
+ if len(stdout) > max_chars:
243
+ stdout = stdout[:max_chars] + f"\n... (truncated, {len(task.stdout)} total chars)"
244
+ if len(stderr) > max_chars:
245
+ stderr = stderr[:max_chars] + f"\n... (truncated, {len(task.stderr)} total chars)"
246
+
247
+ return {
248
+ "task_id": task.task_id,
249
+ "status": task.status.value,
250
+ "stdout": stdout,
251
+ "stderr": stderr,
252
+ "exit_code": task.exit_code,
253
+ }
254
+
255
+ async def cancel_task(self, task_id: str) -> bool:
256
+ """Cancel a running task.
257
+
258
+ Args:
259
+ task_id: The task ID to cancel
260
+
261
+ Returns:
262
+ True if cancelled, False if not found or already completed
263
+ """
264
+ task = self._tasks.get(task_id)
265
+ if not task:
266
+ return False
267
+
268
+ if task.status != TaskStatus.RUNNING:
269
+ return False
270
+
271
+ # Cancel the monitor task
272
+ monitor = self._monitor_tasks.get(task_id)
273
+ if monitor:
274
+ monitor.cancel()
275
+ with contextlib.suppress(asyncio.CancelledError):
276
+ await monitor
277
+
278
+ return True
279
+
280
+ def list_tasks(self, include_completed: bool = True) -> list[Dict[str, Any]]:
281
+ """List all tasks.
282
+
283
+ Args:
284
+ include_completed: Whether to include completed tasks
285
+
286
+ Returns:
287
+ List of task status dicts
288
+ """
289
+ tasks = []
290
+ for task in self._tasks.values():
291
+ if not include_completed and task.status != TaskStatus.RUNNING:
292
+ continue
293
+ status = self.get_task_status(task.task_id)
294
+ if status:
295
+ tasks.append(status)
296
+
297
+ # Sort by created_at descending
298
+ tasks.sort(key=lambda t: t["created_at"], reverse=True)
299
+ return tasks
300
+
301
+ async def _cleanup_old_tasks(self) -> None:
302
+ """Remove old completed tasks to prevent memory growth."""
303
+ now = time.time()
304
+ to_remove = []
305
+
306
+ for task_id, task in self._tasks.items():
307
+ # Remove completed tasks older than expiry time
308
+ if (
309
+ task.status != TaskStatus.RUNNING
310
+ and task.completed_at
311
+ and (now - task.completed_at) > self.TASK_EXPIRY_SECONDS
312
+ ):
313
+ to_remove.append(task_id)
314
+
315
+ # If we still have too many tasks, remove oldest completed ones
316
+ if len(self._tasks) - len(to_remove) > self.MAX_TASKS:
317
+ completed = [
318
+ (tid, t)
319
+ for tid, t in self._tasks.items()
320
+ if t.status != TaskStatus.RUNNING and tid not in to_remove
321
+ ]
322
+ completed.sort(key=lambda x: x[1].completed_at or 0)
323
+ excess = len(self._tasks) - len(to_remove) - self.MAX_TASKS
324
+ for tid, _ in completed[:excess]:
325
+ to_remove.append(tid)
326
+
327
+ for task_id in to_remove:
328
+ del self._tasks[task_id]
329
+
330
+
331
+ class ShellTaskStatusTool(BaseTool):
332
+ """Tool for querying background shell task status."""
333
+
334
+ def __init__(self, task_manager: Optional[BackgroundTaskManager] = None) -> None:
335
+ """Initialize the tool.
336
+
337
+ Args:
338
+ task_manager: Optional task manager instance (uses singleton if not provided)
339
+ """
340
+ self._task_manager = task_manager
341
+
342
+ @property
343
+ def task_manager(self) -> BackgroundTaskManager:
344
+ """Get the task manager instance."""
345
+ if self._task_manager is None:
346
+ self._task_manager = BackgroundTaskManager.get_instance()
347
+ return self._task_manager
348
+
349
+ @property
350
+ def name(self) -> str:
351
+ return "shell_task_status"
352
+
353
+ @property
354
+ def description(self) -> str:
355
+ return (
356
+ "Query status and output of background shell tasks. "
357
+ "Operations: 'status' (get task status), 'output' (get task output), "
358
+ "'list' (list all tasks), 'cancel' (cancel a running task)."
359
+ )
360
+
361
+ @property
362
+ def parameters(self) -> Dict[str, Any]:
363
+ return {
364
+ "operation": {
365
+ "type": "string",
366
+ "description": "Operation to perform: 'status', 'output', 'list', or 'cancel'",
367
+ "enum": ["status", "output", "list", "cancel"],
368
+ },
369
+ "task_id": {
370
+ "type": "string",
371
+ "description": "Task ID (required for status, output, cancel operations)",
372
+ "default": "",
373
+ },
374
+ "include_completed": {
375
+ "type": "boolean",
376
+ "description": "For 'list' operation: whether to include completed tasks",
377
+ "default": True,
378
+ },
379
+ }
380
+
381
+ async def execute(
382
+ self,
383
+ operation: str,
384
+ task_id: str = "",
385
+ include_completed: bool = True,
386
+ ) -> str:
387
+ """Execute the tool operation.
388
+
389
+ Args:
390
+ operation: The operation to perform
391
+ task_id: Task ID for status/output/cancel operations
392
+ include_completed: For list operation, whether to include completed tasks
393
+
394
+ Returns:
395
+ Operation result as string
396
+ """
397
+ if operation == "list":
398
+ tasks = self.task_manager.list_tasks(include_completed=include_completed)
399
+ if not tasks:
400
+ return "No background tasks found."
401
+
402
+ lines = ["Background tasks:"]
403
+ for t in tasks:
404
+ status_emoji = {
405
+ "running": "[RUNNING]",
406
+ "completed": "[DONE]",
407
+ "failed": "[FAILED]",
408
+ "timeout": "[TIMEOUT]",
409
+ "cancelled": "[CANCELLED]",
410
+ }.get(t["status"], "[?]")
411
+
412
+ cmd_preview = t["command"][:50] + "..." if len(t["command"]) > 50 else t["command"]
413
+ lines.append(f" {t['task_id']}: {status_emoji} {cmd_preview}")
414
+
415
+ return "\n".join(lines)
416
+
417
+ # Operations that require task_id
418
+ if not task_id:
419
+ return f"Error: task_id is required for '{operation}' operation"
420
+
421
+ if operation == "status":
422
+ status = self.task_manager.get_task_status(task_id)
423
+ if not status:
424
+ return f"Error: Task '{task_id}' not found"
425
+
426
+ lines = [
427
+ f"Task: {status['task_id']}",
428
+ f"Command: {status['command']}",
429
+ f"Status: {status['status']}",
430
+ ]
431
+ if status["exit_code"] is not None:
432
+ lines.append(f"Exit code: {status['exit_code']}")
433
+ if status["has_output"]:
434
+ lines.append("Output available: yes (use 'output' operation to retrieve)")
435
+
436
+ return "\n".join(lines)
437
+
438
+ elif operation == "output":
439
+ # Limit output to stay within token limits
440
+ max_chars = self.MAX_TOKENS * self.CHARS_PER_TOKEN
441
+ output = self.task_manager.get_task_output(task_id, max_chars=max_chars)
442
+ if not output:
443
+ return f"Error: Task '{task_id}' not found"
444
+
445
+ lines = [f"Task: {output['task_id']} ({output['status']})"]
446
+
447
+ if output["stdout"]:
448
+ lines.append(f"\n=== STDOUT ===\n{output['stdout']}")
449
+ if output["stderr"]:
450
+ lines.append(f"\n=== STDERR ===\n{output['stderr']}")
451
+ if not output["stdout"] and not output["stderr"]:
452
+ lines.append("\n(no output)")
453
+
454
+ if output["exit_code"] is not None:
455
+ lines.append(f"\nExit code: {output['exit_code']}")
456
+
457
+ return "\n".join(lines)
458
+
459
+ elif operation == "cancel":
460
+ cancelled = await self.task_manager.cancel_task(task_id)
461
+ if cancelled:
462
+ return f"Task '{task_id}' has been cancelled."
463
+ else:
464
+ status = self.task_manager.get_task_status(task_id)
465
+ if not status:
466
+ return f"Error: Task '{task_id}' not found"
467
+ return f"Cannot cancel task '{task_id}': status is '{status['status']}'"
468
+
469
+ else:
470
+ return f"Error: Unknown operation '{operation}'"