microbots 0.0.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.
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shell Communication Script
4
+ A Python script to create and communicate with shell sessions.
5
+ Supports interactive shell communication, command execution, and bidirectional data flow.
6
+ """
7
+
8
+ import os
9
+ import queue
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ import logging
15
+ from typing import Callable, List, Optional
16
+ from dataclasses import dataclass
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ @dataclass
21
+ class CmdReturn:
22
+ stdout: str
23
+ stderr: str
24
+ return_code: int
25
+
26
+
27
+ class ShellCommunicator:
28
+ """
29
+ A class to create and manage shell sessions with bidirectional communication.
30
+ """
31
+
32
+ def __init__(self, shell_type: str = "bash", encoding: str = "utf-8"):
33
+ """
34
+ Initialize the shell communicator.
35
+
36
+ Args:
37
+ shell_type: Type of shell ("powershell", "cmd", "bash", "python")
38
+ encoding: Text encoding for communication
39
+ """
40
+ self.shell_type = shell_type.lower()
41
+ self.encoding = encoding
42
+ self.process: Optional[subprocess.Popen] = None
43
+ self.output_queue = queue.Queue()
44
+ self.error_queue = queue.Queue()
45
+ self.is_running = False
46
+ self.output_thread: Optional[threading.Thread] = None
47
+ self.error_thread: Optional[threading.Thread] = None
48
+ self.output_callback: Optional[Callable] = None
49
+
50
+ # Define shell commands
51
+ self.shell_commands = {
52
+ "powershell": ["powershell.exe", "-NoLogo", "-NoExit"],
53
+ "cmd": ["cmd.exe", "/k"],
54
+ "bash": ["bash"],
55
+ "python": [sys.executable, "-i"],
56
+ "wsl": ["wsl.exe"],
57
+ }
58
+
59
+ def start_session(self) -> bool:
60
+ """
61
+ Start a new shell session.
62
+
63
+ Returns:
64
+ bool: True if session started successfully, False otherwise
65
+ """
66
+ try:
67
+ if self.shell_type not in self.shell_commands:
68
+ logger.error("🛑 Unsupported shell type: %s", self.shell_type)
69
+ return False
70
+
71
+ cmd = self.shell_commands[self.shell_type]
72
+
73
+ # Create the subprocess
74
+ self.process = subprocess.Popen(
75
+ cmd,
76
+ stdin=subprocess.PIPE,
77
+ stdout=subprocess.PIPE,
78
+ stderr=subprocess.PIPE,
79
+ text=True,
80
+ encoding=self.encoding,
81
+ bufsize=0,
82
+ universal_newlines=True,
83
+ )
84
+
85
+ self.is_running = True
86
+
87
+ # Start output monitoring threads
88
+ self.output_thread = threading.Thread(
89
+ target=self._monitor_output,
90
+ args=(self.process.stdout, self.output_queue, "OUTPUT"),
91
+ daemon=True,
92
+ )
93
+ self.error_thread = threading.Thread(
94
+ target=self._monitor_output,
95
+ args=(self.process.stderr, self.error_queue, "ERROR"),
96
+ daemon=True,
97
+ )
98
+
99
+ self.output_thread.start()
100
+ self.error_thread.start()
101
+
102
+ logger.info("🚀 %s session started successfully", self.shell_type.capitalize())
103
+ logger.debug("🆔 Process ID: %s", self.process.pid)
104
+ return True
105
+
106
+ except Exception as e:
107
+ logger.exception("❌ Failed to start shell session: %s", e)
108
+ return False
109
+
110
+ def _monitor_output(self, stream, output_queue: queue.Queue, stream_type: str):
111
+ """
112
+ Monitor shell output in a separate thread.
113
+
114
+ Args:
115
+ stream: The stream to monitor (stdout or stderr)
116
+ output_queue: Queue to store output
117
+ stream_type: Type of stream ("OUTPUT" or "ERROR")
118
+ """
119
+ try:
120
+ while self.is_running and self.process and self.process.poll() is None:
121
+ line = stream.readline()
122
+ if line:
123
+ output_queue.put((stream_type, line.rstrip()))
124
+ if self.output_callback:
125
+ self.output_callback(stream_type, line.rstrip())
126
+ elif self.process.poll() is not None:
127
+ break
128
+ except Exception as e:
129
+ output_queue.put((stream_type, f"Monitor error: {e}"))
130
+
131
+ def send_command(
132
+ self, command: str, wait_for_output: bool = True, timeout: float = 5.0
133
+ ) -> CmdReturn:
134
+ """
135
+ Send a command to the shell session.
136
+
137
+ Args:
138
+ command: Command to execute
139
+ wait_for_output: Whether to wait for command output
140
+ timeout: Timeout for waiting for output
141
+
142
+ Returns:
143
+ List of output lines
144
+ """
145
+ if not self.is_running or not self.process:
146
+ logger.warning("⚠️ No active shell session")
147
+ return CmdReturn(stdout="", stderr="No active shell session", return_code=1)
148
+
149
+ try:
150
+ self.process.stdin.write(command + "\n")
151
+ self.process.stdin.flush()
152
+ logger.debug("➡️ Sent command: %s", command)
153
+
154
+ if not wait_for_output:
155
+ return CmdReturn(stdout="", stderr="", return_code=0)
156
+
157
+ output_lines = []
158
+ start_time = time.time()
159
+
160
+ while time.time() - start_time < timeout:
161
+ try:
162
+ # Check for output
163
+ stream_type, line = self.output_queue.get(timeout=0.1)
164
+ output_lines.append(f"{line}")
165
+ if stream_type == "ERROR":
166
+ logger.error("❌ %s", line)
167
+ else:
168
+ logger.debug("📤 %s", line)
169
+ except queue.Empty:
170
+ continue
171
+ except Exception:
172
+ logger.exception("❌ Unexpected error while reading output queue")
173
+ break
174
+
175
+ # Check for errors
176
+ try:
177
+ while True:
178
+ stream_type, line = self.error_queue.get_nowait()
179
+ output_lines.append(f"{line}")
180
+ if stream_type == "ERROR":
181
+ logger.error("❌ %s", line)
182
+ else:
183
+ logger.debug("📤 %s", line)
184
+ except queue.Empty:
185
+ pass
186
+
187
+ return CmdReturn(stdout="\n".join(output_lines), stderr="", return_code=0)
188
+
189
+ except Exception as e:
190
+ logger.exception("❌ Failed to send command: %s", e)
191
+ return CmdReturn(stdout="", stderr=str(e), return_code=1)
192
+
193
+
194
+ def is_alive(self) -> bool:
195
+ """
196
+ Check if the shell session is still alive.
197
+
198
+ Returns:
199
+ bool: True if session is active, False otherwise
200
+ """
201
+ return (
202
+ self.is_running and self.process is not None and self.process.poll() is None
203
+ )
204
+
205
+ def get_shell_info(self) -> dict:
206
+ """
207
+ Get information about the current shell session.
208
+
209
+ Returns:
210
+ Dictionary with shell session information
211
+ """
212
+ if not self.process:
213
+ return {"status": "Not started"}
214
+
215
+ return {
216
+ "shell_type": self.shell_type,
217
+ "pid": self.process.pid,
218
+ "status": "Running" if self.is_alive() else "Stopped",
219
+ "encoding": self.encoding,
220
+ "return_code": self.process.returncode,
221
+ }
222
+
223
+ def close_session(self):
224
+ """
225
+ Close the shell session and cleanup resources.
226
+ """
227
+ logger.info("🛑 Closing shell session…")
228
+
229
+ self.is_running = False
230
+
231
+ if self.process:
232
+ try:
233
+ # Try to terminate gracefully
234
+ if self.shell_type == "powershell":
235
+ self.send_command("exit", wait_for_output=False)
236
+ elif self.shell_type == "cmd":
237
+ self.send_command("exit", wait_for_output=False)
238
+ else:
239
+ self.send_command("exit", wait_for_output=False)
240
+
241
+ # Wait a bit for graceful shutdown
242
+ time.sleep(1)
243
+
244
+ # Force terminate if still running
245
+ if self.process.poll() is None:
246
+ self.process.terminate()
247
+ time.sleep(1)
248
+
249
+ if self.process.poll() is None:
250
+ self.process.kill()
251
+
252
+ logger.info("✅ Shell session closed")
253
+
254
+ except Exception as e:
255
+ logger.exception("⚠️ Error during cleanup: %s", e)
256
+
257
+ # Wait for threads to finish
258
+ if self.output_thread and self.output_thread.is_alive():
259
+ self.output_thread.join(timeout=2)
260
+ if self.error_thread and self.error_thread.is_alive():
261
+ self.error_thread.join(timeout=2)
@@ -0,0 +1,28 @@
1
+ import os
2
+
3
+ import uvicorn
4
+ from fastapi import FastAPI
5
+ from pydantic import BaseModel
6
+ from ShellCommunicator import ShellCommunicator
7
+
8
+ shell = ShellCommunicator("bash")
9
+ shell.start_session()
10
+
11
+
12
+ class Message(BaseModel):
13
+ message: str
14
+
15
+
16
+ app = FastAPI()
17
+
18
+
19
+ @app.post("/")
20
+ async def receive_message(message: Message):
21
+ command_output = shell.send_command(message.message)
22
+ return {"status": "success", "output": "\n".join(command_output)}
23
+
24
+
25
+ if __name__ == "__main__":
26
+ # Prefer BOT_PORT, else default 8080
27
+ port = int(os.getenv("BOT_PORT") or 8080)
28
+ uvicorn.run(app, host="0.0.0.0", port=port)
@@ -0,0 +1,139 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ from enum import Enum
5
+ from typing import Optional, Final
6
+
7
+ from Environment.Environment import Environment, CmdReturn
8
+ from swerex.deployment.docker import DockerDeployment
9
+ from swerex.runtime.abstract import (
10
+ CreateBashSessionRequest,
11
+ CloseBashSessionRequest,
12
+ BashAction,
13
+ Observation,
14
+ )
15
+
16
+ PYTHON_IMAGE = "mcr.microsoft.com/devcontainers/python:3.11"
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class Permission(str, Enum):
22
+ READ_ONLY = "READ_ONLY"
23
+ READ_WRITE = "READ_WRITE"
24
+
25
+
26
+ class LocalDocker(Environment):
27
+ BASE_PATH: Final[str] = "/workdir"
28
+
29
+ def _validate_permission_args(
30
+ self,
31
+ folder_to_mount: Optional[str],
32
+ permission: Optional[Permission],
33
+ ):
34
+ if folder_to_mount is None and permission is not None:
35
+ raise ValueError("permission provided but folder_to_mount is None")
36
+ if permission is None and folder_to_mount is not None:
37
+ raise ValueError("folder_to_mount provided but permission is None")
38
+ if permission is not None and permission not in (
39
+ Permission.READ_ONLY,
40
+ Permission.READ_WRITE,
41
+ ):
42
+ raise ValueError(
43
+ "permission must be Permission.READ_ONLY or Permission.READ_WRITE when provided"
44
+ )
45
+
46
+ def _get_mount_args(
47
+ self,
48
+ folder_to_mount: Optional[str],
49
+ permission: Optional[Permission],
50
+ ) -> str:
51
+ self._validate_permission_args(folder_to_mount, permission)
52
+
53
+ mount_args = ""
54
+ if folder_to_mount and permission:
55
+ # TODO: Make overlay mount for read-only to allow llm to create intermediate files
56
+ sanitized = os.path.abspath(folder_to_mount).strip()
57
+ target_path = f"{LocalDocker.BASE_PATH}/{os.path.basename(sanitized)}"
58
+ mode = "ro" if permission == Permission.READ_ONLY else "rw"
59
+ mount_args = f"-v {sanitized}:{target_path}:{mode}"
60
+ logger.info("🥪 Volume mapping: %s -> %s (%s)", sanitized, target_path, mode)
61
+ logger.debug("🗻 Mount args: %r", mount_args)
62
+ return mount_args
63
+
64
+ def _get_docker_args(self, mount_args: str = "") -> list[str]:
65
+ # _get_mount_args returns either "" or a single string beginning with -v; split into tokens for DockerDeployment
66
+ docker_args = []
67
+
68
+ if mount_args:
69
+ if mount_args.startswith('-v '):
70
+ # split once: '-v src:dest:mode'
71
+ flag, rest = mount_args.split(' ', 1)
72
+ docker_args.extend([flag, rest])
73
+ else:
74
+ docker_args.append(mount_args)
75
+
76
+ return docker_args
77
+
78
+ def __init__(
79
+ self,
80
+ folder_to_mount: Optional[str] = None,
81
+ permission: Optional[Permission] = Permission.READ_WRITE,
82
+ image: str = PYTHON_IMAGE,
83
+ ):
84
+ mount_args = self._get_mount_args(folder_to_mount, permission)
85
+ docker_args = self._get_docker_args(mount_args)
86
+ self.deployment = DockerDeployment(image=image, docker_args=docker_args)
87
+ asyncio.run(self.deployment.start())
88
+ self.start()
89
+ logger.info("🚀 LocalDocker environment initialized successfully")
90
+
91
+ def start(self): # type: ignore[override]
92
+ # Acquire runtime and open a bash session.
93
+ self.runtime = self.deployment.runtime
94
+ asyncio.run(self.runtime.create_session(CreateBashSessionRequest()))
95
+
96
+ def stop(self):
97
+ try:
98
+ asyncio.run(self.runtime.close_session(CloseBashSessionRequest()))
99
+ finally:
100
+ asyncio.run(self.deployment.stop())
101
+
102
+ async def execute(
103
+ self, command: str, timeout: Optional[int] = 300
104
+ ) -> CmdReturn:
105
+ """Execute a shell command inside the container.
106
+
107
+ We pass the command through bash -lc to support shell features (globbing, env vars, pipelines).
108
+ """
109
+ logger.debug("🔧 Executing command: %s", command)
110
+ try:
111
+ output: Observation = await asyncio.wait_for(
112
+ self.runtime.run_in_session(BashAction(command=command)), timeout
113
+ )
114
+ logger.debug("📋 Command '%s' completed:", command)
115
+ logger.debug(" ├─ 📤 Exit code: %s", output.exit_code)
116
+ logger.debug(" ├─ 📝 Output: %s", output.output[:100] + "..." if len(output.output) > 100 else output.output)
117
+ logger.debug(" └─ ⚠️ Error: %s", output.failure_reason if output.failure_reason else "(none)")
118
+ return CmdReturn(
119
+ stdout=output.output,
120
+ return_code=output.exit_code,
121
+ stderr=output.failure_reason if output.failure_reason else "",
122
+ )
123
+ except asyncio.TimeoutError:
124
+ # TODO: Consider killing the process if it exceeds timeout. Because session might be unusable after timeout.
125
+ logger.error("⏱️ Command timed out after %s seconds: '%s'", timeout, command)
126
+ return CmdReturn(
127
+ stdout="",
128
+ stderr=f"Command timed out after {timeout} seconds",
129
+ return_code=124, # Standard timeout exit code
130
+ )
131
+ except Exception as e:
132
+ logger.error("❌ Error occurred while executing command '%s': %s", command, e)
133
+ return CmdReturn(
134
+ stdout="",
135
+ stderr=str(e),
136
+ return_code=1,
137
+ )
138
+
139
+
File without changes
@@ -0,0 +1,78 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from logging import getLogger
5
+
6
+ from dotenv import load_dotenv
7
+ from openai import OpenAI
8
+ from microbot.utils.logger import LogLevelEmoji
9
+
10
+ logger = getLogger(__name__)
11
+
12
+
13
+ load_dotenv()
14
+
15
+ from openai import OpenAI
16
+
17
+ endpoint = os.getenv("OPEN_AI_END_POINT")
18
+ deployment_name = os.getenv("OPEN_AI_DEPLOYMENT_NAME")
19
+ api_key = os.getenv("OPEN_AI_KEY") # use the api_key
20
+
21
+
22
+ @dataclass
23
+ class llmAskResponse:
24
+ task_done: bool = False
25
+ command: str = ""
26
+ result: str | None = None
27
+
28
+
29
+ class OpenAIApi:
30
+
31
+ def __init__(self, system_prompt, deployment_name=deployment_name):
32
+ self.ai_client = OpenAI(base_url=f"{endpoint}", api_key=api_key)
33
+ self.deployment_name = deployment_name
34
+ self.system_prompt = system_prompt
35
+ self.messages = [{"role": "system", "content": system_prompt}]
36
+
37
+ def ask(self, message) -> llmAskResponse:
38
+ self.messages.append({"role": "user", "content": message})
39
+ return_value = {}
40
+ while self._validate_llm_response(return_value) is False:
41
+ response = self.ai_client.responses.create(
42
+ model=self.deployment_name,
43
+ input=self.messages,
44
+ )
45
+ try:
46
+ return_value = json.loads(response.output_text)
47
+ except Exception as e:
48
+ logger.error(
49
+ f"%s Error occurred while dumping JSON: {e}", LogLevelEmoji.ERROR
50
+ )
51
+ logger.error(
52
+ "%s Failed to parse JSON from LLM response and the response is",
53
+ LogLevelEmoji.ERROR,
54
+ )
55
+ logger.error(response.output_text)
56
+
57
+ self.messages.append({"role": "assistant", "content": json.dumps(return_value)})
58
+
59
+ return llmAskResponse(
60
+ task_done=return_value["task_done"],
61
+ result=return_value["result"],
62
+ command=return_value["command"],
63
+ )
64
+
65
+ def clear_history(self):
66
+ self.messages = [
67
+ {
68
+ "role": "system",
69
+ "content": self.system_prompt,
70
+ }
71
+ ]
72
+ return True
73
+
74
+ def _validate_llm_response(self, response: dict) -> bool:
75
+ if "task_done" in response and "command" in response and "result" in response:
76
+ logger.info("The llm response is %s ", response)
77
+ return True
78
+ return False
microbot/tool/tool.py ADDED
@@ -0,0 +1,74 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, List
3
+ from pathlib import Path
4
+ from enum import IntEnum
5
+ import yaml
6
+
7
+
8
+ class FILE_PERMISSION(IntEnum):
9
+ READ = 4
10
+ WRITE = 2
11
+ EXECUTE = 1
12
+
13
+
14
+ @dataclass
15
+ class EnvFileCopies:
16
+ src: Path
17
+ dest: Path
18
+ permissions: int # Use FILE_PERMISSION enum to set permissions
19
+
20
+
21
+ @dataclass
22
+ class Tool:
23
+ # TODO: Handle different instructions based on the platform (linux flavours, windows, mac)
24
+ # TODO: Add versioning to tools
25
+ name: str
26
+ description: str
27
+ parameters: dict | None
28
+
29
+ # This is the set of instructions that will be provided to the LLM on how to use this tool.
30
+ # This string will be appended to the LLM's system prompt.
31
+ # This instructions should be non-interactive
32
+ usage_instructions_to_llm: str
33
+
34
+ # This set of commands will be executed once the environment is up and running.
35
+ # These commands will be executed in the order they are provided.
36
+ install_commands: List[str]
37
+
38
+ # Mention what are the environment variables that need to be copied from your current environment
39
+ env_variables: Optional[str] = None
40
+
41
+ # Any files to be copied to the environment before the tool is installed.
42
+ files_to_copy: Optional[List[EnvFileCopies]] = None
43
+
44
+ # This set of commands will be executed to verify if the tool is installed correctly.
45
+ # If any of these commands fail, the tool installation is considered to have failed.
46
+ verify_commands: Optional[List[str]] = None
47
+
48
+ # This set of commands will be executed after the code is copied to the environment
49
+ # and before the llm is invoked.
50
+ # These commands will be executed inside the mounted folder.
51
+ setup_commands: Optional[List[str]] = None
52
+
53
+ # This set of commands will be executed when the environment is being torn down.
54
+ uninstall_commands: Optional[List[str]] = None
55
+
56
+
57
+ def parse_tool_definition(yaml_path: Path) -> Tool:
58
+ """
59
+ Parse a tool definition from a YAML file.
60
+
61
+ Args:
62
+ yaml_path: The path to the YAML file containing the tool definition.
63
+ If it is not an absolute path, it is relative to project_root/tool/tool_definition/
64
+
65
+ Returns:
66
+ A Tool object parsed from the YAML file.
67
+ """
68
+
69
+ if not yaml_path.is_absolute():
70
+ yaml_path = Path(__file__).parent / "tool_definition" / yaml_path
71
+
72
+ with open(yaml_path, "r") as f:
73
+ tool_dict = yaml.safe_load(f)
74
+ return Tool(**tool_dict)
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import asyncio
4
+ import sys
5
+ from pprint import pprint
6
+
7
+ from dotenv import load_dotenv
8
+ load_dotenv()
9
+
10
+ from browser_use import Agent, AgentHistoryList, Browser, ChatAzureOpenAI
11
+
12
+
13
+ async def main(args: list[str]) -> int:
14
+ if len(args) > 1:
15
+ print("browse allows only one arg at a time.")
16
+ return 1
17
+
18
+ if not args:
19
+ print("Usage: browse <arg>")
20
+ return 1
21
+
22
+ what_to_browse = args[0]
23
+
24
+ browser = Browser(
25
+ headless=True
26
+ )
27
+
28
+ agent = Agent(
29
+ task=what_to_browse,
30
+ browser=browser,
31
+ llm=ChatAzureOpenAI(model="gpt-4.1"),
32
+ use_vision=False,
33
+ )
34
+ history: AgentHistoryList = await agent.run()
35
+ print("Final Result:")
36
+ pprint(history.final_result(), indent=4)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ sys.exit(asyncio.run(main(sys.argv[1:])))
@@ -0,0 +1,23 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BaseTool(ABC):
5
+ @property
6
+ @abstractmethod
7
+ def name(self) -> str:
8
+ pass
9
+
10
+ @property
11
+ @abstractmethod
12
+ def installation_command(self) -> str:
13
+ pass
14
+
15
+ @property
16
+ @abstractmethod
17
+ def verification_command(self) -> str:
18
+ pass
19
+
20
+ @property
21
+ @abstractmethod
22
+ def usage_instructions_to_llm(self) -> str:
23
+ pass
@@ -0,0 +1,25 @@
1
+ from microbot.tool_definitions.base_tool import BaseTool
2
+
3
+
4
+ class Ctags(BaseTool):
5
+
6
+ _instance = None # Class-level attribute to store the single instance
7
+ name = "ctags"
8
+ installation_command = "sudo apt install universal-ctags"
9
+ verification_command = "ctags --version"
10
+ usage_instructions_to_llm = (
11
+ "To use ctags, you can run 'ctags -R .' in your project directory"
12
+ )
13
+
14
+ def __new__(cls, *args, **kwargs):
15
+ if cls._instance is None:
16
+ cls._instance = super(Ctags, cls).__new__(cls)
17
+ return cls._instance
18
+
19
+ def __init__(self):
20
+ # We can still use __init__ for any additional setup if needed
21
+ if not hasattr(self, "initialized"):
22
+ self.initialized = True
23
+ print(f"Singleton instance initialized with name: {self.name}")
24
+ else:
25
+ print(f"Attempted to re-initialize an existing instance.")