Files
strukto-ai--mirage/python/mirage/agents/langchain/backend.py
T
bytecii 3293cb5ff4 feat(py): mirage mcp over stdio, and invert the mypy allowlist
Two cleanup-plan items, both Python-side.

Item 29 -- `mirage mcp`. TypeScript shipped a six-tool stdio MCP server;
Python had none, so a pip-install user could not point Cursor or Claude
Desktop at a workspace and `mirage --help` differed by distribution.
Adding the entry point alone would have duplicated the tools, because
this side kept them private inside the Claude Agent SDK integration, so
the shared layer comes first:

  - agents/tool_descriptions.py -- the six strings, one copy.
  - agents/tool_operations.py -- MirageToolOperations, lifted out of the
    SDK server's private _MirageTools.
  - agents/file_version.py -- stale-write protection, which this side
    lacked entirely. TS stamps stored bytes; here the stamp covers the
    rendered bytes, because this read tool has always rendered and an
    edit must search what the agent was actually shown.
  - agents/mcp/server.py + cli/mcp.py -- the server and `mirage mcp`.
  - server/workspace_config.py -- config discovery (candidates, env
    names, walk up from cwd), which Python had nowhere, so every entry
    point had to be handed an explicit path.

The server is the low-level MCP Server rather than FastMCP: FastMCP does
not forward a version, and TS advertises one. Handlers are bound methods,
not decorated closures, so nothing nests.

Item 28 -- the mypy allowlist. 54 modules opted *in* to annotation
checking against 1826, so the default was unchecked and every new file
joined the unchecked side. The default is now strict, with a list of
what is not yet annotated that only shrinks. 166 annotations cleared
along the way; the remainder is named module by module.

Two real defects surfaced by the annotations, neither of them typing:

  - Workspace._original_open / _original_os were invented by assignment
    in lifecycle.patch_process, so unpatch without a patch raised
    AttributeError. Declared, and the restore is guarded.
  - sed_generic declared a non-optional writer while its own docstring
    and its `write_bytes is None` branch said otherwise; the builder
    passes None whenever the backend cannot write.

Tests keep the PathSpec rule instead of full strict: measured, full
strict on python/tests is 2374 errors, of which 634 are `str` where a
pydantic field declares SecretStr -- which pydantic coerces at runtime --
and most of the rest is the monkeypatched-fake pattern CLAUDE.md
sanctions. The rule that is violated for real is PathSpec, 19 times, and
scripts/check_test_pathspec.py now holds that line. One of the 19 was a
latent AttributeError: tests/e2e passes a str to s3 write_bytes, which
reads .mount_path, and the test skips without a live versioned bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 03:33:16 -07:00

362 lines
13 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
import shlex
from collections.abc import Awaitable
from pathlib import PurePosixPath
from typing import TypeVar
from deepagents.backends.protocol import (EditResult, ExecuteResponse,
FileData, FileDownloadResponse,
FileInfo, FileUploadResponse,
GlobResult, GrepResult, LsResult,
ReadResult, SandboxBackendProtocol,
WriteResult)
from mirage.agents.langchain._convert import (io_to_execute_response,
io_to_file_infos,
io_to_grep_matches)
from mirage.bridge.sync import run_async_from_sync
from mirage.io.types import IOResult
from mirage.workspace.workspace import Workspace
T = TypeVar("T")
BINARY_EXTENSIONS = frozenset({
".3gpp",
".aac",
".aiff",
".avi",
".flac",
".flv",
".gif",
".heic",
".heif",
".jpeg",
".jpg",
".mov",
".mp3",
".mp4",
".mpeg",
".mpg",
".ogg",
".pdf",
".png",
".ppt",
".pptx",
".wav",
".webm",
".webp",
".wmv",
})
COMMAND_SUCCESS_EXIT_CODES = frozenset({0})
GREP_SUCCESS_EXIT_CODES = frozenset({0, 1})
def _is_binary(file_path: str, data: bytes) -> bool:
suffix = PurePosixPath(file_path).suffix.lower()
if suffix in BINARY_EXTENSIONS or b"\x00" in data:
return True
try:
data.decode("utf-8")
except UnicodeDecodeError:
return True
return False
def _to_read_result(file_path: str, data: bytes, offset: int,
limit: int) -> ReadResult:
if _is_binary(file_path, data):
content = base64.standard_b64encode(data).decode("ascii")
return ReadResult(
file_data=FileData(content=content, encoding="base64"))
text = data.decode("utf-8")
lines = text.splitlines(keepends=True)
if lines and offset >= len(lines):
error = (f"Line offset {offset} exceeds file length "
f"({len(lines)} lines)")
return ReadResult(error=error)
content = "".join(lines[offset:offset + limit])
return ReadResult(file_data=FileData(content=content, encoding="utf-8"))
async def _command_error(
io: IOResult,
success_exit_codes: frozenset[int] = COMMAND_SUCCESS_EXIT_CODES,
) -> str | None:
if io.exit_code in success_exit_codes:
return None
stderr = (await io.stderr_str()).strip()
if stderr:
return stderr
return f"Command failed with exit code {io.exit_code}"
class LangchainWorkspace(SandboxBackendProtocol):
"""Deep Agents backend backed by a Mirage Workspace.
File operations (read, write, edit, ls, upload, download) go through the
Ops layer directly. Shell operations (execute, grep, glob) go through
Workspace.execute() for pipe and flag support.
"""
def __init__(
self,
workspace: Workspace,
sandbox_id: str = "mirage",
session_id: str | None = None,
) -> None:
self._ws = workspace
self._id = sandbox_id
self._session_id = session_id
def _run(self, coro: Awaitable[T]) -> T:
return run_async_from_sync(coro)
@property
def id(self) -> str:
return self._id
async def _exec(self, command: str) -> IOResult:
result = await self._ws.execute(command, session_id=self._session_id)
assert isinstance(result, IOResult)
return result
# ── execute ──────────────────────────────────────────────
def execute(self,
command: str,
*,
timeout: int | None = None) -> ExecuteResponse:
return self._run(self.aexecute(command, timeout=timeout))
async def aexecute(self,
command: str,
*,
timeout: int | None = None) -> ExecuteResponse:
io = await self._exec(command)
return io_to_execute_response(io)
# ── ls ──────────────────────────────────────────────────
def ls(self, path: str) -> LsResult:
return self._run(self.als(path))
async def als(self, path: str) -> LsResult:
io = await self._exec(f"ls {shlex.quote(path)}")
stdout = (await io.stdout_str()).strip()
error = await _command_error(io)
if error:
return LsResult(error=error)
if not stdout:
return LsResult(entries=[])
base = path.rstrip("/")
result: list[FileInfo] = []
for name in stdout.split("\n"):
name = name.strip()
if not name:
continue
is_dir = name.endswith("/")
clean = name.rstrip("/")
result.append(FileInfo(path=f"{base}/{clean}", is_dir=is_dir))
return LsResult(entries=result)
# ── read ─────────────────────────────────────────────────
def read(self,
file_path: str,
offset: int = 0,
limit: int = 2000) -> ReadResult:
return self._run(self.aread(file_path, offset, limit))
async def aread(self,
file_path: str,
offset: int = 0,
limit: int = 2000) -> ReadResult:
ops = self._ws.ops
try:
data = await ops.read(file_path)
except (FileNotFoundError, ValueError) as exc:
return ReadResult(error=f"Error: {exc}")
return _to_read_result(file_path, data, offset, limit)
# ── write ────────────────────────────────────────────────
def write(self, file_path: str, content: str) -> WriteResult:
return self._run(self.awrite(file_path, content))
async def awrite(self, file_path: str, content: str) -> WriteResult:
ops = self._ws.ops
try:
await ops.stat(file_path)
return WriteResult(
error=f"Error: file '{file_path}' already exists")
except (FileNotFoundError, ValueError):
# missing file is the good path: the write may proceed
pass
parent = "/".join(file_path.rstrip("/").split("/")[:-1]) or "/"
try:
await ops.mkdir(parent)
except (FileExistsError, ValueError):
# mkdir -p semantics: an existing parent is success
pass
await ops.write(file_path, content.encode("utf-8"))
return WriteResult(path=file_path)
# ── edit ─────────────────────────────────────────────────
def edit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
return self._run(
self.aedit(file_path, old_string, new_string, replace_all))
async def aedit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
ops = self._ws.ops
try:
data = await ops.read(file_path)
except (FileNotFoundError, ValueError):
return EditResult(error=f"Error: file '{file_path}' not found")
content = data.decode("utf-8", errors="replace")
count = content.count(old_string)
if count == 0:
return EditResult(
error=f"Error: string not found in file: '{old_string}'")
if count > 1 and not replace_all:
return EditResult(
error=f"Error: string '{old_string}' appears {count} times. "
f"Use replace_all=True")
if replace_all:
new_content = content.replace(old_string, new_string)
else:
new_content = content.replace(old_string, new_string, 1)
await ops.write(file_path, new_content.encode("utf-8"))
return EditResult(path=file_path,
occurrences=count if replace_all else 1)
# ── grep ─────────────────────────────────────────────────
def grep(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
*,
max_count: int | None = None,
) -> GrepResult:
return self._run(self.agrep(pattern, path, glob, max_count=max_count))
async def agrep(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
*,
max_count: int | None = None,
) -> GrepResult:
"""Search file contents, optionally capping the matches returned.
``max_count`` is a total cap across every file, which is not what
``grep -m`` means (that one caps each file separately), so it is
applied to the collected matches rather than pushed onto the
command line. A run that hits the cap with matches left over is
reported truncated; one that lands on it exactly is complete.
Args:
pattern (str): literal text to search for.
path (str | None): directory to search, root when absent.
glob (str | None): filename filter for which files to search.
max_count (int | None): total cap on matches returned.
"""
parts = ["grep", "-rn"]
if glob:
parts.extend(["--include", shlex.quote(glob)])
parts.append(shlex.quote(pattern))
parts.append(shlex.quote(path or "/"))
io = await self._exec(" ".join(parts))
await io.materialize_stdout()
error = await _command_error(io, GREP_SUCCESS_EXIT_CODES)
if error:
return GrepResult(error=error)
matches = io_to_grep_matches(io)
if max_count is None or len(matches) <= max_count:
return GrepResult(matches=matches)
return GrepResult(matches=matches[:max_count], truncated=True)
# ── glob ─────────────────────────────────────────────────
def glob(self, pattern: str, path: str | None = None) -> GlobResult:
return self._run(self.aglob(pattern, path))
async def aglob(self, pattern: str, path: str | None = None) -> GlobResult:
name = pattern.split("/")[-1] if "/" in pattern else pattern
io = await self._exec(
f"find {shlex.quote(path or '/')} -name {shlex.quote(name)}")
await io.materialize_stdout()
error = await _command_error(io)
if error:
return GlobResult(error=error)
return GlobResult(matches=io_to_file_infos(io))
# ── upload / download ────────────────────────────────────
def upload_files(
self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return self._run(self.aupload_files(files))
async def aupload_files(
self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
ops = self._ws.ops
results: list[FileUploadResponse] = []
for path, data in files:
parent = "/".join(path.rstrip("/").split("/")[:-1]) or "/"
try:
await ops.mkdir(parent)
except (FileExistsError, ValueError):
# mkdir -p semantics: an existing parent is success
pass
await ops.write(path, data)
results.append(FileUploadResponse(path=path))
return results
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
return self._run(self.adownload_files(paths))
async def adownload_files(self,
paths: list[str]) -> list[FileDownloadResponse]:
ops = self._ws.ops
results: list[FileDownloadResponse] = []
for path in paths:
try:
data = await ops.read(path)
results.append(FileDownloadResponse(path=path, content=data))
except (FileNotFoundError, ValueError):
results.append(
FileDownloadResponse(path=path,
content=None,
error="file_not_found"))
return results