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:
@@ -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 ""
|
||||
Reference in New Issue
Block a user