Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions docs/advanced/tools/tesseract_ocr_tool_use.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ A tool YAML file supports the following fields:
| `name` | Yes | Unique identifier for the tool |
| `tool_type` | Yes | `internal` (runs in sandbox) or `external` (runs on host) |
| `description` | Yes | Short description of the tool |
| `install_commands` | No | Shell commands to install the tool. Executed when the Environment is set up |
| `verify_commands` | No | Commands to confirm installation succeeded |
| `setup_commands` | No | Commands executed after code is mounted, before the LLM runs |
| `uninstall_commands` | No | Cleanup commands run during environment teardown. Can be omitted if the environment is not reused |
| `install_commands` | No | Shell commands to install the tool. Executed when the Environment is set up. A list, or a map of [distro-specific commands](#distro-specific-commands) |
| `verify_commands` | No | Commands to confirm installation succeeded. A list or a distro map |
| `setup_commands` | No | Commands executed after code is mounted, before the LLM runs. A list or a distro map |
| `uninstall_commands` | No | Cleanup commands run during environment teardown. Can be omitted if the environment is not reused. A list or a distro map |
| `env_variables` | No | Environment variables to copy from the host into the sandbox |
| `files_to_copy` | No | Files to copy into the environment with explicit permissions |
| `parameters` | No | Parameter schema exposed to the LLM |
Expand All @@ -50,6 +50,31 @@ Following the table of fields above, create a file called `tesseract_ocr.yaml`:

Because `tool_type` is `internal`, every command in `install_commands` executes **inside the Docker sandbox**. The `apt-get install` never touches your host — even if it fails, only the disposable container is affected.

### Distro-specific commands

`apt-get` only exists on Debian-based sandboxes, so the example keys its `install_commands` by distro. Each `*_commands` field accepts either form:

- **A list**: generic commands that run on every distro.
- **A map**: keys are distro IDs, as in the sandbox's `/etc/os-release` (for example `debian` or `azurelinux`), and/or `generic`.

For a map, Microbots reads the sandbox's `/etc/os-release` and picks the first entry that matches its `ID`, then its `ID_LIKE` entries (so `debian` also covers Ubuntu), and finally `generic`.

- **Known distro, no match:** installation fails with `UnsupportedDistroError` before any command runs.
- **Distro can't be detected:** Microbots logs a warning and runs `generic`, or the first entry in the map. If those commands don't suit the image, the failing command reports the problem.

```yaml
install_commands:
debian:
- apt-get update
- apt-get install -y tesseract-ocr
azurelinux:
- tdnf install -y tesseract
verify_commands: # a list applies to every distro
- tesseract --version
```

External tools resolve the map against the host's `/etc/os-release`.

The `usage_instructions_to_llm` block is injected verbatim into the LLM's system prompt. Write it as if you are explaining the tool to a colleague who has shell access but has never used it before.

Since the Environment will be reaped once the job is over, it is overkill to uninstall the tool before cleaning up. So `uninstall_commands` is purposefully left out.
Expand Down
7 changes: 4 additions & 3 deletions docs/examples/tesseract_ocr_tool_use/tesseract_ocr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ tool_type: internal
description: Tesseract OCR tool for text recognition

install_commands:
- rm -f /etc/apt/sources.list.d/yarn.list
- apt-get update
- apt-get install -y tesseract-ocr
debian:
- rm -f /etc/apt/sources.list.d/yarn.list
- apt-get update
- apt-get install -y tesseract-ocr

usage_instructions_to_llm: |
This tool uses Tesseract OCR to recognize text from images. To use this tool, provide the path to the image file you want to process. The tool will return the recognized text from the image.
Expand Down
24 changes: 24 additions & 0 deletions src/microbots/environment/Environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from typing import Optional
from dataclasses import dataclass

from microbots.utils.os_release import distro_ids, parse_os_release

@dataclass
class CmdReturn:
stdout: str
Expand All @@ -10,6 +12,9 @@ class CmdReturn:


class Environment(ABC):
# Cached result of get_distro_ids(); None until /etc/os-release has been read.
_distro_ids: Optional[list[str]] = None

@abstractmethod
def start(self):
pass
Expand All @@ -28,6 +33,25 @@ def execute_privileged(self, command: str, timeout: Optional[int] = 300, sensiti
Override this one while implementing custom Environment subclasses."""
return self.execute(command, timeout=timeout, sensitive=sensitive)

def get_distro_ids(self) -> list[str]:
"""Identify the environment's Linux distribution from ``/etc/os-release``.

Tools use these identifiers to pick distro-specific commands. The
result is cached after the first successful lookup.

Returns
-------
list[str]
``ID`` followed by the ``ID_LIKE`` entries (for example
``["ubuntu", "debian"]``), or an empty list if unknown.
"""
if self._distro_ids is None:
ret = self.execute_privileged("cat /etc/os-release")
if ret.return_code != 0:
return []
self._distro_ids = distro_ids(parse_os_release(ret.stdout))
return list(self._distro_ids)

def copy_to_container(self, src_path: str, dest_path: str) -> bool:
raise NotImplementedError(
f"{self.__class__.__name__} does not support copying files to container. "
Expand Down
13 changes: 9 additions & 4 deletions src/microbots/tools/external_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from microbots.tools.tool import TOOLTYPE, ToolAbstract, EnvFileCopies
from microbots.environment.Environment import Environment
from microbots.utils.os_release import host_distro_ids

logger = logging.getLogger(" 🔧 ExternalTool")

Expand Down Expand Up @@ -110,7 +111,8 @@ def install_tool(self, env: Environment):
"""Install the tool by running install commands on the **host**.
Files listed in ``files_to_copy`` are also copied to the host."""
logger.debug("Installing external tool on host: %s", self.name)
for command in self.install_commands or []:
distro_ids = host_distro_ids()
for command in self.resolve_commands("install", distro_ids):
result = self._run_host_command(command)
if result.returncode != 0:
logger.error(
Expand All @@ -128,7 +130,8 @@ def install_tool(self, env: Environment):
def verify_tool_installation(self, env: Environment):
"""Verify the tool by running verify commands on the **host**."""
logger.debug("Verifying installation of external tool: %s", self.name)
for command in self.verify_commands or []:
distro_ids = host_distro_ids()
for command in self.resolve_commands("verify", distro_ids):
result = self._run_host_command(command)
if result.returncode != 0:
logger.error(
Expand All @@ -150,7 +153,8 @@ def setup_tool(self, env: Environment):
"""
self._verify_env_variables()

for command in self.setup_commands or []:
distro_ids = host_distro_ids()
for command in self.resolve_commands("setup", distro_ids):
result = self._run_host_command(command)
if result.returncode != 0:
logger.error(
Expand Down Expand Up @@ -181,7 +185,8 @@ def uninstall_tool(self, env: Environment):
dest, self.name,
)

for command in self.uninstall_commands or []:
distro_ids = host_distro_ids()
for command in self.resolve_commands("uninstall", distro_ids):
result = self._run_host_command(command)
if result.returncode != 0:
logger.error(
Expand Down
12 changes: 8 additions & 4 deletions src/microbots/tools/internal_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ def _copy_file_to_env(env: Environment, file_copy: EnvFileCopies):

def install_tool(self, env: Environment):
logger.debug("Installing Internal tool: %s", self.name)
for command in self.install_commands:
distro_ids = env.get_distro_ids()
for command in self.resolve_commands("install", distro_ids):
output = env.execute_privileged(command)
if output.return_code != 0:
logger.error(
Expand All @@ -126,7 +127,8 @@ def install_tool(self, env: Environment):

def verify_tool_installation(self, env: Environment):
logger.debug("Verifying installation of tool: %s", self.name)
for command in self.verify_commands:
distro_ids = env.get_distro_ids()
for command in self.resolve_commands("verify", distro_ids):
output = env.execute(command)
if output.return_code != 0:
logger.error(
Expand All @@ -146,7 +148,8 @@ def setup_tool(self, env: Environment):
self._copy_env_variables(env)
self._copy_files(env)

for command in self.setup_commands:
distro_ids = env.get_distro_ids()
for command in self.resolve_commands("setup", distro_ids):
output = env.execute(command)
if output.return_code != 0:
logger.error(
Expand Down Expand Up @@ -175,7 +178,8 @@ def uninstall_tool(self, env):
f"Failed to remove copied file in container {file_copy.dest} during uninstallation of tool {self.name}. Output: {output}"
)

for command in self.uninstall_commands:
distro_ids = env.get_distro_ids()
for command in self.resolve_commands("uninstall", distro_ids):
output = env.execute_privileged(command)
if output.return_code != 0:
logger.error(
Expand Down
97 changes: 92 additions & 5 deletions src/microbots/tools/tool.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from abc import ABC, abstractmethod
from typing import Optional, List
from collections.abc import Sequence
from typing import Dict, Optional, List, Union
from pydantic import field_validator
from pydantic.dataclasses import dataclass, Field
import logging
from enum import Enum
Expand All @@ -11,6 +13,19 @@
logger = logging.getLogger(" 🔧 Tool")


# Key of a command map whose commands apply to any distro without its own entry.
GENERIC_COMMANDS_KEY = "generic"

# A command field is either a list (generic: runs on every distro) or a map
# from distro ID (as in /etc/os-release, e.g. "debian", "azurelinux") and/or
# "generic" to the commands for that distro.
ToolCommands = Union[List[str], Dict[str, List[str]]]


class UnsupportedDistroError(RuntimeError):
"""Raised when a tool defines no commands for the target distro."""


class TOOLTYPE(str, Enum):
INTERNAL = "internal"
EXTERNAL = "external"
Expand Down Expand Up @@ -63,7 +78,9 @@ class ToolAbstract(ABC):
# while commands for external tools will be executed in the current environment (Host).
# So, you should be careful about it as it is making changes to your system.
# These commands will be executed in the order they are provided.
install_commands: Optional[List[str]] = Field(default_factory=list)
# Every *_commands field takes either a list (generic, any distro) or a map
# keyed by distro ID and/or "generic"; see resolve_commands().
install_commands: Optional[ToolCommands] = Field(default_factory=list)

# Optional parameters for the tool
parameters: Optional[dict] = Field(default_factory=dict)
Expand All @@ -75,15 +92,85 @@ class ToolAbstract(ABC):

# This set of commands will be executed to verify if the tool is installed correctly.
# If any of these commands fail, the tool installation is considered to have failed.
verify_commands: Optional[List[str]] = Field(default_factory=list)
verify_commands: Optional[ToolCommands] = Field(default_factory=list)

# This set of commands will be executed after the code is copied to the environment
# and before the llm is invoked.
# For internal tools, these commands will be executed inside the mounted folder.
setup_commands: Optional[List[str]] = Field(default_factory=list)
setup_commands: Optional[ToolCommands] = Field(default_factory=list)

# This set of commands will be executed when the environment is being torn down.
uninstall_commands: Optional[List[str]] = Field(default_factory=list)
uninstall_commands: Optional[ToolCommands] = Field(default_factory=list)

@field_validator(
"install_commands", "verify_commands", "setup_commands", "uninstall_commands"
)
@classmethod
def _normalize_command_map(cls, value):
"""Lower-case distro keys of a command map and reject empty/duplicate keys."""
if not isinstance(value, dict):
return value
normalized = {}
for key, commands in value.items():
distro = key.strip().lower()
if not distro:
raise ValueError("command map keys must be non-empty distro IDs or 'generic'")
if distro in normalized:
raise ValueError(f"duplicate command map key {key!r}")
normalized[distro] = commands
return normalized

def resolve_commands(self, kind: str, distro_ids: Sequence[str]) -> List[str]:
"""Select the ``<kind>_commands`` to run on the target distro.

A list applies to every distro. For a map, the first entry matching the
distro IDs (``ID`` then ``ID_LIKE``) wins, then ``generic``. When the
distro cannot be determined (no ``/etc/os-release``), ``generic`` is
used, else the first entry in the map; a failing command is then the
last line of defence.

Parameters
----------
kind : str
``"install"``, ``"verify"``, ``"setup"`` or ``"uninstall"``.
distro_ids : Sequence[str]
The target's distro IDs (``ID`` then ``ID_LIKE``); empty when the
distro is unknown. Only consulted for command maps.

Returns
-------
List[str]
The commands to run, in order (possibly empty).

Raises
------
UnsupportedDistroError
If the distro is known but the command map has neither a matching
entry nor ``generic``.
"""
spec = getattr(self, f"{kind}_commands") or []
if isinstance(spec, list):
return list(spec)

if not spec:
return []

ids = [i.lower() for i in distro_ids]
for key in (*ids, GENERIC_COMMANDS_KEY):
if key in spec:
return list(spec[key])
if not ids:
fallback = next(iter(spec))
logger.warning(
"⚠️ Could not detect the distro for tool '%s'; trying its '%s' %s commands",
self.name, fallback, kind,
)
return list(spec[fallback])
raise UnsupportedDistroError(
f"Tool '{self.name}' has no {kind} commands for distro "
f"{'/'.join(ids)}; it defines {sorted(spec)}. "
f"Add a '{ids[0]}' or '{GENERIC_COMMANDS_KEY}' entry."
)

def is_invoked(self, command: str) -> bool:
return False # Default implementation, override in subclasses if needed
Expand Down
11 changes: 7 additions & 4 deletions src/microbots/tools/tool_definitions/browser-use.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ files_to_copy:
dest: /sbin/browser
permissions: 7 # rwx

# `playwright install --with-deps` installs browser libraries with apt-get, so
# only Debian-based sandboxes are supported.
install_commands:
# Install it system-wide using pip
- pip install --no-input browser-use==0.5.4 playwright
- playwright install --with-deps
- playwright install chromium --with-deps --no-shell
debian:
# Install it system-wide using pip
- pip install --no-input browser-use==0.5.4 playwright
- playwright install --with-deps
- playwright install chromium --with-deps --no-shell

setup_commands:
- cat /sbin/browser
Expand Down
12 changes: 8 additions & 4 deletions src/microbots/tools/tool_definitions/cscope.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ usage_instructions_to_llm: |

# End of non-interactive instructions.

# Commands are keyed by distro (/etc/os-release ID); installation fails on
# distros without an entry.
install_commands:
- apt-get update
- apt-get install -y cscope
debian:
- apt-get update
- apt-get install -y cscope

verify_commands:
- cscope -V
Expand All @@ -66,5 +69,6 @@ setup_commands:
- cscope -bR

uninstall_commands:
- apt-get remove -y cscope
- apt-get autoremove -y
debian:
- apt-get remove -y cscope
- apt-get autoremove -y
Loading
Loading