iridet-bot 0.1.1a1__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.
@@ -0,0 +1,572 @@
1
+ """Execute command tool with interactive shell sessions"""
2
+ import os
3
+ import subprocess
4
+ import platform
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from collections import deque
9
+ from typing import Any, Dict, Optional, Deque, Tuple, List
10
+ from .base import BaseTool, BaseToolGroup, BaseStatus
11
+ from ..config import settings
12
+
13
+
14
+ class ShellSession:
15
+ """Persistent shell session with async output capture"""
16
+
17
+ def __init__(self, working_dir: Optional[str] = None):
18
+ self.working_dir = working_dir
19
+ self._output: Deque[Tuple[str, str]] = deque()
20
+ self._log: Deque[Dict[str, str]] = deque()
21
+ self._output_event = threading.Event()
22
+ self._lock = threading.Lock()
23
+ self._running_marker: Optional[str] = None # Track if command is running
24
+
25
+ env = os.environ.copy()
26
+ env["PYTHONIOENCODING"] = "utf-8"
27
+
28
+ # Use UTF-8 encoding for both Windows and Unix
29
+ # Git Bash and Python both support UTF-8 well
30
+ encoding = "utf-8"
31
+
32
+ # Disable colors and set simple terminal to avoid ANSI escape sequences
33
+ env["TERM"] = "dumb"
34
+ env["PS1"] = "$ " # Simple prompt without colors
35
+ env["NO_COLOR"] = "1" # Disable colors in many tools
36
+ env["PYTHONUNBUFFERED"] = "1" # Disable Python output buffering
37
+
38
+ # Always use bash, get path from config
39
+ bash_cmd = settings.bash_path
40
+ # Use --norc --noprofile to avoid loading user configs that add colors
41
+ # Don't use -i to avoid "no job control" warnings in non-TTY environment
42
+ cmd = [bash_cmd, "--norc", "--noprofile"]
43
+
44
+ self.process = subprocess.Popen(
45
+ cmd,
46
+ stdin=subprocess.PIPE,
47
+ stdout=subprocess.PIPE,
48
+ stderr=subprocess.PIPE,
49
+ cwd=working_dir if working_dir else None,
50
+ text=True,
51
+ bufsize=1,
52
+ env=env,
53
+ encoding=encoding,
54
+ errors="replace",
55
+ )
56
+
57
+ self._stdout_thread = threading.Thread(
58
+ target=self._read_stream,
59
+ args=("stdout", self.process.stdout),
60
+ daemon=True,
61
+ )
62
+ self._stderr_thread = threading.Thread(
63
+ target=self._read_stream,
64
+ args=("stderr", self.process.stderr),
65
+ daemon=True,
66
+ )
67
+ self._stdout_thread.start()
68
+ self._stderr_thread.start()
69
+
70
+ def _read_stream(self, stream_name: str, stream):
71
+ try:
72
+ for line in iter(stream.readline, ""):
73
+ if line:
74
+ with self._lock:
75
+ self._output.append((stream_name, line))
76
+ self._log.append({"stream": stream_name, "data": line})
77
+ self._output_event.set()
78
+ except Exception:
79
+ pass
80
+ finally:
81
+ stream.close()
82
+
83
+ def is_alive(self) -> bool:
84
+ return self.process and self.process.poll() is None
85
+
86
+ def write(self, data: str) -> None:
87
+ if not self.is_alive():
88
+ raise RuntimeError("Shell session is not running")
89
+ if not data.endswith("\n"):
90
+ data += "\n"
91
+ with self._lock:
92
+ self._log.append({"stream": "stdin", "data": data})
93
+ self.process.stdin.write(data)
94
+ self.process.stdin.flush()
95
+
96
+ def read(self, wait_ms: int = 0, max_chars: int = 20000) -> Dict[str, Any]:
97
+ output = []
98
+ stderr = []
99
+
100
+ if wait_ms > 0:
101
+ self._output_event.wait(wait_ms / 1000)
102
+
103
+ with self._lock:
104
+ char_count = 0
105
+ while self._output and char_count <= max_chars:
106
+ stream_name, line = self._output.popleft()
107
+ char_count += len(line)
108
+ if stream_name == "stdout":
109
+ output.append(line)
110
+ else:
111
+ stderr.append(line)
112
+ self._output_event.clear()
113
+
114
+ return {
115
+ "stdout": "".join(output),
116
+ "stderr": "".join(stderr),
117
+ }
118
+
119
+ def terminate(self) -> None:
120
+ if self.is_alive():
121
+ self.process.terminate()
122
+
123
+ def get_log(self) -> list:
124
+ with self._lock:
125
+ return list(self._log)
126
+
127
+ def is_running(self) -> bool:
128
+ """Check if a command is currently running (marker not yet seen)"""
129
+ if self._running_marker is None:
130
+ return False
131
+
132
+ # Check if marker is already in the buffered output
133
+ # This handles the case where command completed but we didn't wait long enough
134
+ with self._lock:
135
+ new_output = deque()
136
+ marker_found = False
137
+ for stream_name, line in self._output:
138
+ if self._running_marker in line:
139
+ # Marker found in buffer, command has completed
140
+ marker_found = True
141
+ # Remove the marker line from output
142
+ continue
143
+ new_output.append((stream_name, line))
144
+
145
+ if marker_found:
146
+ self._output = new_output
147
+ self._running_marker = None
148
+ return False
149
+
150
+ return True
151
+
152
+ def set_running_marker(self, marker: str) -> None:
153
+ """Set the marker for currently running command"""
154
+ with self._lock:
155
+ self._running_marker = marker
156
+
157
+ def clear_running_marker(self) -> None:
158
+ """Clear the running marker when command completes"""
159
+ with self._lock:
160
+ self._running_marker = None
161
+
162
+
163
+ _shell_sessions: Dict[str, ShellSession] = {}
164
+
165
+
166
+ def _collect_output_until_marker(
167
+ session: ShellSession,
168
+ marker: str,
169
+ wait_ms: int,
170
+ max_chars: int,
171
+ ) -> Tuple[str, str, bool]:
172
+ """
173
+ Collect output from session until marker is found or timeout.
174
+
175
+ Returns: (stdout, stderr, marker_found)
176
+ """
177
+ max_wait_time = wait_ms if wait_ms > 0 else 100000
178
+ start_time = time.time() * 1000
179
+ all_stdout = []
180
+ all_stderr = []
181
+ marker_found = False
182
+
183
+ while (time.time() * 1000 - start_time) < max_wait_time:
184
+ output = session.read(wait_ms=100, max_chars=max_chars)
185
+ stdout_chunk = output.get("stdout", "")
186
+ stderr_chunk = output.get("stderr", "")
187
+
188
+ if marker in stdout_chunk:
189
+ # Remove the marker and everything after it (including the marker line)
190
+ lines = stdout_chunk.split('\n')
191
+ filtered_lines = []
192
+ for line in lines:
193
+ if marker in line:
194
+ marker_found = True
195
+ break
196
+ filtered_lines.append(line)
197
+ stdout_chunk = '\n'.join(filtered_lines)
198
+ # Add final newline if there were lines
199
+ if filtered_lines and stdout_chunk and not stdout_chunk.endswith('\n'):
200
+ stdout_chunk += '\n'
201
+
202
+ if stdout_chunk:
203
+ all_stdout.append(stdout_chunk)
204
+ if stderr_chunk:
205
+ all_stderr.append(stderr_chunk)
206
+
207
+ if marker_found:
208
+ # Clear running marker when command completes
209
+ session.clear_running_marker()
210
+ break
211
+
212
+ if not stdout_chunk and not stderr_chunk:
213
+ time.sleep(0.01)
214
+
215
+ return "".join(all_stdout), "".join(all_stderr), marker_found
216
+
217
+
218
+ def _ensure_session(session_id: str, working_dir: Optional[str] = None) -> ShellSession:
219
+ if session_id not in _shell_sessions or not _shell_sessions[session_id].is_alive():
220
+ _shell_sessions[session_id] = ShellSession(working_dir=working_dir)
221
+ if working_dir:
222
+ _shell_sessions[session_id].write(f'cd "{working_dir}"')
223
+ return _shell_sessions[session_id]
224
+
225
+
226
+ def _get_sessions_status() -> List[Dict[str, Any]]:
227
+ sessions = []
228
+ for session_id, session in _shell_sessions.items():
229
+ sessions.append({
230
+ "session_id": session_id,
231
+ "working_dir": session.working_dir,
232
+ "alive": session.is_alive(),
233
+ "pid": session.process.pid if session.process else None,
234
+ "log": session.get_log(),
235
+ })
236
+ return sessions
237
+
238
+
239
+ class ShellStartTool(BaseTool):
240
+ """Start a persistent shell session"""
241
+
242
+ @property
243
+ def name(self) -> str:
244
+ return "shell_start"
245
+
246
+ @property
247
+ def description(self) -> str:
248
+ return "Start a persistent bash shell session."
249
+
250
+ @property
251
+ def parameters(self) -> Dict[str, Any]:
252
+ return {
253
+ "type": "object",
254
+ "properties": {
255
+ "session_id": {
256
+ "type": "string",
257
+ "description": "Session/agent identifier for persistent shell",
258
+ },
259
+ "working_dir": {
260
+ "type": "string",
261
+ "description": "Working directory to start shell (optional)",
262
+ },
263
+ },
264
+ "required": [],
265
+ }
266
+
267
+ def execute(
268
+ self,
269
+ session_id: Optional[str] = None,
270
+ working_dir: Optional[str] = None,
271
+ ) -> Dict[str, Any]:
272
+ session_id = session_id or "default"
273
+ _ensure_session(session_id, working_dir=working_dir)
274
+ return {
275
+ "success": True,
276
+ "session_id": session_id,
277
+ "status": "started",
278
+ }
279
+
280
+
281
+ class ShellRunTool(BaseTool):
282
+ """Run a command in a shell session"""
283
+
284
+ @property
285
+ def name(self) -> str:
286
+ return "shell_run"
287
+
288
+ @property
289
+ def description(self) -> str:
290
+ return "Run a command in a persistent bash session. **IMPORTANT**: If 'background' is set to true, the command will run in the background and the tool will return immediately. This will occupy the shell session; start a new session for other commands."
291
+
292
+ @property
293
+ def parameters(self) -> Dict[str, Any]:
294
+ return {
295
+ "type": "object",
296
+ "properties": {
297
+ "session_id": {
298
+ "type": "string",
299
+ "description": "Session/agent identifier for persistent shell",
300
+ },
301
+ "command": {
302
+ "type": "string",
303
+ "description": "Command to run",
304
+ },
305
+ "wait_ms": {
306
+ "type": "integer",
307
+ "description": "Max wait time in milliseconds for command completion",
308
+ },
309
+ "max_chars": {
310
+ "type": "integer",
311
+ "description": "Max characters to return from buffered output",
312
+ "default": 20000,
313
+ },
314
+ "background": {
315
+ "type": "boolean",
316
+ "description": "Run command in background and return immediately. **IMPORTANT** This will occupy the shell session; start a new session for other commands.",
317
+ "default": False,
318
+ },
319
+ "working_dir": {
320
+ "type": "string",
321
+ "description": "Working directory to start shell (optional)",
322
+ },
323
+ },
324
+ "required": ["command", "background"],
325
+ }
326
+
327
+ def execute(
328
+ self,
329
+ command: str,
330
+ session_id: Optional[str] = None,
331
+ wait_ms: Optional[int] = None,
332
+ max_chars: int = 20000,
333
+ background: bool = False,
334
+ working_dir: Optional[str] = None,
335
+ ) -> Dict[str, Any]:
336
+ session_id = session_id or "default"
337
+ session = _ensure_session(session_id, working_dir=working_dir)
338
+
339
+ # Check if session is already running a command
340
+ if session.is_running():
341
+ return {
342
+ "success": False,
343
+ "error": f"Session '{session_id}' is already running a command. Please wait for it to complete, use shell_read to check status, or start a new session to run another command.",
344
+ "session_id": session_id,
345
+ }
346
+
347
+ if wait_ms is None:
348
+ wait_ms = 10000 if background else 100000
349
+
350
+ marker = f"__CMD_DONE_{uuid.uuid4().hex[:8]}__"
351
+
352
+ # Mark session as running before executing command
353
+ session.set_running_marker(marker)
354
+
355
+ session.write(command)
356
+ session.write(f"echo {marker}")
357
+
358
+ if background:
359
+ # For background mode, wait for marker with a reasonable timeout
360
+ # If marker is found, command completed and marker will be cleared
361
+ # If marker is not found, command is still running, keep marker set
362
+ # (is_running() will check buffer for marker before rejecting new commands)
363
+ stdout, stderr, marker_found = _collect_output_until_marker(
364
+ session, marker, wait_ms, max_chars
365
+ )
366
+
367
+ status = "completed" if marker_found else "running"
368
+
369
+ return {
370
+ "success": True,
371
+ "session_id": session_id,
372
+ "status": status,
373
+ "stdout": stdout,
374
+ "stderr": stderr,
375
+ }
376
+ else:
377
+ # For normal mode, wait until completion or timeout
378
+ max_wait_time = wait_ms if wait_ms > 0 else 100000
379
+ stdout, stderr, marker_found = _collect_output_until_marker(
380
+ session, marker, max_wait_time, max_chars
381
+ )
382
+
383
+ return {
384
+ "success": True,
385
+ "session_id": session_id,
386
+ "stdout": stdout,
387
+ "stderr": stderr,
388
+ }
389
+
390
+
391
+ class ShellWriteTool(BaseTool):
392
+ """Write input to a shell session"""
393
+
394
+ @property
395
+ def name(self) -> str:
396
+ return "shell_write"
397
+
398
+ @property
399
+ def description(self) -> str:
400
+ return "Write input to stdin of a persistent bash session."
401
+
402
+ @property
403
+ def parameters(self) -> Dict[str, Any]:
404
+ return {
405
+ "type": "object",
406
+ "properties": {
407
+ "session_id": {
408
+ "type": "string",
409
+ "description": "Session/agent identifier for persistent shell",
410
+ },
411
+ "input": {
412
+ "type": "string",
413
+ "description": "Input to write to stdin",
414
+ },
415
+ "working_dir": {
416
+ "type": "string",
417
+ "description": "Working directory to start shell (optional)",
418
+ },
419
+ },
420
+ "required": ["input"],
421
+ }
422
+
423
+ def execute(
424
+ self,
425
+ input: str,
426
+ session_id: Optional[str] = None,
427
+ working_dir: Optional[str] = None,
428
+ ) -> Dict[str, Any]:
429
+ session_id = session_id or "default"
430
+ session = _ensure_session(session_id, working_dir=working_dir)
431
+ session.write(input)
432
+ return {
433
+ "success": True,
434
+ "session_id": session_id,
435
+ }
436
+
437
+
438
+ class ShellReadTool(BaseTool):
439
+ """Read output from a shell session"""
440
+
441
+ @property
442
+ def name(self) -> str:
443
+ return "shell_read"
444
+
445
+ @property
446
+ def description(self) -> str:
447
+ return "Read buffered output from a persistent bash session."
448
+
449
+ @property
450
+ def parameters(self) -> Dict[str, Any]:
451
+ return {
452
+ "type": "object",
453
+ "properties": {
454
+ "session_id": {
455
+ "type": "string",
456
+ "description": "Session/agent identifier for persistent shell",
457
+ },
458
+ "wait_ms": {
459
+ "type": "integer",
460
+ "description": "Wait time in milliseconds before reading output",
461
+ "default": 0,
462
+ },
463
+ "max_chars": {
464
+ "type": "integer",
465
+ "description": "Max characters to return from buffered output",
466
+ "default": 20000,
467
+ },
468
+ "working_dir": {
469
+ "type": "string",
470
+ "description": "Working directory to start shell (optional)",
471
+ },
472
+ },
473
+ "required": [],
474
+ }
475
+
476
+ def execute(
477
+ self,
478
+ session_id: Optional[str] = None,
479
+ wait_ms: int = 0,
480
+ max_chars: int = 20000,
481
+ working_dir: Optional[str] = None,
482
+ ) -> Dict[str, Any]:
483
+ session_id = session_id or "default"
484
+ session = _ensure_session(session_id, working_dir=working_dir)
485
+ default_wait_ms = wait_ms if wait_ms > 0 else 1000
486
+ output = session.read(wait_ms=default_wait_ms, max_chars=max_chars)
487
+ return {
488
+ "success": True,
489
+ "session_id": session_id,
490
+ **output,
491
+ }
492
+
493
+
494
+ class ShellStopTool(BaseTool):
495
+ """Stop a shell session"""
496
+
497
+ @property
498
+ def name(self) -> str:
499
+ return "shell_stop"
500
+
501
+ @property
502
+ def description(self) -> str:
503
+ return "Stop a persistent bash session."
504
+
505
+ @property
506
+ def parameters(self) -> Dict[str, Any]:
507
+ return {
508
+ "type": "object",
509
+ "properties": {
510
+ "session_id": {
511
+ "type": "string",
512
+ "description": "Session/agent identifier for persistent shell",
513
+ },
514
+ },
515
+ "required": [],
516
+ }
517
+
518
+ def execute(self, session_id: Optional[str] = None) -> Dict[str, Any]:
519
+ session_id = session_id or "default"
520
+ session = _shell_sessions.get(session_id)
521
+ if not session or not session.is_alive():
522
+ return {
523
+ "success": True,
524
+ "session_id": session_id,
525
+ "status": "stopped",
526
+ }
527
+ session.terminate()
528
+ return {
529
+ "success": True,
530
+ "session_id": session_id,
531
+ "status": "stopped",
532
+ }
533
+
534
+
535
+ class ShellToolGroup(BaseToolGroup):
536
+ """Shell tool group"""
537
+
538
+ @property
539
+ def name(self) -> str:
540
+ return "shell"
541
+
542
+ @property
543
+ def description(self) -> str:
544
+ return "Persistent bash shell tools."
545
+
546
+ def get_tools(self) -> List[BaseTool]:
547
+ return [
548
+ ShellStartTool(),
549
+ ShellRunTool(),
550
+ ShellWriteTool(),
551
+ ShellReadTool(),
552
+ ShellStopTool(),
553
+ ]
554
+
555
+
556
+ class ShellStatus(BaseStatus):
557
+ """Status provider for shell sessions"""
558
+
559
+ @property
560
+ def name(self) -> str:
561
+ return "shell"
562
+
563
+ @property
564
+ def description(self) -> str:
565
+ return "Persistent bash shell status."
566
+
567
+ def get_status(self) -> Dict[str, Any]:
568
+ return {
569
+ "name": self.name,
570
+ "status": "ok",
571
+ "sessions": _get_sessions_status(),
572
+ }
@@ -0,0 +1,49 @@
1
+ """List directory tool"""
2
+ from pathlib import Path
3
+ from typing import Any, Dict
4
+ from .base import BaseTool
5
+
6
+
7
+ class ListDirectoryTool(BaseTool):
8
+ """List files and directories"""
9
+
10
+ @property
11
+ def name(self) -> str:
12
+ return "list_directory"
13
+
14
+ @property
15
+ def description(self) -> str:
16
+ return "List files and directories in a path"
17
+
18
+ @property
19
+ def parameters(self) -> Dict[str, Any]:
20
+ return {
21
+ "type": "object",
22
+ "properties": {
23
+ "path": {
24
+ "type": "string",
25
+ "description": "Path to list"
26
+ }
27
+ },
28
+ "required": ["path"]
29
+ }
30
+
31
+ def execute(self, path: str) -> Dict[str, Any]:
32
+ """List directory contents"""
33
+ try:
34
+ items = []
35
+ for item in Path(path).iterdir():
36
+ items.append({
37
+ "name": item.name,
38
+ "type": "directory" if item.is_dir() else "file",
39
+ "path": str(item)
40
+ })
41
+ return {
42
+ "success": True,
43
+ "items": items
44
+ }
45
+ except Exception as e:
46
+ return {
47
+ "success": False,
48
+ "error": str(e)
49
+ }
@@ -0,0 +1,43 @@
1
+ """Read file tool"""
2
+ from typing import Any, Dict
3
+ from .base import BaseTool
4
+
5
+
6
+ class ReadFileTool(BaseTool):
7
+ """Read content from files"""
8
+
9
+ @property
10
+ def name(self) -> str:
11
+ return "read_file"
12
+
13
+ @property
14
+ def description(self) -> str:
15
+ return "Read content from a file"
16
+
17
+ @property
18
+ def parameters(self) -> Dict[str, Any]:
19
+ return {
20
+ "type": "object",
21
+ "properties": {
22
+ "file_path": {
23
+ "type": "string",
24
+ "description": "Path to the file to read"
25
+ }
26
+ },
27
+ "required": ["file_path"]
28
+ }
29
+
30
+ def execute(self, file_path: str) -> Dict[str, Any]:
31
+ """Read file content"""
32
+ try:
33
+ with open(file_path, 'r', encoding='utf-8') as f:
34
+ content = f.read()
35
+ return {
36
+ "success": True,
37
+ "content": content
38
+ }
39
+ except Exception as e:
40
+ return {
41
+ "success": False,
42
+ "error": str(e)
43
+ }