chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._execute_code_tool import MontyExecuteCodeTool
|
||||
from ._provider import MontyCodeActProvider
|
||||
from ._types import FileMount, FileMountInput, MountMode
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"FileMount",
|
||||
"FileMountInput",
|
||||
"MontyCodeActProvider",
|
||||
"MontyExecuteCodeTool",
|
||||
"MountMode",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,558 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""``MontyExecuteCodeTool`` - a ``FunctionTool`` that runs Python in Monty.
|
||||
|
||||
Mirrors the public API of ``HyperlightExecuteCodeTool`` for the subset that
|
||||
applies to a pure-Python interpreter (no backends to choose from). By default
|
||||
the Monty sandbox rejects OS / filesystem / network calls with
|
||||
``PermissionError``; pass ``workspace_root`` or ``file_mounts`` to expose
|
||||
scoped host directories, and the tool will capture any files written under
|
||||
``read-write`` mounts as ``Content`` items in the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from copy import copy
|
||||
from functools import partial
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import Content, FunctionTool
|
||||
from agent_framework._tools import ApprovalMode, normalize_tools
|
||||
|
||||
from ._instructions import build_codeact_instructions, build_execute_code_description
|
||||
from ._monty_bridge import InlineCodeBridge, generate_type_stubs
|
||||
from ._types import FileMount, FileMountInput
|
||||
|
||||
EXECUTE_CODE_TOOL_NAME = "execute_code"
|
||||
EXECUTE_CODE_TOOL_DESCRIPTION = "Execute Python in a Monty interpreter."
|
||||
|
||||
#: Virtual path that the optional ``workspace_root`` directory is mounted at,
|
||||
#: matching the Hyperlight default. Use ``file_mounts`` for any other path.
|
||||
WORKSPACE_MOUNT_PATH = "/input"
|
||||
|
||||
#: Maximum bytes per captured output file. Files larger than this are skipped
|
||||
#: and a ``Content.from_text`` warning is appended in their place.
|
||||
MAX_CAPTURED_FILE_BYTES = 5 * 1024 * 1024 # 5 MiB
|
||||
|
||||
EXECUTE_CODE_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"title": "_ExecuteCodeInput",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"title": "Code",
|
||||
"description": "Python code to execute in a Monty interpreter.",
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
}
|
||||
|
||||
|
||||
def _collect_tools(*tool_groups: Any) -> list[FunctionTool]:
|
||||
"""Merge tool groups, dropping any ``execute_code`` entries and deduping by name."""
|
||||
tools_by_name: dict[str, FunctionTool] = {}
|
||||
|
||||
for tool_group in tool_groups:
|
||||
normalized_group = normalize_tools(tool_group)
|
||||
for tool_obj in normalized_group:
|
||||
if not isinstance(tool_obj, FunctionTool):
|
||||
continue
|
||||
if tool_obj.name == EXECUTE_CODE_TOOL_NAME:
|
||||
continue
|
||||
tools_by_name.pop(tool_obj.name, None)
|
||||
tools_by_name[tool_obj.name] = tool_obj
|
||||
|
||||
return list(tools_by_name.values())
|
||||
|
||||
|
||||
def _resolve_execute_code_approval_mode(
|
||||
*,
|
||||
base_approval_mode: ApprovalMode,
|
||||
tools: Sequence[FunctionTool],
|
||||
) -> ApprovalMode:
|
||||
if base_approval_mode == "always_require":
|
||||
return "always_require"
|
||||
if any(tool_obj.approval_mode == "always_require" for tool_obj in tools):
|
||||
return "always_require"
|
||||
return "never_require"
|
||||
|
||||
|
||||
def _normalize_mount_path(mount_path: str) -> str:
|
||||
"""Normalize a virtual mount path to a clean POSIX absolute path."""
|
||||
raw = mount_path.strip().replace("\\", "/")
|
||||
if not raw:
|
||||
raise ValueError("mount_path must not be empty.")
|
||||
pure = PurePosixPath(raw)
|
||||
parts = [part for part in pure.parts if part not in {"", "/", "."}]
|
||||
if any(part == ".." for part in parts):
|
||||
raise ValueError("mount_path must not contain '..' segments.")
|
||||
if not parts:
|
||||
raise ValueError("mount_path must point to a concrete absolute path.")
|
||||
return "/" + "/".join(parts)
|
||||
|
||||
|
||||
def _resolve_existing_directory(value: str | Path) -> Path:
|
||||
resolved = Path(value).expanduser().resolve(strict=True)
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f"Path {value!r} must point to an existing directory.")
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_file_mount_pair(value: Any) -> bool:
|
||||
if not isinstance(value, tuple) or isinstance(value, FileMount):
|
||||
return False
|
||||
items = cast("tuple[object, ...]", value)
|
||||
if len(items) != 2:
|
||||
return False
|
||||
host_path, mount_path = items
|
||||
return isinstance(host_path, (str, Path)) and isinstance(mount_path, str)
|
||||
|
||||
|
||||
def _normalize_file_mount(file_mount: FileMountInput) -> FileMount:
|
||||
if isinstance(file_mount, FileMount):
|
||||
host_path = file_mount.host_path
|
||||
mount_path = file_mount.mount_path
|
||||
mode = file_mount.mode
|
||||
write_limit = file_mount.write_bytes_limit
|
||||
elif isinstance(file_mount, str):
|
||||
host_path = file_mount
|
||||
mount_path = file_mount
|
||||
mode = "overlay"
|
||||
write_limit = None
|
||||
else:
|
||||
host_path, mount_path = file_mount
|
||||
mode = "overlay"
|
||||
write_limit = None
|
||||
|
||||
return FileMount(
|
||||
host_path=_resolve_existing_directory(host_path),
|
||||
mount_path=_normalize_mount_path(mount_path),
|
||||
mode=mode,
|
||||
write_bytes_limit=write_limit,
|
||||
)
|
||||
|
||||
|
||||
def _to_monty_mount(file_mount: FileMount) -> Any:
|
||||
"""Convert a public :class:`FileMount` to Monty's ``MountDir``.
|
||||
|
||||
Imports lazily through the bridge's loader so missing-dependency errors
|
||||
surface as the same actionable ``RuntimeError`` the rest of the package
|
||||
raises, rather than a bare ``ImportError`` from a top-level import.
|
||||
"""
|
||||
from ._monty_bridge import load_monty # avoid top-level pydantic_monty import
|
||||
|
||||
monty_module = load_monty()
|
||||
return monty_module.MountDir(
|
||||
virtual_path=file_mount.mount_path,
|
||||
host_path=str(file_mount.host_path),
|
||||
mode=file_mount.mode,
|
||||
write_bytes_limit=file_mount.write_bytes_limit,
|
||||
)
|
||||
|
||||
|
||||
def _make_tool_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
|
||||
"""Return an async callable that invokes ``tool_obj`` with the bridge's kwargs.
|
||||
|
||||
Returns the raw native value (no ``Content`` wrapping) so the Monty interpreter
|
||||
receives real Python objects. ``FunctionTool.invoke`` accepts direct keyword
|
||||
arguments and handles both sync and async underlying functions internally.
|
||||
"""
|
||||
return partial(copy(tool_obj).invoke, skip_parsing=True)
|
||||
|
||||
|
||||
class MontyExecuteCodeTool(FunctionTool):
|
||||
"""Execute Python code inside a Monty interpreter.
|
||||
|
||||
Tools registered on this object are available inside the interpreter as
|
||||
typed async functions (e.g. ``await tool_name(...)``). Argument types are
|
||||
validated by the [ty](https://docs.astral.sh/ty/) type checker before any
|
||||
host tool runs.
|
||||
|
||||
Optional filesystem access is exposed via:
|
||||
|
||||
- ``workspace_root`` — auto-mounts a host directory at ``/input`` (matching
|
||||
Hyperlight's default).
|
||||
- ``file_mounts`` — extra :class:`FileMount` entries for fine-grained
|
||||
control (mount path, read-only / read-write / overlay mode, write
|
||||
byte caps).
|
||||
|
||||
Files written by sandboxed code to any **read-write** mount are scanned
|
||||
after execution and returned as ``Content.from_data`` items, mirroring
|
||||
Hyperlight's ``/output`` flow.
|
||||
|
||||
``resource_limits`` is forwarded to Monty's ``ResourceLimits`` to cap CPU
|
||||
time, memory, output size, recursion depth, and GC frequency.
|
||||
|
||||
All mutators (``add_tools``, ``add_file_mounts`` etc.) must be called from
|
||||
the same task/thread that owns the tool. Monty itself runs on the event
|
||||
loop, so no internal locking is needed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
workspace_root: str | Path | None = None,
|
||||
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
|
||||
resource_limits: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=EXECUTE_CODE_TOOL_NAME,
|
||||
description=EXECUTE_CODE_TOOL_DESCRIPTION,
|
||||
approval_mode="never_require",
|
||||
func=self._run_code,
|
||||
input_model=EXECUTE_CODE_INPUT_SCHEMA,
|
||||
)
|
||||
self._default_approval_mode: ApprovalMode = approval_mode or "never_require"
|
||||
self._managed_tools: list[FunctionTool] = []
|
||||
self._workspace_root: Path | None = (
|
||||
_resolve_existing_directory(workspace_root) if workspace_root is not None else None
|
||||
)
|
||||
self._file_mounts: dict[str, FileMount] = {}
|
||||
self._resource_limits: dict[str, Any] | None = dict(resource_limits) if resource_limits else None
|
||||
|
||||
if tools is not None:
|
||||
self.add_tools(tools)
|
||||
if file_mounts is not None:
|
||||
self.add_file_mounts(file_mounts)
|
||||
|
||||
self._refresh_approval_mode()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
# During FunctionTool.__init__, ``_managed_tools`` is not yet set.
|
||||
if not hasattr(self, "_managed_tools"):
|
||||
return str(self.__dict__.get("description", EXECUTE_CODE_TOOL_DESCRIPTION))
|
||||
return build_execute_code_description(
|
||||
tools=self._managed_tools,
|
||||
mounts=self._effective_mounts(),
|
||||
)
|
||||
|
||||
@description.setter
|
||||
def description(self, value: str) -> None:
|
||||
self.__dict__["description"] = value
|
||||
|
||||
def add_tools(
|
||||
self,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
|
||||
) -> None:
|
||||
"""Add Monty-side tools to this execute_code surface."""
|
||||
self._managed_tools = _collect_tools(self._managed_tools, tools)
|
||||
self._refresh_approval_mode()
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""Return the currently managed Monty tools."""
|
||||
return list(self._managed_tools)
|
||||
|
||||
def remove_tool(self, name: str) -> None:
|
||||
"""Remove one managed Monty tool by name."""
|
||||
remaining_tools = [tool_obj for tool_obj in self._managed_tools if tool_obj.name != name]
|
||||
if len(remaining_tools) == len(self._managed_tools):
|
||||
raise KeyError(f"No managed tool named {name!r} is registered.")
|
||||
self._managed_tools = remaining_tools
|
||||
self._refresh_approval_mode()
|
||||
|
||||
def clear_tools(self) -> None:
|
||||
"""Remove all managed Monty tools."""
|
||||
self._managed_tools = []
|
||||
self._refresh_approval_mode()
|
||||
|
||||
def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
|
||||
"""Add one or more file mounts.
|
||||
|
||||
A single string mounts the same path on both sides. Use a
|
||||
``(host_path, mount_path)`` tuple or :class:`FileMount` when the paths
|
||||
differ or when you need to set the mount mode / write limit.
|
||||
"""
|
||||
if isinstance(file_mounts, (str, FileMount)) or _is_file_mount_pair(file_mounts):
|
||||
normalized = [_normalize_file_mount(cast("FileMountInput", file_mounts))]
|
||||
else:
|
||||
normalized = [_normalize_file_mount(item) for item in cast("Sequence[FileMountInput]", file_mounts)]
|
||||
|
||||
for mount in normalized:
|
||||
self._file_mounts[mount.mount_path] = mount
|
||||
|
||||
def get_file_mounts(self) -> list[FileMount]:
|
||||
"""Return the configured file mounts (excluding ``workspace_root``)."""
|
||||
return list(self._file_mounts.values())
|
||||
|
||||
def remove_file_mount(self, mount_path: str) -> None:
|
||||
"""Remove one file mount by its sandbox path."""
|
||||
normalized = _normalize_mount_path(mount_path)
|
||||
if normalized not in self._file_mounts:
|
||||
raise KeyError(f"No file mount exists for {mount_path!r}.")
|
||||
del self._file_mounts[normalized]
|
||||
|
||||
def clear_file_mounts(self) -> None:
|
||||
"""Remove all configured file mounts."""
|
||||
self._file_mounts.clear()
|
||||
|
||||
@property
|
||||
def workspace_root(self) -> Path | None:
|
||||
"""Return the configured workspace root, if any."""
|
||||
return self._workspace_root
|
||||
|
||||
@property
|
||||
def resource_limits(self) -> dict[str, Any] | None:
|
||||
"""Return the configured Monty :class:`pydantic_monty.ResourceLimits`, if any."""
|
||||
return dict(self._resource_limits) if self._resource_limits else None
|
||||
|
||||
def build_instructions(self, *, tools_visible_to_model: bool) -> str:
|
||||
"""Build the current CodeAct instructions for this execute_code surface."""
|
||||
return build_codeact_instructions(
|
||||
tools=list(self._managed_tools),
|
||||
tools_visible_to_model=tools_visible_to_model,
|
||||
mounts=self._effective_mounts(),
|
||||
)
|
||||
|
||||
def create_run_tool(self) -> MontyExecuteCodeTool:
|
||||
"""Create a run-scoped snapshot of this execute_code surface."""
|
||||
return MontyExecuteCodeTool(
|
||||
tools=self.get_tools(),
|
||||
approval_mode=self._default_approval_mode,
|
||||
workspace_root=self._workspace_root,
|
||||
file_mounts=list(self._file_mounts.values()) or None,
|
||||
resource_limits=self._resource_limits,
|
||||
)
|
||||
|
||||
def build_serializable_state(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable snapshot of the effective run state."""
|
||||
approval_mode = _resolve_execute_code_approval_mode(
|
||||
base_approval_mode=self._default_approval_mode,
|
||||
tools=self._managed_tools,
|
||||
)
|
||||
mounts = self._effective_mounts()
|
||||
return {
|
||||
"runtime": "monty",
|
||||
"approval_mode": approval_mode,
|
||||
"tool_names": [tool_obj.name for tool_obj in self._managed_tools],
|
||||
"workspace_root": str(self._workspace_root) if self._workspace_root is not None else None,
|
||||
"file_mounts": [
|
||||
{
|
||||
"host_path": str(mount.host_path),
|
||||
"mount_path": mount.mount_path,
|
||||
"mode": mount.mode,
|
||||
"write_bytes_limit": mount.write_bytes_limit,
|
||||
}
|
||||
for mount in mounts
|
||||
],
|
||||
"resource_limits": dict(self._resource_limits) if self._resource_limits else None,
|
||||
}
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
# Materialize the dynamic description so the dump captures the current tool list.
|
||||
self.__dict__["description"] = self.description
|
||||
return super().to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
|
||||
def _refresh_approval_mode(self) -> None:
|
||||
self.approval_mode = _resolve_execute_code_approval_mode(
|
||||
base_approval_mode=self._default_approval_mode,
|
||||
tools=self._managed_tools,
|
||||
)
|
||||
|
||||
def _build_tool_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]:
|
||||
return {tool_obj.name: _make_tool_callback(tool_obj) for tool_obj in tools}
|
||||
|
||||
def _build_type_stub_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]:
|
||||
"""Return a name -> underlying-Python-callable map for type stub generation.
|
||||
|
||||
The raw Python function attached to the ``FunctionTool`` carries the
|
||||
author's actual ``Annotated`` parameter types, which are what we want
|
||||
``ty`` to validate against. Tools without an attached function (e.g.
|
||||
``declaration_only`` tools) are skipped.
|
||||
"""
|
||||
stub_map: dict[str, Callable[..., Any]] = {}
|
||||
for tool_obj in tools:
|
||||
func = getattr(tool_obj, "func", None)
|
||||
if callable(func):
|
||||
stub_map[tool_obj.name] = func
|
||||
return stub_map
|
||||
|
||||
def _effective_mounts(self) -> list[FileMount]:
|
||||
"""Combine ``workspace_root`` (if set) with the explicit ``file_mounts``."""
|
||||
mounts: list[FileMount] = []
|
||||
if self._workspace_root is not None and WORKSPACE_MOUNT_PATH not in self._file_mounts:
|
||||
mounts.append(
|
||||
FileMount(
|
||||
host_path=self._workspace_root,
|
||||
mount_path=WORKSPACE_MOUNT_PATH,
|
||||
mode="read-write",
|
||||
write_bytes_limit=None,
|
||||
)
|
||||
)
|
||||
mounts.extend(self._file_mounts.values())
|
||||
return mounts
|
||||
|
||||
async def _run_code(self, *, code: str) -> list[Content]:
|
||||
tools = list(self._managed_tools)
|
||||
mounts = self._effective_mounts()
|
||||
|
||||
tool_map = self._build_tool_map(tools)
|
||||
stub_map = self._build_type_stub_map(tools)
|
||||
type_stubs = generate_type_stubs(stub_map) if stub_map else None
|
||||
|
||||
# Snapshot mtimes of host files in read-write mounts so we can later
|
||||
# identify which files the sandbox actually touched.
|
||||
pre_state = _snapshot_writable_mounts(mounts)
|
||||
|
||||
bridge = InlineCodeBridge(
|
||||
tool_map,
|
||||
type_stubs=type_stubs,
|
||||
mounts=[_to_monty_mount(mount) for mount in mounts] or None,
|
||||
resource_limits=self._resource_limits,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await bridge.run(code)
|
||||
except Exception as exc:
|
||||
return [
|
||||
Content.from_error(
|
||||
message="Execution error",
|
||||
error_details=f"{type(exc).__name__}: {exc}",
|
||||
),
|
||||
]
|
||||
|
||||
contents = _build_execution_contents(result=result)
|
||||
contents.extend(_capture_written_files(mounts, pre_state))
|
||||
return contents
|
||||
|
||||
|
||||
def _build_execution_contents(*, result: dict[str, Any]) -> list[Content]:
|
||||
stdout = str(result.get("stdout") or "").replace("\r\n", "\n")
|
||||
output_value = result.get("output")
|
||||
truncated = bool(result.get("truncated"))
|
||||
|
||||
outputs: list[Content] = []
|
||||
if stdout:
|
||||
text = stdout
|
||||
if truncated:
|
||||
text = f"{text}\n\n[stdout truncated]"
|
||||
outputs.append(Content.from_text(text))
|
||||
elif truncated:
|
||||
outputs.append(Content.from_text("[stdout truncated]"))
|
||||
|
||||
if output_value is not None:
|
||||
try:
|
||||
serialized_output = json.dumps(output_value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
serialized_output = repr(output_value)
|
||||
outputs.append(Content.from_text(serialized_output))
|
||||
|
||||
if not outputs:
|
||||
outputs.append(Content.from_text("Code executed successfully without output."))
|
||||
|
||||
return outputs
|
||||
|
||||
|
||||
def _iter_real_files(root: Path) -> Iterator[Path]:
|
||||
"""Walk ``root`` recursively, yielding only real (non-symlink) files.
|
||||
|
||||
``Path.rglob`` follows directory symlinks by default, which combined with
|
||||
``Path.is_file()`` / ``Path.read_bytes()`` (both follow symlinks) would let
|
||||
an attacker who controls the workspace pre-place a symlink to a host file
|
||||
or directory and have our post-execution capture surface it. Skipping every
|
||||
symlink at both the directory and file level closes that escape.
|
||||
"""
|
||||
stack: list[Path] = [root]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
entries = list(current.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
if entry.is_dir():
|
||||
stack.append(entry)
|
||||
elif entry.is_file():
|
||||
yield entry
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _snapshot_writable_mounts(mounts: Sequence[FileMount]) -> dict[str, dict[str, tuple[int, int]]]:
|
||||
"""Capture (size, mtime_ns) for every real (non-symlink) host file under read-write mounts.
|
||||
|
||||
Returns ``{mount_path: {relative_posix_path: (size, mtime_ns)}}``. Used by
|
||||
:func:`_capture_written_files` to detect new or modified files after the run.
|
||||
Read-only and overlay mounts are skipped because their writes do not
|
||||
propagate to the host. Symlinks (file or directory) are deliberately skipped
|
||||
so an attacker cannot escape the mount by pre-placing a symlink to a host
|
||||
path outside the workspace.
|
||||
"""
|
||||
snapshot: dict[str, dict[str, tuple[int, int]]] = {}
|
||||
for mount in mounts:
|
||||
if mount.mode != "read-write":
|
||||
continue
|
||||
host_root = Path(mount.host_path)
|
||||
per_mount: dict[str, tuple[int, int]] = {}
|
||||
for entry in _iter_real_files(host_root):
|
||||
try:
|
||||
stat = entry.lstat() # lstat: never follow symlinks (defensive)
|
||||
except OSError:
|
||||
continue
|
||||
relative = entry.relative_to(host_root).as_posix()
|
||||
per_mount[relative] = (int(stat.st_size), int(stat.st_mtime_ns))
|
||||
snapshot[mount.mount_path] = per_mount
|
||||
return snapshot
|
||||
|
||||
|
||||
def _capture_written_files(
|
||||
mounts: Sequence[FileMount],
|
||||
pre_state: dict[str, dict[str, tuple[int, int]]],
|
||||
) -> list[Content]:
|
||||
"""Return :class:`Content` items for files the sandbox wrote during the run.
|
||||
|
||||
Mirrors Hyperlight's ``/output`` capture flow: any new or modified real
|
||||
(non-symlink) file under a read-write mount is read back as binary and
|
||||
surfaced as ``Content.from_data`` with a ``path`` annotation in
|
||||
``additional_properties``. Symlinks are skipped at both directory and file
|
||||
level so a malicious workspace cannot trick us into capturing host files
|
||||
outside the configured mount root.
|
||||
"""
|
||||
captured: list[Content] = []
|
||||
for mount in mounts:
|
||||
if mount.mode != "read-write":
|
||||
continue
|
||||
host_root = Path(mount.host_path)
|
||||
before = pre_state.get(mount.mount_path, {})
|
||||
for entry in sorted(_iter_real_files(host_root)):
|
||||
try:
|
||||
stat = entry.lstat()
|
||||
except OSError:
|
||||
continue
|
||||
relative = entry.relative_to(host_root).as_posix()
|
||||
current = (int(stat.st_size), int(stat.st_mtime_ns))
|
||||
if before.get(relative) == current:
|
||||
continue # Unchanged.
|
||||
sandbox_path = f"{mount.mount_path.rstrip('/')}/{relative}"
|
||||
if stat.st_size > MAX_CAPTURED_FILE_BYTES:
|
||||
captured.append(
|
||||
Content.from_text(
|
||||
f"[file {sandbox_path} omitted: {stat.st_size} bytes "
|
||||
f"exceeds MAX_CAPTURED_FILE_BYTES={MAX_CAPTURED_FILE_BYTES}]"
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
# _iter_real_files already excluded symlinks at every level of
|
||||
# the walk; reading the file here is safe.
|
||||
data = entry.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
media_type = mimetypes.guess_type(entry.name)[0] or "application/octet-stream"
|
||||
captured.append(
|
||||
Content.from_data(
|
||||
data=data,
|
||||
media_type=media_type,
|
||||
additional_properties={"path": sandbox_path},
|
||||
)
|
||||
)
|
||||
return captured
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Dynamic CodeAct instructions and execute_code tool descriptions for Monty."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
from ._types import FileMount
|
||||
|
||||
|
||||
def _format_tool_summaries(tools: Sequence[FunctionTool]) -> str:
|
||||
if not tools:
|
||||
return "- No tools are currently registered."
|
||||
|
||||
lines: list[str] = []
|
||||
for tool_obj in tools:
|
||||
parameters = tool_obj.parameters().get("properties", {})
|
||||
parameter_names = [name for name in parameters if isinstance(name, str)]
|
||||
parameter_summary = ", ".join(parameter_names) if parameter_names else "none"
|
||||
description = str(tool_obj.description or "").strip() or "No description provided."
|
||||
lines.append(f"- `{tool_obj.name}`: {description} Parameters: {parameter_summary}.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_filesystem_capabilities(mounts: Sequence[FileMount]) -> str:
|
||||
if not mounts:
|
||||
return (
|
||||
"Filesystem access is unavailable. OS-level paths raise `PermissionError`. "
|
||||
"If you need files, ask the agent operator to configure `workspace_root` or `file_mounts`."
|
||||
)
|
||||
|
||||
lines = ["Filesystem access is enabled. Read and write paths via `pathlib.Path(...)` (or `os.path`)."]
|
||||
lines.append("Configured mounts:")
|
||||
for mount in mounts:
|
||||
cap = ""
|
||||
if mount.write_bytes_limit is not None:
|
||||
cap = f", write cap {mount.write_bytes_limit} bytes"
|
||||
lines.append(f"- `{mount.mount_path}` ({mount.mode}{cap})")
|
||||
|
||||
writable = [mount for mount in mounts if mount.mode == "read-write"]
|
||||
if writable:
|
||||
writable_paths = ", ".join(f"`{m.mount_path}`" for m in writable)
|
||||
lines.append(
|
||||
f"Files written to {writable_paths} are returned to the caller as attached files; "
|
||||
"use these paths for any output artifacts."
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_codeact_instructions(
|
||||
*,
|
||||
tools: Sequence[FunctionTool],
|
||||
tools_visible_to_model: bool,
|
||||
mounts: Sequence[FileMount] = (),
|
||||
) -> str:
|
||||
"""Build dynamic CodeAct instructions for the effective Monty tool set."""
|
||||
tool_summaries = _format_tool_summaries(tools)
|
||||
filesystem_text = _format_filesystem_capabilities(mounts)
|
||||
|
||||
usage_note = (
|
||||
"Some tools may also appear directly, but prefer `execute_code` whenever you need to combine "
|
||||
"Python control flow with sandbox tool calls."
|
||||
if tools_visible_to_model
|
||||
else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them."
|
||||
)
|
||||
|
||||
return f"""You have one primary tool: `execute_code`.
|
||||
|
||||
Inside `execute_code`, call registered tools directly as async functions:
|
||||
`result = await tool_name(param=value)`. Always use `await` and keyword arguments.
|
||||
Your code is type-checked against the tool signatures below before execution.
|
||||
`await call_tool('name', **kwargs)` is also supported as a fallback but is not type-checked.
|
||||
|
||||
For fan-out, use `asyncio.gather`:
|
||||
`results = await asyncio.gather(tool_a(...), tool_b(...))`.
|
||||
|
||||
Surface results to the caller via `print(...)` (captured and returned as text)
|
||||
or by ending the code with an expression whose value is JSON-encodable - the
|
||||
value of the final expression is returned alongside captured stdout.
|
||||
|
||||
Filesystem capabilities:
|
||||
{filesystem_text}
|
||||
|
||||
Registered tools:
|
||||
{tool_summaries}
|
||||
|
||||
Prefer a single `execute_code` call per request when possible, combining
|
||||
multiple tool calls with Python control flow.
|
||||
|
||||
{usage_note}
|
||||
"""
|
||||
|
||||
|
||||
def build_execute_code_description(
|
||||
*,
|
||||
tools: Sequence[FunctionTool],
|
||||
mounts: Sequence[FileMount] = (),
|
||||
) -> str:
|
||||
"""Build the dynamic ``execute_code`` tool description for standalone usage."""
|
||||
tool_summaries = _format_tool_summaries(tools)
|
||||
filesystem_text = _format_filesystem_capabilities(mounts)
|
||||
|
||||
return f"""Execute Python code in a Monty interpreter.
|
||||
|
||||
Inside the sandbox, call registered tools directly as typed async functions:
|
||||
`result = await tool_name(param=value)`. Always use `await` and keyword arguments.
|
||||
Code is type-checked against tool signatures before execution.
|
||||
`await call_tool('name', **kwargs)` is also supported as a fallback.
|
||||
|
||||
For fan-out, use `asyncio.gather`:
|
||||
`results = await asyncio.gather(tool_a(...), tool_b(...))`.
|
||||
|
||||
Filesystem capabilities:
|
||||
{filesystem_text}
|
||||
|
||||
Registered tools:
|
||||
{tool_summaries}
|
||||
|
||||
Surface results via `print(...)` (captured and returned as text) or by ending
|
||||
with an expression whose value is JSON-encodable.
|
||||
"""
|
||||
@@ -0,0 +1,327 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Inline (non-durable) Monty execution bridge and type-stub generation.
|
||||
|
||||
Adapted from https://github.com/anthonychu/maf-codeact-monty-python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import keyword
|
||||
import types
|
||||
import typing
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Annotated, Any, cast, get_type_hints
|
||||
|
||||
MAX_PRINT_OUTPUT_CHARS = 8192
|
||||
|
||||
# Prelude injected into all Monty code so `asyncio.gather` works for fan-out.
|
||||
_CODEACT_PRELUDE = """\
|
||||
import asyncio
|
||||
"""
|
||||
|
||||
|
||||
def _ensure_json_value(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
raise ValueError("Non-finite floating point values are not JSON-safe.")
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
items = cast("list[object] | tuple[object, ...]", value)
|
||||
return [_ensure_json_value(item) for item in items]
|
||||
if isinstance(value, dict):
|
||||
as_dict = cast("dict[object, object]", value)
|
||||
return {str(k): _ensure_json_value(v) for k, v in as_dict.items()}
|
||||
raise ValueError(f"Value of type {type(value).__name__} is not JSON-safe.")
|
||||
|
||||
|
||||
def _external_error(exc: Exception) -> dict[str, str]:
|
||||
return {"exc_type": type(exc).__name__, "message": str(exc)}
|
||||
|
||||
|
||||
def _parse_call_tool(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
if not args:
|
||||
raise ValueError("call_tool requires a tool name as the first argument.")
|
||||
name = args[0]
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError("Tool name must be a non-empty string.")
|
||||
if len(args) > 1:
|
||||
raise ValueError(
|
||||
"call_tool accepts only the tool name as a positional argument. Use keyword arguments for parameters."
|
||||
)
|
||||
return name, dict(kwargs)
|
||||
|
||||
|
||||
def _build_code(code: str) -> str:
|
||||
return f"{_CODEACT_PRELUDE}\n{code}"
|
||||
|
||||
|
||||
def _python_type_repr(annotation: Any) -> str:
|
||||
"""Convert a Python type annotation to its string representation for stubs."""
|
||||
if annotation is inspect.Parameter.empty:
|
||||
return "Any"
|
||||
if annotation is type(None):
|
||||
# ``None`` in annotations represents ``NoneType``; emit it literally so
|
||||
# ``ty`` can validate ``Optional[X]`` / ``Union[..., None]`` / ``-> None``
|
||||
# signatures correctly.
|
||||
return "None"
|
||||
origin = typing.get_origin(annotation)
|
||||
if origin is Annotated:
|
||||
args = typing.get_args(annotation)
|
||||
return _python_type_repr(args[0]) if args else "Any"
|
||||
if origin is not None:
|
||||
args = typing.get_args(annotation)
|
||||
# Normalize ``typing.Union[...]`` and PEP-604 ``X | Y`` to PEP-604 syntax so
|
||||
# ``None`` is preserved across both forms.
|
||||
if origin is typing.Union or origin is types.UnionType:
|
||||
return " | ".join(_python_type_repr(a) for a in args) if args else "Any"
|
||||
origin_name = getattr(origin, "__name__", None)
|
||||
if origin_name is None:
|
||||
origin_name = str(origin)
|
||||
if origin_name.startswith("<class '"):
|
||||
origin_name = origin_name[8:-2]
|
||||
if args:
|
||||
arg_strs = ", ".join(_python_type_repr(a) for a in args)
|
||||
return f"{origin_name}[{arg_strs}]"
|
||||
return origin_name
|
||||
if hasattr(annotation, "__name__"):
|
||||
return str(annotation.__name__)
|
||||
return str(annotation)
|
||||
|
||||
|
||||
def generate_type_stubs(tool_callables: dict[str, Callable[..., Any]]) -> str:
|
||||
"""Generate Python type stub declarations for tools + DSL primitives.
|
||||
|
||||
Stubs are fed to Monty's ``type_check_stubs`` so ``ty`` can validate the
|
||||
LLM-generated code against the actual tool signatures before any host
|
||||
call runs.
|
||||
|
||||
Tools whose ``name`` is not a valid Python identifier are skipped because
|
||||
their name cannot be safely splatted into stub source. The model can still
|
||||
reach them via the ``call_tool("weird name", ...)`` fallback at runtime,
|
||||
but they will not get type-checked stubs.
|
||||
"""
|
||||
lines: list[str] = [
|
||||
"from typing import Any",
|
||||
"",
|
||||
"# DSL primitives",
|
||||
"async def call_tool(name: str, **kwargs: Any) -> Any:",
|
||||
" raise NotImplementedError()",
|
||||
"",
|
||||
"# Registered tools - call directly with typed arguments",
|
||||
]
|
||||
|
||||
for name, func in sorted(tool_callables.items()):
|
||||
if not name.isidentifier() or keyword.iskeyword(name):
|
||||
# A non-identifier name (or a Python keyword) would inject invalid
|
||||
# / dangerous syntax into the stub source. Skip stub generation;
|
||||
# the tool stays reachable through ``call_tool(name, ...)``.
|
||||
continue
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
hints = get_type_hints(func, include_extras=True)
|
||||
except (ValueError, TypeError):
|
||||
lines.append(f"async def {name}(**kwargs: Any) -> Any:")
|
||||
lines.append(" raise NotImplementedError()")
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
params: list[str] = []
|
||||
for param_name, param in sig.parameters.items():
|
||||
annotation = hints.get(param_name, inspect.Parameter.empty)
|
||||
type_str = _python_type_repr(annotation)
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
params.append(f"{param_name}: {type_str} = ...")
|
||||
else:
|
||||
params.append(f"{param_name}: {type_str}")
|
||||
|
||||
return_annotation = hints.get("return", inspect.Parameter.empty)
|
||||
return_str = _python_type_repr(return_annotation)
|
||||
param_str = ", ".join(params)
|
||||
lines.append(f"async def {name}({param_str}) -> {return_str}:")
|
||||
lines.append(" raise NotImplementedError()")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class _PrintCollector:
|
||||
"""Collect Monty stdout, capped at ``MAX_PRINT_OUTPUT_CHARS``."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.chunks: list[str] = []
|
||||
self.truncated: bool = False
|
||||
self._size: int = 0 # running character count to avoid O(n) per append
|
||||
|
||||
def __call__(self, stream: str, text: str) -> None:
|
||||
if self.truncated:
|
||||
return
|
||||
remaining = MAX_PRINT_OUTPUT_CHARS - self._size
|
||||
if remaining <= 0:
|
||||
self.truncated = True
|
||||
return
|
||||
text_value = str(text)
|
||||
if len(text_value) > remaining:
|
||||
clipped = text_value[:remaining]
|
||||
self.chunks.append(clipped)
|
||||
self._size += len(clipped)
|
||||
self.truncated = True
|
||||
else:
|
||||
self.chunks.append(text_value)
|
||||
self._size += len(text_value)
|
||||
|
||||
@property
|
||||
def output(self) -> str:
|
||||
return "".join(self.chunks)
|
||||
|
||||
|
||||
def load_monty() -> Any:
|
||||
"""Import ``pydantic_monty`` lazily so unit tests can run without it.
|
||||
|
||||
Returns the module so callers can read ``Monty``, ``MontyComplete``,
|
||||
``FunctionSnapshot``, ``FutureSnapshot``, ``NameLookupSnapshot`` from it.
|
||||
"""
|
||||
try:
|
||||
import pydantic_monty
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"The `pydantic-monty` package is required to execute Monty CodeAct code. "
|
||||
"Install it with `pip install pydantic-monty`."
|
||||
) from exc
|
||||
return pydantic_monty
|
||||
|
||||
|
||||
class InlineCodeBridge:
|
||||
"""Execute Monty code inline (non-durable).
|
||||
|
||||
Supports both ``await call_tool('name', ...)`` and direct ``await name(...)``
|
||||
calls. When Monty yields a :class:`FutureSnapshot`, the bridge invokes the
|
||||
registered host tools and resumes execution with the results.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tool_map: dict[str, Callable[..., Any]],
|
||||
*,
|
||||
type_stubs: str | None = None,
|
||||
mounts: Sequence[Any] | None = None,
|
||||
resource_limits: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.tool_map: dict[str, Callable[..., Any]] = dict(tool_map)
|
||||
self.type_stubs: str | None = type_stubs
|
||||
self._mounts = tuple(mounts) if mounts else ()
|
||||
self._resource_limits = resource_limits
|
||||
self._pending_calls: dict[int, tuple[str, dict[str, Any]]] = {}
|
||||
|
||||
async def run(self, code: str) -> dict[str, Any]:
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
raise ValueError("Code must be a non-empty string.")
|
||||
|
||||
monty_module = load_monty()
|
||||
Monty = monty_module.Monty
|
||||
MontyComplete = monty_module.MontyComplete
|
||||
FunctionSnapshot = monty_module.FunctionSnapshot
|
||||
FutureSnapshot = monty_module.FutureSnapshot
|
||||
NameLookupSnapshot = monty_module.NameLookupSnapshot
|
||||
|
||||
printer = _PrintCollector()
|
||||
monty = Monty(
|
||||
_build_code(code),
|
||||
script_name="codeact.py",
|
||||
type_check=self.type_stubs is not None,
|
||||
type_check_stubs=self.type_stubs,
|
||||
)
|
||||
start_kwargs: dict[str, Any] = {"print_callback": printer}
|
||||
if self._mounts:
|
||||
start_kwargs["mount"] = list(self._mounts)
|
||||
if self._resource_limits:
|
||||
start_kwargs["limits"] = self._resource_limits
|
||||
progress = monty.start(**start_kwargs)
|
||||
|
||||
while True:
|
||||
if isinstance(progress, MontyComplete):
|
||||
return {
|
||||
"output": _ensure_json_value(progress.output),
|
||||
"stdout": printer.output,
|
||||
"truncated": printer.truncated,
|
||||
}
|
||||
if isinstance(progress, FunctionSnapshot):
|
||||
progress = self._handle_function(progress)
|
||||
continue
|
||||
if isinstance(progress, FutureSnapshot):
|
||||
progress = await self._handle_future(progress)
|
||||
continue
|
||||
if isinstance(progress, NameLookupSnapshot):
|
||||
raise RuntimeError(f"Name lookup not supported: {progress.variable_name!r}")
|
||||
raise RuntimeError(f"Unsupported Monty progress type: {type(progress).__name__}")
|
||||
|
||||
def _handle_function(self, snapshot: Any) -> Any:
|
||||
if snapshot.is_os_function:
|
||||
return snapshot.resume({
|
||||
"exc_type": "PermissionError",
|
||||
"message": "OS and filesystem calls are not available.",
|
||||
})
|
||||
|
||||
function_name = str(snapshot.function_name)
|
||||
|
||||
if function_name in self.tool_map:
|
||||
return self._schedule_direct_tool(snapshot, function_name)
|
||||
if function_name == "call_tool":
|
||||
return self._schedule_call_tool(snapshot)
|
||||
|
||||
return snapshot.resume({
|
||||
"exc_type": "NameError",
|
||||
"message": f"Function {function_name!r} is not available.",
|
||||
})
|
||||
|
||||
def _schedule_direct_tool(self, snapshot: Any, name: str) -> Any:
|
||||
# Positional args are rejected up-front by ``ty`` because the generated
|
||||
# stubs declare every parameter as keyword-typed. Anything that slips
|
||||
# through (e.g. tools with no signature inspection) is forwarded to the
|
||||
# host tool as-is via kwargs only.
|
||||
self._pending_calls[int(snapshot.call_id)] = (name, dict(snapshot.kwargs))
|
||||
return snapshot.resume({"future": ...})
|
||||
|
||||
def _schedule_call_tool(self, snapshot: Any) -> Any:
|
||||
try:
|
||||
name, kwargs = _parse_call_tool(snapshot.args, snapshot.kwargs)
|
||||
if name not in self.tool_map:
|
||||
allowed = ", ".join(sorted(self.tool_map.keys())) or "<none>"
|
||||
raise ValueError(f"Tool {name!r} is not registered. Available tools: {allowed}")
|
||||
self._pending_calls[int(snapshot.call_id)] = (name, kwargs)
|
||||
except Exception as exc:
|
||||
return snapshot.resume(_external_error(exc))
|
||||
return snapshot.resume({"future": ...})
|
||||
|
||||
async def _handle_future(self, snapshot: Any) -> Any:
|
||||
pending_call_ids = [int(cid) for cid in snapshot.pending_call_ids]
|
||||
if not pending_call_ids:
|
||||
return snapshot.resume({})
|
||||
|
||||
entries: list[tuple[int, tuple[str, dict[str, Any]]]] = []
|
||||
for cid in pending_call_ids:
|
||||
if cid not in self._pending_calls:
|
||||
raise RuntimeError(f"Unknown future call ID: {cid}")
|
||||
entries.append((cid, self._pending_calls.pop(cid)))
|
||||
|
||||
tasks = [self._invoke_tool(cid, name, kwargs) for cid, (name, kwargs) in entries]
|
||||
results = await asyncio.gather(*tasks)
|
||||
resume_results: dict[int, Any] = dict(results)
|
||||
return snapshot.resume(resume_results)
|
||||
|
||||
async def _invoke_tool(self, cid: int, name: str, kwargs: dict[str, Any]) -> tuple[int, Any]:
|
||||
# Every entry in ``self.tool_map`` is produced by ``_make_tool_callback``
|
||||
# as ``partial(FunctionTool.invoke, skip_parsing=True)``. ``FunctionTool.invoke``
|
||||
# is always ``async def``, so a plain ``await`` is correct for every call and
|
||||
# avoids relying on ``inspect.iscoroutinefunction(partial(...))``, which can
|
||||
# return ``False`` for some ``partial`` shapes (cpython#98590) and would route
|
||||
# the call through ``asyncio.to_thread`` with an unawaited coroutine return.
|
||||
try:
|
||||
result = await self.tool_map[name](**kwargs)
|
||||
return cid, {"return_value": _ensure_json_value(result)}
|
||||
except Exception as exc:
|
||||
return cid, _external_error(exc)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""``MontyCodeActProvider`` - context provider injecting Monty-backed CodeAct."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext
|
||||
from agent_framework._tools import ApprovalMode
|
||||
|
||||
from ._execute_code_tool import MontyExecuteCodeTool
|
||||
from ._types import FileMount, FileMountInput
|
||||
|
||||
|
||||
class MontyCodeActProvider(ContextProvider):
|
||||
"""Inject a Monty-backed CodeAct surface using provider-owned tools.
|
||||
|
||||
Mirrors :class:`agent_framework_hyperlight.HyperlightCodeActProvider` for
|
||||
the subset of capabilities that apply to the Monty interpreter:
|
||||
``tools``, ``approval_mode``, ``workspace_root``, ``file_mounts``, and
|
||||
``resource_limits`` (Monty-only).
|
||||
"""
|
||||
|
||||
DEFAULT_SOURCE_ID = "monty_codeact"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
*,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
workspace_root: str | Path | None = None,
|
||||
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
|
||||
resource_limits: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(source_id)
|
||||
self._execute_code_tool = MontyExecuteCodeTool(
|
||||
tools=tools,
|
||||
approval_mode=approval_mode,
|
||||
workspace_root=workspace_root,
|
||||
file_mounts=file_mounts,
|
||||
resource_limits=resource_limits,
|
||||
)
|
||||
|
||||
def add_tools(
|
||||
self,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
|
||||
) -> None:
|
||||
"""Add provider-owned Monty tools."""
|
||||
self._execute_code_tool.add_tools(tools)
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
"""Return the provider-owned Monty tools."""
|
||||
return self._execute_code_tool.get_tools()
|
||||
|
||||
def remove_tool(self, name: str) -> None:
|
||||
"""Remove one provider-owned Monty tool by name."""
|
||||
self._execute_code_tool.remove_tool(name)
|
||||
|
||||
def clear_tools(self) -> None:
|
||||
"""Remove all provider-owned Monty tools."""
|
||||
self._execute_code_tool.clear_tools()
|
||||
|
||||
def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
|
||||
"""Add provider-managed file mounts."""
|
||||
self._execute_code_tool.add_file_mounts(file_mounts)
|
||||
|
||||
def get_file_mounts(self) -> list[FileMount]:
|
||||
"""Return the provider-managed file mounts (excluding ``workspace_root``)."""
|
||||
return self._execute_code_tool.get_file_mounts()
|
||||
|
||||
def remove_file_mount(self, mount_path: str) -> None:
|
||||
"""Remove one provider-managed file mount by its sandbox path."""
|
||||
self._execute_code_tool.remove_file_mount(mount_path)
|
||||
|
||||
def clear_file_mounts(self) -> None:
|
||||
"""Remove all provider-managed file mounts."""
|
||||
self._execute_code_tool.clear_file_mounts()
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession | None,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject CodeAct instructions and a run-scoped execute_code tool before each run."""
|
||||
run_tool = self._execute_code_tool.create_run_tool()
|
||||
state[self.source_id] = run_tool.build_serializable_state()
|
||||
context.extend_instructions(self.source_id, run_tool.build_instructions(tools_visible_to_model=False))
|
||||
context.extend_tools(self.source_id, [run_tool])
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Public types for ``agent-framework-monty``.
|
||||
|
||||
Mirrors ``agent_framework_hyperlight._types`` where the Monty runtime exposes
|
||||
an equivalent concept so users can move between the two providers with minimal
|
||||
churn.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal, NamedTuple, TypeAlias
|
||||
|
||||
#: Allowed Monty mount modes. ``overlay`` (the Monty default) buffers writes
|
||||
#: in-memory and is therefore not visible to the host after execution.
|
||||
#: ``read-only`` rejects writes. ``read-write`` writes through to the host
|
||||
#: directory.
|
||||
MountMode: TypeAlias = Literal["overlay", "read-only", "read-write"]
|
||||
|
||||
|
||||
class FileMount(NamedTuple):
|
||||
"""Map a host directory into the Monty sandbox.
|
||||
|
||||
Mirrors :class:`agent_framework_hyperlight.FileMount` with two extra
|
||||
fields that surface Monty's underlying ``MountDir`` capabilities:
|
||||
``mode`` selects read-only / read-write / overlay semantics, and
|
||||
``write_bytes_limit`` caps the total bytes written through this mount.
|
||||
"""
|
||||
|
||||
host_path: str | Path
|
||||
mount_path: str
|
||||
mode: MountMode = "overlay"
|
||||
write_bytes_limit: int | None = None
|
||||
|
||||
|
||||
FileMountHostPath: TypeAlias = str | Path
|
||||
FileMountInput: TypeAlias = str | tuple[FileMountHostPath, str] | FileMount
|
||||
Reference in New Issue
Block a user