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.
microbot/MicroBot.py ADDED
@@ -0,0 +1,173 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+ from logging import getLogger
7
+ from typing import Optional
8
+
9
+ from microbot.constants import ModelProvider, PermissionLabels, PermissionMapping
10
+ from microbot.environment.local_docker.LocalDockerEnvironment import LocalDockerEnvironment
11
+ from microbot.llm.openai_api import OpenAIApi
12
+ from microbot.tool_definitions.base_tool import BaseTool
13
+ from microbot.utils.logger import LogLevelEmoji, dividerString
14
+ from microbot.utils.network import get_free_port
15
+
16
+ logger = getLogger(" MicroBot ")
17
+
18
+ llm_output_format = """```json
19
+ {
20
+ task_done: true | false,
21
+ command: "<command to run> | null",
22
+ result: str | null
23
+ }
24
+ ```
25
+ """
26
+
27
+ system_prompt_common = """There is a shell session open for you.
28
+ I will provide a task to achieve using the shell.
29
+ You will provide the commands to achieve the task in this particular below json format, Ensure all the time to respond in this format only and nothing else, also all the properties ( task_done, command, result ) are mandatory on each response
30
+ {llm_output_format}
31
+ after each command I will provide the output of the command.
32
+ ensure to run only one command at a time.
33
+ I won't be able to intervene once I have given task. ."""
34
+
35
+
36
+ class BotType(StrEnum):
37
+ READING_BOT = "READING_BOT"
38
+ WRITING_BOT = "WRITING_BOT"
39
+ BROWSING_BOT = "BROWSING_BOT"
40
+ CUSTOM_BOT = "CUSTOM_BOT"
41
+
42
+
43
+ @dataclass
44
+ class BotRunResult:
45
+ status: bool
46
+ result: str | None
47
+ error: Optional[str]
48
+
49
+
50
+ class MicroBot:
51
+
52
+ def __init__(
53
+ self,
54
+ bot_type: BotType,
55
+ model: str,
56
+ system_prompt: Optional[str] = None,
57
+ environment: Optional[any] = None,
58
+ additional_tools: Optional[list[BaseTool]] = [],
59
+ folder_to_mount: Optional[str] = None,
60
+ permission: Optional[PermissionLabels] = None,
61
+ ):
62
+ # validate init values before assigning
63
+ self.permission = permission
64
+ self.permission = permission
65
+ if folder_to_mount is not None:
66
+ self.folder_to_mount_base_path = os.path.basename(folder_to_mount) # TODO
67
+
68
+ self._validate_model_and_provider(model)
69
+ self._validate_model_and_provider(model)
70
+ self.permission_key = PermissionMapping.MAPPING.get(self.permission)
71
+ self.system_prompt = system_prompt
72
+ self.system_prompt = system_prompt
73
+ self.model = model
74
+ self.bot_type = bot_type
75
+ self.model_provider = model.split("/")[0]
76
+ self.deployment_name = model.split("/")[1]
77
+ self.environment = environment
78
+ self._create_environment(folder_to_mount)
79
+ self._create_llm()
80
+
81
+ def run(self, task, max_iterations=20, timeout_in_seconds=200) -> BotRunResult:
82
+
83
+ iteration_count = 1
84
+ # start timer
85
+ start_time = time.time()
86
+ timeout = timeout_in_seconds
87
+ llm_response = self.llm.ask(task)
88
+ return_value = BotRunResult(
89
+ status=False,
90
+ result=None,
91
+ error="Did not complete",
92
+ )
93
+ logger.info("%s TASK STARTED : %s...", LogLevelEmoji.INFO, task[0:15])
94
+ while llm_response.task_done is False:
95
+ print(dividerString)
96
+ logger.info(
97
+ " %s LLM Iteration Count : %d", LogLevelEmoji.INFO, iteration_count
98
+ )
99
+ logger.info(
100
+ " âžĄī¸ LLM tool call : %s",
101
+ json.dumps(llm_response.command),
102
+ )
103
+ # increment iteration count
104
+ iteration_count += 1
105
+ if iteration_count >= max_iterations:
106
+ return_value.error = f"Max iterations {max_iterations} reached"
107
+ return return_value
108
+
109
+ # check if timeout has reached
110
+ current_time = time.time()
111
+ elapsed_time = current_time - start_time
112
+
113
+ if elapsed_time > timeout:
114
+ logger.error(
115
+ "Iteration %d with response %s",
116
+ iteration_count,
117
+ json.dumps(llm_response),
118
+ )
119
+ return_value.error = f"Timeout of {timeout} seconds reached"
120
+ return return_value
121
+
122
+ llm_command_output = self.environment.execute(llm_response.command)
123
+ logger.info(
124
+ " âŦ…ī¸ Command Execution Output : %s",
125
+ llm_command_output,
126
+ )
127
+ # Convert CmdReturn to string for LLM
128
+ if llm_command_output.stdout:
129
+ output_text = llm_command_output.stdout
130
+ elif llm_command_output.stderr:
131
+ output_text = f"COMMUNICATION ERROR: {llm_command_output.stderr}"
132
+ else:
133
+ output_text = "No output received"
134
+
135
+ llm_response = self.llm.ask(output_text)
136
+
137
+ logger.info("🔚 TASK COMPLETED : %s...", task[0:15])
138
+ return BotRunResult(status=True, result=llm_response.result, error=None)
139
+
140
+ def _create_environment(self, folder_to_mount):
141
+ if self.environment is None:
142
+ # check for a free port in the system and assign to environment
143
+
144
+ free_port = get_free_port()
145
+
146
+ self.environment = LocalDockerEnvironment(
147
+ port=free_port,
148
+ folder_to_mount=folder_to_mount,
149
+ permission=self.permission,
150
+ )
151
+
152
+ def _create_llm(self):
153
+ if self.model_provider == ModelProvider.OPENAI:
154
+ self.llm = OpenAIApi(
155
+ system_prompt=self.system_prompt, deployment_name=self.deployment_name
156
+ )
157
+
158
+ def _validate_model_and_provider(self, model):
159
+ # Ensure it has only only slash
160
+ if model.count("/") != 1:
161
+ raise ValueError("Model should be in the format <provider>/<model_name>")
162
+ provider = model.split("/")[0]
163
+ if provider not in [e.value for e in ModelProvider]:
164
+ raise ValueError(f"Unsupported model provider: {provider}")
165
+
166
+ def __del__(self):
167
+ if self.environment:
168
+ try:
169
+ self.environment.stop()
170
+ except Exception as e:
171
+ logger.error(
172
+ "%s Error while stopping environment: %s", LogLevelEmoji.ERROR, e
173
+ )
microbot/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from microbot.bot import ReadingBot, WritingBot, BrowserBot, CustomBot
2
+
3
+ __all__ = [
4
+ "ReadingBot",
5
+ "WritingBot",
6
+ "BrowserBot",
7
+ "CustomBot"
8
+ ]
9
+
10
+ __version__ = "0.0.1"
@@ -0,0 +1,32 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from microbot.constants import PermissionLabels
5
+ from microbot.MicroBot import BotType, MicroBot, system_prompt_common
6
+ from microbot.tool_definitions.base_tool import BaseTool
7
+
8
+
9
+ class BrowserBot(MicroBot):
10
+
11
+ def __init__(
12
+ self,
13
+ model: str,
14
+ environment: Optional[any] = None,
15
+ additional_tools: Optional[list[BaseTool]] = [],
16
+ ):
17
+ # validate init values before assigning
18
+ bot_type = BotType.BROWSING_BOT
19
+ permission = PermissionLabels.READ_WRITE
20
+ system_prompt = f"""
21
+ {system_prompt_common}
22
+ You are also provided access to internet to search for information.
23
+ """
24
+
25
+ super().__init__(
26
+ bot_type,
27
+ model,
28
+ system_prompt,
29
+ environment,
30
+ additional_tools,
31
+ permission,
32
+ )
@@ -0,0 +1,31 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from microbot.constants import PermissionLabels
5
+ from microbot.MicroBot import BotType, MicroBot
6
+ from microbot.tool_definitions.base_tool import BaseTool
7
+
8
+
9
+ class BrowserBot(MicroBot):
10
+
11
+ def __init__(
12
+ self,
13
+ model: str,
14
+ system_prompt: str,
15
+ folder_to_mount: Optional[str] = None,
16
+ environment: Optional[any] = None,
17
+ additional_tools: Optional[list[BaseTool]] = [],
18
+ ):
19
+ # validate init values before assigning
20
+ bot_type = BotType.BROWSING_BOT
21
+ permission = PermissionLabels.READ_WRITE
22
+
23
+ super().__init__(
24
+ bot_type,
25
+ model,
26
+ system_prompt,
27
+ environment,
28
+ additional_tools,
29
+ folder_to_mount,
30
+ permission,
31
+ )
@@ -0,0 +1,41 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from microbot.constants import DOCKER_WORKING_DIR, PermissionLabels
5
+ from microbot.MicroBot import BotType, MicroBot, system_prompt_common
6
+ from microbot.tool_definitions.base_tool import BaseTool
7
+
8
+
9
+ class ReadingBot(MicroBot):
10
+
11
+ def __init__(
12
+ self,
13
+ model: str,
14
+ folder_to_mount: str,
15
+ environment: Optional[any] = None,
16
+ additional_tools: Optional[list[BaseTool]] = [],
17
+ ):
18
+ # validate init values before assigning
19
+ bot_type = BotType.READING_BOT
20
+ permission = PermissionLabels.READ_ONLY
21
+
22
+ base_name = Path(folder_to_mount).name
23
+
24
+ system_prompt = f"""
25
+ {system_prompt_common}
26
+ You are a reading bot.
27
+ You are only provided access to read files inside the mounted directory.
28
+ The directory is mounted at /{DOCKER_WORKING_DIR}/{base_name} in your current environment.
29
+ You can access files using paths like /{DOCKER_WORKING_DIR}/{base_name}/filename.txt or by changing to that directory first.
30
+ Once all the commands are done, and task is verified finally give me the result.
31
+ """
32
+
33
+ super().__init__(
34
+ bot_type,
35
+ model,
36
+ system_prompt,
37
+ environment,
38
+ additional_tools,
39
+ folder_to_mount,
40
+ permission,
41
+ )
@@ -0,0 +1,38 @@
1
+ from typing import Optional
2
+
3
+ from microbot.constants import PermissionLabels
4
+ from microbot.MicroBot import BotType, MicroBot, system_prompt_common
5
+ from microbot.tool_definitions.base_tool import BaseTool
6
+
7
+
8
+ class WritingBot(MicroBot):
9
+
10
+ def __init__(
11
+ self,
12
+ model: str,
13
+ folder_to_mount: str,
14
+ environment: Optional[any] = None,
15
+ additional_tools: Optional[list[BaseTool]] = [],
16
+ ):
17
+ # validate init values before assigning
18
+ bot_type = BotType.WRITING_BOT
19
+ permission = PermissionLabels.READ_WRITE
20
+
21
+ system_prompt = f"""
22
+ {system_prompt_common}
23
+ You are a writing bot.
24
+ You are only provided access to write files inside the mounted directory.
25
+ The directory is mounted at /app/{folder_to_mount} in your current environment.
26
+ You can access files using paths like /app/{folder_to_mount}/filename.txt or by changing to that directory first.
27
+ Once all the commands are done, and task is verified finally give me the result.
28
+ """
29
+
30
+ super().__init__(
31
+ bot_type,
32
+ model,
33
+ system_prompt,
34
+ environment,
35
+ additional_tools,
36
+ folder_to_mount,
37
+ permission,
38
+ )
@@ -0,0 +1,4 @@
1
+ from .ReadingBot import ReadingBot
2
+ from .WritingBot import WritingBot
3
+
4
+ __all__ = ["ReadingBot", "WritingBot"]
microbot/constants.py ADDED
@@ -0,0 +1,25 @@
1
+ from enum import Enum, StrEnum
2
+ from typing import Optional, TypedDict
3
+
4
+
5
+ class ModelProvider(StrEnum):
6
+ OPENAI = "openai"
7
+
8
+
9
+ class ModelEnum(StrEnum):
10
+ GPT_5 = "gpt-5"
11
+
12
+
13
+ class PermissionLabels(StrEnum):
14
+ READ_ONLY = "READ_ONLY"
15
+ READ_WRITE = "READ_WRITE"
16
+
17
+
18
+ class PermissionMapping:
19
+ MAPPING = {
20
+ PermissionLabels.READ_ONLY: "ro",
21
+ PermissionLabels.READ_WRITE: "rw",
22
+ }
23
+
24
+
25
+ DOCKER_WORKING_DIR = "workdir"
@@ -0,0 +1,22 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional
3
+
4
+ class CmdReturn:
5
+ def __init__(self, stdout: str, stderr: str, return_code: int):
6
+ self.stdout = stdout
7
+ self.stderr = stderr
8
+ self.return_code = return_code
9
+
10
+
11
+ class Environment(ABC):
12
+ @abstractmethod
13
+ def start(self):
14
+ pass
15
+
16
+ @abstractmethod
17
+ def stop(self):
18
+ pass
19
+
20
+ @abstractmethod
21
+ def execute(self, command: str, timeout: Optional[int] = 300) -> CmdReturn:
22
+ pass
@@ -0,0 +1,156 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from microbot.environment.Environment import Environment, CmdReturn
8
+
9
+ import docker
10
+ import requests
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ WORKING_DIR = str(Path.home() / "MICROBOT_WORKDIR")
15
+ DOCKER_WORKING_DIR = "/workdir"
16
+
17
+
18
+ class LocalDockerEnvironment(Environment):
19
+ def __init__(
20
+ self,
21
+ port: int,
22
+ folder_to_mount: Optional[str] = None,
23
+ permission: Optional[str] = None,
24
+ image: str = "kavyasree261002/shell_server:latest",
25
+ ):
26
+ if folder_to_mount is None and permission is not None:
27
+ raise ValueError("permission provided but folder_to_mount is None")
28
+ elif permission is None and folder_to_mount is not None:
29
+ raise ValueError("folder_to_mount provided but permission is None")
30
+ if permission is not None and permission not in ["READ_ONLY", "READ_WRITE"]:
31
+ raise ValueError(
32
+ "permission must be 'READ_ONLY' or 'READ_WRITE' when provided"
33
+ )
34
+
35
+ self.image = image
36
+ self.folder_to_mount = folder_to_mount
37
+ self.permission = permission
38
+ self.container = None
39
+ self.client = docker.from_env()
40
+ self.port = port # required host port
41
+ self.container_port = 8080
42
+ self._create_working_dir()
43
+ self.start()
44
+
45
+ def _create_working_dir(self):
46
+ if not os.path.exists(WORKING_DIR):
47
+ os.makedirs(WORKING_DIR)
48
+ logger.info("đŸ—‚ī¸ Created working directory at %s", WORKING_DIR)
49
+ else:
50
+ logger.info("đŸ—‚ī¸ Working directory already exists at %s", WORKING_DIR)
51
+
52
+ def start(self):
53
+ mode_map = {"READ_ONLY": "ro", "READ_WRITE": "rw"}
54
+ volumes_config = {WORKING_DIR: {"bind": DOCKER_WORKING_DIR, "mode": "rw"}}
55
+ if self.folder_to_mount and self.permission:
56
+ if self.permission == "READ_ONLY":
57
+ volumes_config[self.folder_to_mount] = {
58
+ "bind": f"/ro/{os.path.basename(self.folder_to_mount)}",
59
+ "mode": mode_map[self.permission],
60
+ }
61
+ logger.info(
62
+ "đŸ“Ļ Volume mapping: %s → /ro/%s",
63
+ self.folder_to_mount,
64
+ os.path.basename(self.folder_to_mount),
65
+ )
66
+ else:
67
+ volumes_config[self.folder_to_mount] = {
68
+ "bind": f"/{DOCKER_WORKING_DIR}/{os.path.basename(self.folder_to_mount)}",
69
+ "mode": mode_map[self.permission],
70
+ }
71
+ logger.debug(
72
+ "đŸ“Ļ Volume mapping: %s → /{DOCKER_WORKING_DIR}/%s",
73
+ self.folder_to_mount,
74
+ os.path.basename(self.folder_to_mount),
75
+ )
76
+
77
+ # Port mapping
78
+ port_mapping = {f"{self.container_port}/tcp": self.port}
79
+
80
+ self.container = self.client.containers.run(
81
+ self.image,
82
+ volumes=volumes_config,
83
+ ports=port_mapping,
84
+ detach=True,
85
+ working_dir="/app",
86
+ privileged=True, # Required for mounting overlayfs
87
+ environment={"BOT_PORT": str(self.container_port)},
88
+ )
89
+ logger.info(
90
+ "🚀 Started container %s with image %s on host port %s",
91
+ self.container.id[:12],
92
+ self.image,
93
+ self.port,
94
+ )
95
+ time.sleep(2) # Give some time for the server to start
96
+
97
+ if self.permission == "READ_ONLY":
98
+ self._setup_overlay_mount(self.folder_to_mount)
99
+
100
+ def _setup_overlay_mount(self, folder_to_mount: str):
101
+ path_name = os.path.basename(os.path.abspath(folder_to_mount))
102
+ # Mount /ro/path_name to /{WORKING_DIR}/path_name using overlayfs
103
+ mount_command = (
104
+ f"mkdir -p /overlaydir && "
105
+ f"mkdir -p /{DOCKER_WORKING_DIR}/{path_name} /{DOCKER_WORKING_DIR}/overlay/{path_name}/upper /{DOCKER_WORKING_DIR}/overlay/{path_name}/work && "
106
+ f"mount -t overlay overlay -o lowerdir=/ro/{path_name},upperdir=/{DOCKER_WORKING_DIR}/overlay/{path_name}/upper,workdir=/{DOCKER_WORKING_DIR}/overlay/{path_name}/work /{DOCKER_WORKING_DIR}/{path_name}"
107
+ )
108
+ self.execute(mount_command)
109
+ logger.info(
110
+ "🔒 Set up overlay mount for read-only directory at /{DOCKER_WORKING_DIR}/%s",
111
+ path_name,
112
+ )
113
+
114
+ def stop(self):
115
+ """Stop and remove the container"""
116
+ if self.container:
117
+ self.container.stop()
118
+ self.container.remove()
119
+ self.container = None
120
+
121
+ # Remove working directory
122
+ if os.path.exists(WORKING_DIR):
123
+ try:
124
+ import shutil
125
+
126
+ shutil.rmtree(WORKING_DIR)
127
+ logger.info("đŸ—‘ī¸ Removed working directory at %s", WORKING_DIR)
128
+ except Exception as e:
129
+ logger.error("❌ Failed to remove working directory: %s", e)
130
+
131
+ def execute(self, command: str, timeout: Optional[int] = 10) -> CmdReturn: # TODO: Need proper return value
132
+ logger.debug("âžĄī¸ Executing command in container: %s", command)
133
+ try:
134
+ response = requests.post(
135
+ f"http://localhost:{self.port}/",
136
+ json={"message": command},
137
+ timeout=timeout,
138
+ )
139
+ response.raise_for_status()
140
+ logger.debug("âŦ…ī¸ Command output: %s", response.json().get("output", ""))
141
+ output = response.json().get("output", "")
142
+ return CmdReturn(
143
+ stdout=output, stderr="", return_code=0
144
+ )
145
+ self.container.reload()
146
+ logger.info("â„šī¸ Container status: %s", self.container.status)
147
+ if self.container.status != "running":
148
+ logs = self.container.logs().decode("utf-8", errors="replace")
149
+ logger.error("🛑 Container not running. Recent logs below:\n%s", logs)
150
+ return CmdReturn(stdout="", stderr="Connection error", return_code=1)
151
+ except requests.exceptions.RequestException as e:
152
+ logger.exception("❌ Request failed while executing command: %s", e)
153
+ return CmdReturn(stdout="", stderr=str(e), return_code=1)
154
+ except Exception as e:
155
+ logger.exception("❌ Unexpected error while executing command: %s", e)
156
+ return CmdReturn(stdout="", stderr="Unexpected error", return_code=1)
@@ -0,0 +1 @@
1
+ from .LocalDockerEnvironment import LocalDockerEnvironment