Build session-based agent tool loop
Some checks failed
Test and publish / publish (push) Failing after 2s
Some checks failed
Test and publish / publish (push) Failing after 2s
This commit is contained in:
9
packages/micro-agents-tools/README.md
Normal file
9
packages/micro-agents-tools/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# micro-agents-tools
|
||||
|
||||
Optional filesystem and Python-execution tools for `micro-agents`.
|
||||
|
||||
`FilesystemTools` includes `attach_files(paths)`, which lets the model select images or documents under the session `base_dir` and add them to its conversation as real attachments. The same path-containment checks used by the other filesystem tools apply.
|
||||
|
||||
`PythonTools` executes arbitrary code. It has a timeout and per-session working directory, but is not a security sandbox; use a container or VM for untrusted code.
|
||||
|
||||
`BraveSearchTools` reads its token from `BRAVE_SEARCH_API_KEY` and exposes a `web_search` tool. The token is not passed to the model.
|
||||
20
packages/micro-agents-tools/pyproject.toml
Normal file
20
packages/micro-agents-tools/pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "micro-agents-tools"
|
||||
version = "0.1.0"
|
||||
description = "Basic opt-in tools for micro-agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"micro-agents>=0.1.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.azimkin.dev/Azimkin/micro-agents"
|
||||
Issues = "https://git.azimkin.dev/Azimkin/micro-agents/issues"
|
||||
|
||||
[tool.uv.sources]
|
||||
micro-agents = { workspace = true }
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.2,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Optional, basic tools for micro-agents."""
|
||||
|
||||
from micro_agents_tools.brave import BraveSearchTools
|
||||
from micro_agents_tools.filesystem import FilesystemTools
|
||||
from micro_agents_tools.python import PythonTools
|
||||
|
||||
__all__ = ["BraveSearchTools", "FilesystemTools", "PythonTools"]
|
||||
92
packages/micro-agents-tools/src/micro_agents_tools/brave.py
Normal file
92
packages/micro-agents-tools/src/micro_agents_tools/brave.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Brave Search API tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from micro_agents import Tool
|
||||
|
||||
|
||||
class BraveSearchTools:
|
||||
"""Opt-in web search backed by the Brave Search API.
|
||||
|
||||
The API token comes from ``BRAVE_SEARCH_API_KEY`` unless supplied directly.
|
||||
It is retained by this object only and is never exposed to the LLM.
|
||||
"""
|
||||
|
||||
_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
|
||||
|
||||
def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 10):
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
resolved_api_key = api_key or os.environ.get("BRAVE_SEARCH_API_KEY")
|
||||
if not resolved_api_key:
|
||||
raise ValueError("Set BRAVE_SEARCH_API_KEY before creating BraveSearchTools")
|
||||
self._api_key: str = resolved_api_key
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
return [Tool(self.search_web, name="web_search", strict=False)]
|
||||
|
||||
def search_web(self, query: str, count: int = 10, country: str = "US", search_lang: str = "en") -> str:
|
||||
"""Search the web and return up to 20 title, URL, and description results."""
|
||||
query = query.strip()
|
||||
if not query:
|
||||
raise ValueError("query must not be empty")
|
||||
if len(query) > 600 or len(query.split()) > 75:
|
||||
raise ValueError("query exceeds Brave Search limits")
|
||||
if not 1 <= count <= 20:
|
||||
raise ValueError("count must be between 1 and 20")
|
||||
|
||||
url = self._ENDPOINT + "?" + urlencode(
|
||||
{"q": query, "count": count, "country": country.upper(), "search_lang": search_lang}
|
||||
)
|
||||
payload = self._request(url)
|
||||
return json.dumps({"query": query, "results": self._results(payload)}, ensure_ascii=False)
|
||||
|
||||
def _request(self, url: str) -> Mapping[str, object]:
|
||||
request = Request(
|
||||
url,
|
||||
headers={"Accept": "application/json", "X-Subscription-Token": self._api_key},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=self._timeout_seconds) as response:
|
||||
payload = json.load(response)
|
||||
except HTTPError as error:
|
||||
raise RuntimeError(f"Brave Search returned HTTP {error.code}") from error
|
||||
except URLError as error:
|
||||
raise RuntimeError(f"Brave Search request failed: {error.reason}") from error
|
||||
except (OSError, TimeoutError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError("Brave Search returned an invalid response") from error
|
||||
if not isinstance(payload, Mapping):
|
||||
raise RuntimeError("Brave Search returned an invalid response")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _results(payload: Mapping[str, object]) -> list[dict[str, str]]:
|
||||
web = payload.get("web")
|
||||
if not isinstance(web, Mapping):
|
||||
return []
|
||||
raw_results = web.get("results")
|
||||
if not isinstance(raw_results, list):
|
||||
return []
|
||||
|
||||
results: list[dict[str, str]] = []
|
||||
for raw_result in raw_results:
|
||||
if not isinstance(raw_result, Mapping):
|
||||
continue
|
||||
title = raw_result.get("title")
|
||||
url = raw_result.get("url")
|
||||
if not isinstance(title, str) or not isinstance(url, str):
|
||||
continue
|
||||
description = raw_result.get("description")
|
||||
result = {"title": title, "url": url}
|
||||
if isinstance(description, str) and description:
|
||||
result["description"] = description
|
||||
results.append(result)
|
||||
return results
|
||||
@@ -0,0 +1 @@
|
||||
"""Runnable examples for micro-agents-tools."""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Set DEEPSEEK_API_KEY, edit PROMPT, then run this module."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from micro_agents import Aggregator, OpenAIBasedLLM, ToolManager
|
||||
from micro_agents_tools import FilesystemTools
|
||||
|
||||
|
||||
PROMPT = "Create hello.txt with a short greeting."
|
||||
|
||||
llm = OpenAIBasedLLM(
|
||||
api_url="https://api.deepseek.com",
|
||||
api_key=os.environ["DEEPSEEK_API_KEY"],
|
||||
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-flash"),
|
||||
)
|
||||
agent = Aggregator(
|
||||
llm,
|
||||
ToolManager(FilesystemTools().tools()),
|
||||
master_prompt=lambda _: "Use tools to work only in the test directory.",
|
||||
)
|
||||
result = asyncio.run(agent.create_session(context={"base_dir": Path("test")}).run(PROMPT))
|
||||
|
||||
if result.succeeded:
|
||||
final_message = result.final_message
|
||||
assert final_message is not None
|
||||
print(final_message.message())
|
||||
else:
|
||||
print(f"Agent failed: {result.error}")
|
||||
126
packages/micro-agents-tools/src/micro_agents_tools/filesystem.py
Normal file
126
packages/micro-agents-tools/src/micro_agents_tools/filesystem.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Filesystem tools confined to ``base_dir`` from the session context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from micro_agents import Injected, Media, SessionContext, Tool, ToolOutput
|
||||
|
||||
|
||||
def context_base_dir(context: SessionContext, key: str) -> Path:
|
||||
"""Return the existing base directory selected by the embedding application."""
|
||||
value = context.get(key)
|
||||
if not isinstance(value, (Path, str)):
|
||||
raise ValueError(f"Session context must contain a Path or str under '{key}'")
|
||||
base_dir = Path(value).resolve()
|
||||
if not base_dir.is_dir():
|
||||
raise ValueError(f"Context directory does not exist: {base_dir}")
|
||||
return base_dir
|
||||
|
||||
|
||||
class FilesystemTools:
|
||||
"""Basic filesystem tools restricted to a per-session base directory."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_dir_key: str = "base_dir",
|
||||
max_read_chars: int = 20_000,
|
||||
max_attachment_bytes: int = 50 * 1024 * 1024,
|
||||
):
|
||||
if max_read_chars < 1:
|
||||
raise ValueError("max_read_chars must be positive")
|
||||
if max_attachment_bytes < 1:
|
||||
raise ValueError("max_attachment_bytes must be positive")
|
||||
self._base_dir_key = base_dir_key
|
||||
self._max_read_chars = max_read_chars
|
||||
self._max_attachment_bytes = max_attachment_bytes
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
return [
|
||||
Tool(self.list_files, name="list_files", strict=False),
|
||||
Tool(self.read_file, name="read_file", strict=False),
|
||||
Tool(self.attach_files, name="attach_files", strict=False),
|
||||
Tool(self.write_file, name="write_file", strict=False),
|
||||
Tool(self.make_directory, name="make_directory", strict=False),
|
||||
]
|
||||
|
||||
def list_files(self, path: str = ".", context: Annotated[SessionContext | None, Injected] = None) -> list[str]:
|
||||
"""List direct children of a relative directory."""
|
||||
target = self._resolve(path, context)
|
||||
if not target.is_dir():
|
||||
raise ValueError(f"Not a directory: {path}")
|
||||
return sorted(
|
||||
item.relative_to(self._base_dir(context)).as_posix() + ("/" if item.is_dir() else "")
|
||||
for item in target.iterdir()
|
||||
)
|
||||
|
||||
def read_file(self, path: str, context: Annotated[SessionContext | None, Injected] = None) -> str:
|
||||
"""Read a UTF-8 file, truncated to the configured maximum size."""
|
||||
target = self._resolve(path, context)
|
||||
if not target.is_file():
|
||||
raise ValueError(f"Not a file: {path}")
|
||||
content = target.read_text(encoding="utf-8")
|
||||
if len(content) <= self._max_read_chars:
|
||||
return content
|
||||
return content[:self._max_read_chars] + "\n...[truncated]"
|
||||
|
||||
def attach_files(
|
||||
self,
|
||||
paths: list[str],
|
||||
context: Annotated[SessionContext | None, Injected] = None,
|
||||
) -> ToolOutput:
|
||||
"""Attach selected images or documents to the model context.
|
||||
|
||||
Params:
|
||||
paths: Relative paths of the files to attach.
|
||||
"""
|
||||
if not paths:
|
||||
raise ValueError("At least one path is required")
|
||||
targets = [self._resolve(path, context) for path in paths]
|
||||
for path, target in zip(paths, targets, strict=True):
|
||||
if not target.is_file():
|
||||
raise ValueError(f"Not a file: {path}")
|
||||
total_bytes = sum(target.stat().st_size for target in targets)
|
||||
if total_bytes > self._max_attachment_bytes:
|
||||
raise ValueError(f"Attachments exceed the {self._max_attachment_bytes}-byte limit")
|
||||
|
||||
attachments = tuple(Media.from_path(target) for target in targets)
|
||||
if any(attachment.kind == "audio" for attachment in attachments):
|
||||
raise ValueError("Audio cannot be attached from a function tool output")
|
||||
relative_paths = [target.relative_to(self._base_dir(context)).as_posix() for target in targets]
|
||||
return ToolOutput(f"Attached files: {', '.join(relative_paths)}", attachments)
|
||||
|
||||
def write_file(
|
||||
self,
|
||||
path: str,
|
||||
content: str,
|
||||
context: Annotated[SessionContext | None, Injected] = None,
|
||||
) -> str:
|
||||
"""Create or replace a UTF-8 text file at a relative path."""
|
||||
target = self._resolve(path, context)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return f"Wrote {target.relative_to(self._base_dir(context)).as_posix()}"
|
||||
|
||||
def make_directory(self, path: str, context: Annotated[SessionContext | None, Injected] = None) -> str:
|
||||
"""Create a relative directory and any missing parents."""
|
||||
target = self._resolve(path, context)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return f"Created {target.relative_to(self._base_dir(context)).as_posix()}"
|
||||
|
||||
def _base_dir(self, context: SessionContext | None) -> Path:
|
||||
if context is None:
|
||||
raise RuntimeError("Session context was not injected")
|
||||
return context_base_dir(context, self._base_dir_key)
|
||||
|
||||
def _resolve(self, relative_path: str, context: SessionContext | None) -> Path:
|
||||
path = Path(relative_path)
|
||||
if path.is_absolute():
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
base_dir = self._base_dir(context)
|
||||
resolved = (base_dir / path).resolve()
|
||||
if resolved != base_dir and base_dir not in resolved.parents:
|
||||
raise ValueError("Path escapes the session base directory")
|
||||
return resolved
|
||||
63
packages/micro-agents-tools/src/micro_agents_tools/python.py
Normal file
63
packages/micro-agents-tools/src/micro_agents_tools/python.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Opt-in Python execution tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Annotated
|
||||
|
||||
from micro_agents import Injected, SessionContext, Tool
|
||||
from micro_agents_tools.filesystem import context_base_dir
|
||||
|
||||
|
||||
class PythonTools:
|
||||
"""Run Python code in the session's base directory.
|
||||
|
||||
This is process isolation, not a security sandbox. Only enable it for code
|
||||
the embedding application trusts, or run the application in a container/VM.
|
||||
"""
|
||||
|
||||
def __init__(self, *, base_dir_key: str = "base_dir", timeout_seconds: float = 10, max_output_chars: int = 20_000):
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
if max_output_chars < 1:
|
||||
raise ValueError("max_output_chars must be positive")
|
||||
self._base_dir_key = base_dir_key
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._max_output_chars = max_output_chars
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
return [Tool(self.execute_python, name="execute_python", strict=False)]
|
||||
|
||||
def execute_python(self, code: str, context: Annotated[SessionContext | None, Injected] = None) -> str:
|
||||
"""Execute Python code in the session base directory and return its output."""
|
||||
if context is None:
|
||||
raise RuntimeError("Session context was not injected")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-I", "-c", code],
|
||||
cwd=context_base_dir(context, self._base_dir_key),
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
text=True,
|
||||
timeout=self._timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
return self._result(None, self._as_text(error.stdout), self._as_text(error.stderr), timed_out=True)
|
||||
return self._result(completed.returncode, completed.stdout, completed.stderr, timed_out=False)
|
||||
|
||||
def _result(self, exit_code: int | None, stdout: str, stderr: str, *, timed_out: bool) -> str:
|
||||
return json.dumps(
|
||||
{"exit_code": exit_code, "stdout": self._clip(stdout), "stderr": self._clip(stderr), "timed_out": timed_out},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _clip(self, text: str) -> str:
|
||||
return text if len(text) <= self._max_output_chars else text[:self._max_output_chars] + "\n...[truncated]"
|
||||
|
||||
@staticmethod
|
||||
def _as_text(value: bytes | str | None) -> str:
|
||||
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value or ""
|
||||
100
packages/micro-agents-tools/tests/test_tools.py
Normal file
100
packages/micro-agents-tools/tests/test_tools.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from micro_agents import OpenAIBasedLLM, ToolCallMessage, ToolManager
|
||||
from micro_agents_tools import BraveSearchTools, FilesystemTools, PythonTools
|
||||
|
||||
|
||||
class FakeBraveSearchTools(BraveSearchTools):
|
||||
def _request(self, url):
|
||||
self.url = url
|
||||
return {
|
||||
"web": {
|
||||
"results": [
|
||||
{"title": "Example", "url": "https://example.com", "description": "A result"},
|
||||
{"title": "Missing URL"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FilesystemToolsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_brave_search_returns_compact_results(self):
|
||||
tools = FakeBraveSearchTools(api_key="test-key")
|
||||
response = await ToolManager(tools.tools()).execute(
|
||||
ToolCallMessage("search", "web_search", {"query": "micro agents", "count": 3}),
|
||||
{},
|
||||
)
|
||||
payload = json.loads(response.response.text())
|
||||
|
||||
self.assertIn("q=micro+agents", tools.url)
|
||||
self.assertEqual(
|
||||
[{"title": "Example", "url": "https://example.com", "description": "A result"}],
|
||||
payload["results"],
|
||||
)
|
||||
|
||||
async def test_filesystem_tools_use_the_context_base_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory) / "test"
|
||||
root.mkdir()
|
||||
manager = ToolManager(FilesystemTools().tools())
|
||||
|
||||
write = await manager.execute(
|
||||
ToolCallMessage("write", "write_file", {"path": "nested/hello.txt", "content": "hello"}),
|
||||
{"base_dir": root},
|
||||
)
|
||||
read = await manager.execute(
|
||||
ToolCallMessage("read", "read_file", {"path": "nested/hello.txt"}),
|
||||
{"base_dir": root},
|
||||
)
|
||||
|
||||
self.assertEqual("Wrote nested/hello.txt", write.response.text())
|
||||
self.assertEqual("hello", read.response.text())
|
||||
|
||||
async def test_filesystem_tools_reject_path_escapes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory) / "test"
|
||||
root.mkdir()
|
||||
response = await ToolManager(FilesystemTools().tools()).execute(
|
||||
ToolCallMessage("write", "write_file", {"path": "../outside.txt", "content": "nope"}),
|
||||
{"base_dir": root},
|
||||
)
|
||||
|
||||
self.assertTrue(response.response.is_error())
|
||||
self.assertEqual("tool_execution_failed", response.response.error_code())
|
||||
|
||||
async def test_python_tool_uses_the_session_base_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
base_dir = Path(temporary_directory)
|
||||
code = "from pathlib import Path; Path('created.txt').write_text('ok'); print(Path.cwd())"
|
||||
response = await ToolManager(PythonTools().tools()).execute(
|
||||
ToolCallMessage("call-1", "execute_python", {"code": code}),
|
||||
{"base_dir": base_dir},
|
||||
)
|
||||
payload = json.loads(response.response.text())
|
||||
|
||||
self.assertEqual(0, payload["exit_code"])
|
||||
self.assertFalse(payload["timed_out"])
|
||||
self.assertEqual("ok", (base_dir / "created.txt").read_text())
|
||||
|
||||
async def test_attach_files_returns_model_attachments(self):
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
base_dir = Path(temporary_directory)
|
||||
(base_dir / "note.txt").write_text("hello", encoding="utf-8")
|
||||
response = await ToolManager(FilesystemTools().tools()).execute(
|
||||
ToolCallMessage("attach", "attach_files", {"paths": ["note.txt"]}),
|
||||
{"base_dir": base_dir},
|
||||
)
|
||||
|
||||
self.assertEqual(["note.txt"], [item.filename for item in response.response.attachments()])
|
||||
serialized = OpenAIBasedLLM._serialize_tool_call_response_message(response.response)
|
||||
output = serialized["output"]
|
||||
assert isinstance(output, list)
|
||||
self.assertEqual("input_text", output[0]["type"])
|
||||
self.assertEqual("input_file", output[1]["type"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user