aloop 0.1.1__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.
- agent/__init__.py +0 -0
- agent/agent.py +182 -0
- agent/base.py +406 -0
- agent/context.py +126 -0
- agent/prompts/__init__.py +1 -0
- agent/todo.py +149 -0
- agent/tool_executor.py +54 -0
- agent/verification.py +135 -0
- aloop-0.1.1.dist-info/METADATA +252 -0
- aloop-0.1.1.dist-info/RECORD +66 -0
- aloop-0.1.1.dist-info/WHEEL +5 -0
- aloop-0.1.1.dist-info/entry_points.txt +2 -0
- aloop-0.1.1.dist-info/licenses/LICENSE +21 -0
- aloop-0.1.1.dist-info/top_level.txt +9 -0
- cli.py +19 -0
- config.py +146 -0
- interactive.py +865 -0
- llm/__init__.py +51 -0
- llm/base.py +26 -0
- llm/compat.py +226 -0
- llm/content_utils.py +309 -0
- llm/litellm_adapter.py +450 -0
- llm/message_types.py +245 -0
- llm/model_manager.py +265 -0
- llm/retry.py +95 -0
- main.py +246 -0
- memory/__init__.py +20 -0
- memory/compressor.py +554 -0
- memory/manager.py +538 -0
- memory/serialization.py +82 -0
- memory/short_term.py +88 -0
- memory/store/__init__.py +6 -0
- memory/store/memory_store.py +100 -0
- memory/store/yaml_file_memory_store.py +414 -0
- memory/token_tracker.py +203 -0
- memory/types.py +51 -0
- tools/__init__.py +6 -0
- tools/advanced_file_ops.py +557 -0
- tools/base.py +51 -0
- tools/calculator.py +50 -0
- tools/code_navigator.py +975 -0
- tools/explore.py +254 -0
- tools/file_ops.py +150 -0
- tools/git_tools.py +791 -0
- tools/notify.py +69 -0
- tools/parallel_execute.py +420 -0
- tools/session_manager.py +205 -0
- tools/shell.py +147 -0
- tools/shell_background.py +470 -0
- tools/smart_edit.py +491 -0
- tools/todo.py +130 -0
- tools/web_fetch.py +673 -0
- tools/web_search.py +61 -0
- utils/__init__.py +15 -0
- utils/logger.py +105 -0
- utils/model_pricing.py +49 -0
- utils/runtime.py +75 -0
- utils/terminal_ui.py +422 -0
- utils/tui/__init__.py +39 -0
- utils/tui/command_registry.py +49 -0
- utils/tui/components.py +306 -0
- utils/tui/input_handler.py +393 -0
- utils/tui/model_ui.py +204 -0
- utils/tui/progress.py +292 -0
- utils/tui/status_bar.py +178 -0
- 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}'"
|