chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# ========= 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. =========
+22
View File
@@ -0,0 +1,22 @@
# ========= 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. =========
from mirage.agents.agno.prompt import MIRAGE_SYSTEM_PROMPT, build_system_prompt
from mirage.agents.agno.toolkit import MirageToolkit
__all__ = [
"MirageToolkit",
"MIRAGE_SYSTEM_PROMPT",
"build_system_prompt",
]
+17
View File
@@ -0,0 +1,17 @@
# ========= 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. =========
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]
+133
View File
@@ -0,0 +1,133 @@
# ========= 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 shlex
try:
from agno.tools import Toolkit
except ImportError as exc:
raise ImportError(
"`agno` not installed. Install with: pip install 'mirage-ai[agno]'"
) from exc
from mirage.agents.io_text import io_to_str
from mirage.bridge.sync import run_async_from_sync
from mirage.workspace.workspace import Workspace
class MirageToolkit(Toolkit):
"""Agno toolkit backed by a Mirage Workspace.
Exposes shell-style filesystem access (execute, read, write, ls, grep)
as sync and async tool pairs for Agno agents.
Args:
workspace (Workspace): The workspace to operate on.
"""
def __init__(self, workspace: Workspace, **kwargs) -> None:
self._ws = workspace
tools = [self.execute, self.read, self.write, self.ls, self.grep]
async_tools = [
(self.aexecute, "execute"),
(self.aread, "read"),
(self.awrite, "write"),
(self.als, "ls"),
(self.agrep, "grep"),
]
super().__init__(name="mirage",
tools=tools,
async_tools=async_tools,
**kwargs)
def _run(self, coro):
return run_async_from_sync(coro)
# -- execute ---------------------------------------------------------
def execute(self, command: str) -> str:
"""Run a shell-style command on the mounted filesystem.
Supports cat, grep, find, head, pipe, and any other Unix command.
Args:
command (str): The shell command to execute.
"""
return self._run(self.aexecute(command))
async def aexecute(self, command: str) -> str:
io = await self._ws.execute(command)
return io_to_str(io)
# -- read --------------------------------------------------------------
def read(self, path: str) -> str:
"""Read the full contents of a file at the given path.
Args:
path (str): Absolute path to the file on the mounted filesystem.
"""
return self._run(self.aread(path))
async def aread(self, path: str) -> str:
io = await self._ws.execute(f"cat {shlex.quote(path)}")
return io_to_str(io)
# -- write -------------------------------------------------------------
def write(self, path: str, content: str) -> str:
"""Write content to a file, creating it if it does not exist.
Args:
path (str): Absolute path to the file on the mounted filesystem.
content (str): The content to write to the file.
"""
return self._run(self.awrite(path, content))
async def awrite(self, path: str, content: str) -> str:
io = await self._ws.execute(f"tee {shlex.quote(path)}",
stdin=content.encode("utf-8"))
return io_to_str(io)
# -- ls ------------------------------------------------------------------
def ls(self, path: str = "/") -> str:
"""List the files and directories at the given path.
Args:
path (str): Absolute directory path to list. Defaults to root.
"""
return self._run(self.als(path))
async def als(self, path: str = "/") -> str:
io = await self._ws.execute(f"ls {shlex.quote(path)}")
return io_to_str(io)
# -- grep ---------------------------------------------------------------
def grep(self, pattern: str, path: str) -> str:
"""Search for a pattern in files at the given path.
Supports regex patterns.
Args:
pattern (str): The string or regex pattern to search for.
path (str): The file or directory path to search within.
"""
return self._run(self.agrep(pattern, path))
async def agrep(self, pattern: str, path: str) -> str:
io = await self._ws.execute(
f"grep -r {shlex.quote(pattern)} {shlex.quote(path)}")
return io_to_str(io)
+18
View File
@@ -0,0 +1,18 @@
# ========= 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. =========
from mirage.agents.camel.file import MirageFileToolkit
from mirage.agents.camel.terminal import MirageTerminalToolkit
__all__ = ["MirageFileToolkit", "MirageTerminalToolkit"]
+56
View File
@@ -0,0 +1,56 @@
# ========= 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 asyncio
import threading
from collections.abc import Coroutine
from typing import Any
class AsyncRunner:
def __init__(self) -> None:
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
with self._lock:
if self._loop is not None and not self._loop.is_closed():
return self._loop
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(
target=self._loop.run_forever,
name="mirage-camel-runner",
daemon=True,
)
self._thread.start()
return self._loop
def run(self, coro: Coroutine[Any, Any, Any]) -> Any:
loop = self._ensure_loop()
future = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result()
def close(self) -> None:
with self._lock:
if self._loop is None:
return
loop = self._loop
self._loop = None
loop.call_soon_threadsafe(loop.stop)
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
loop.close()
+256
View File
@@ -0,0 +1,256 @@
# ========= 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 shlex
import tempfile
from pathlib import Path
from camel.toolkits import FileToolkit
from mirage.agents.camel._async import AsyncRunner
from mirage.workspace.workspace import Workspace
class MirageFileToolkit(FileToolkit):
def __init__(
self,
workspace: Workspace,
working_directory: str = "/",
timeout: float | None = None,
default_encoding: str = "utf-8",
backup_enabled: bool = False,
) -> None:
self._ws = workspace
self._runner = AsyncRunner()
self._mirage_root = working_directory
self._tmpdir = tempfile.TemporaryDirectory(prefix="mirage-camel-")
super().__init__(
working_directory=self._tmpdir.name,
timeout=timeout,
default_encoding=default_encoding,
backup_enabled=backup_enabled,
)
def close(self) -> None:
self._runner.close()
self._tmpdir.cleanup()
def _to_mirage_path(self, file_path: str) -> str:
path_str = file_path
if not path_str.startswith("/"):
base = self._mirage_root.rstrip("/") or ""
path_str = f"{base}/{path_str}"
return path_str
def _read_mirage_bytes(self, mirage_path: str) -> bytes:
quoted = shlex.quote(mirage_path)
io = self._runner.run(self._ws.execute(f"cat {quoted}"))
if io.exit_code != 0:
stderr = io.stderr if isinstance(io.stderr, bytes) else b""
raise FileNotFoundError(stderr.decode("utf-8", errors="replace"))
return io.stdout if isinstance(io.stdout, bytes) else b""
def _write_mirage_bytes(self, mirage_path: str, data: bytes) -> None:
parent = str(Path(mirage_path).parent)
if parent and parent != "/":
mkdir_io = self._runner.run(
self._ws.execute(f"mkdir -p {shlex.quote(parent)}"))
if mkdir_io.exit_code != 0:
stderr = mkdir_io.stderr if isinstance(mkdir_io.stderr,
bytes) else b""
raise OSError(stderr.decode("utf-8", errors="replace"))
quoted = shlex.quote(mirage_path)
io = self._runner.run(self._ws.execute(f"cat > {quoted}", stdin=data))
if io.exit_code != 0:
stderr = io.stderr if isinstance(io.stderr, bytes) else b""
raise OSError(stderr.decode("utf-8", errors="replace"))
def write_to_file(
self,
title: str,
content: str | list[list[str]],
filename: str,
encoding: str | None = None,
use_latex: bool = False,
) -> str:
"""Write content to filename over Mirage Workspace.
Args:
title (str): Document title (used by some format writers).
content: Content payload.
filename (str): Logical Mirage path.
encoding (str | None): Override default encoding.
use_latex (bool): Forwarded to PDF writer.
Returns:
str: Success or error message.
"""
mirage_path = self._to_mirage_path(filename)
local_name = Path(filename).name or "out"
local_path = Path(self._tmpdir.name) / local_name
super_msg = super().write_to_file(
title=title,
content=content,
filename=str(local_path),
encoding=encoding,
use_latex=use_latex,
)
if super_msg.startswith("Error"):
return super_msg
produced = self._find_produced_file(local_path)
if produced is None:
return f"Error: format writer produced no file for {local_path}"
target = self._adjust_extension(mirage_path, produced)
try:
self._write_mirage_bytes(target, produced.read_bytes())
except OSError as exc:
return f"Error writing {target}: {exc}"
return f"Content successfully written to file: {target}"
def read_file(self, file_paths: str | list[str]) -> str | dict[str, str]:
"""Read files from Mirage and return Markdown-rendered text.
Args:
file_paths (str | list[str]): Single path or list of paths.
Returns:
str | dict[str, str]: Same shape as camel read_file.
"""
if isinstance(file_paths, str):
return self._read_one(file_paths)
out: dict[str, str] = {}
for fp in file_paths:
out[fp] = self._read_one(fp)
return out
def _read_one(self, file_path: str) -> str:
mirage_path = self._to_mirage_path(file_path)
try:
data = self._read_mirage_bytes(mirage_path)
except FileNotFoundError as exc:
return f"Failed to read file: {mirage_path} ({exc})"
suffix = Path(mirage_path).suffix or ".txt"
local = Path(
self._tmpdir.name) / f"read_{abs(hash(mirage_path))}{suffix}"
local.write_bytes(data)
return super().read_file(file_paths=str(local))
def _find_produced_file(self, expected: Path) -> Path | None:
if expected.exists():
return expected
for ext in (".md", ".pdf", ".docx", ".csv", ".json", ".html", ".txt"):
candidate = expected.with_suffix(ext)
if candidate.exists():
return candidate
parent = expected.parent
if parent.exists():
for child in parent.iterdir():
if child.stem == expected.stem and child.is_file():
return child
return None
def _adjust_extension(self, mirage_path: str, produced: Path) -> str:
target = Path(mirage_path)
if target.suffix:
return mirage_path
return str(target.with_suffix(produced.suffix))
def edit_file(self, file_path: str, old_content: str,
new_content: str) -> str:
"""Replace old_content with new_content in a Mirage file.
Args:
file_path (str): Logical Mirage path.
old_content (str): Exact text to find.
new_content (str): Replacement text.
Returns:
str: Success or error message.
"""
mirage_path = self._to_mirage_path(file_path)
try:
data = self._read_mirage_bytes(mirage_path).decode(
self.default_encoding)
except FileNotFoundError:
return f"Error: File {mirage_path} does not exist"
if old_content not in data:
return f"Error: old_content not found in {mirage_path}"
new_data = data.replace(old_content, new_content)
self._write_mirage_bytes(mirage_path,
new_data.encode(self.default_encoding))
return f"Successfully edited file: {mirage_path}"
def search_files(self, file_name: str, path: str | None = None) -> str:
"""Locate files by name pattern via Mirage's find.
Args:
file_name (str): Glob pattern passed to find -name.
path (str | None): Search root; defaults to working_directory.
Returns:
str: Newline-separated list of matching paths.
"""
root = self._to_mirage_path(path or self._mirage_root)
cmd = f"find {shlex.quote(root)} -name {shlex.quote(file_name)}"
io = self._runner.run(self._ws.execute(cmd))
return _io_text(io)
def glob_files(self, pattern: str, path: str | None = None) -> str:
"""Glob via Mirage's find -name.
Args:
pattern (str): Glob pattern.
path (str | None): Search root.
Returns:
str: Newline-separated list of matches.
"""
return self.search_files(file_name=pattern, path=path)
def grep_files(
self,
pattern: str,
path: str | None = None,
file_pattern: str | None = None,
) -> str:
"""Regex search via Mirage's grep -rn.
Args:
pattern (str): Regex.
path (str | None): Search root.
file_pattern (str | None): --include glob.
Returns:
str: Concatenated grep output.
"""
root = self._to_mirage_path(path or self._mirage_root)
parts = ["grep", "-rn", shlex.quote(pattern)]
if file_pattern:
parts.insert(2, f"--include={shlex.quote(file_pattern)}")
parts.append(shlex.quote(root))
io = self._runner.run(self._ws.execute(" ".join(parts)))
return _io_text(io)
def _io_text(io) -> str:
stdout = io.stdout if isinstance(io.stdout, bytes) else b""
stderr = io.stderr if isinstance(io.stderr, bytes) else b""
out = stdout.decode("utf-8", errors="replace")
err = stderr.decode("utf-8", errors="replace")
if err and not out:
return err
if err:
return f"{out}\n{err}"
return out
+169
View File
@@ -0,0 +1,169 @@
# ========= 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 shlex
from camel.toolkits import BaseToolkit, FunctionTool
from mirage.agents.camel._async import AsyncRunner
from mirage.agents.io_text import decode, io_to_str
from mirage.workspace.workspace import Workspace
def _parse_job_id(stderr: str) -> int | None:
line = stderr.strip()
if line.startswith("[") and "]" in line:
try:
return int(line[1:line.index("]")])
except ValueError:
return None
return None
class MirageTerminalToolkit(BaseToolkit):
def __init__(self,
workspace: Workspace,
timeout: float | None = 20.0) -> None:
super().__init__(timeout=timeout)
self._ws = workspace
self._runner = AsyncRunner()
self._sessions: dict[str, int] = {}
def close(self) -> None:
self._runner.close()
def shell_exec(
self,
id: str,
command: str,
block: bool = True,
timeout: float = 20.0,
) -> str:
"""Run command in the Mirage workspace.
Args:
id (str): Session identifier (mapped to a Mirage job id).
command (str): Shell command to execute.
block (bool): Wait for completion when True; otherwise launch
the command in the background via Mirage's & operator.
timeout (float): Reserved for future use.
Returns:
str: Combined stdout/stderr (blocking) or a confirmation
message with the session id (non-blocking).
"""
if block:
io = self._runner.run(self._ws.execute(command))
return io_to_str(io)
bg_cmd = f"{command} &"
io = self._runner.run(self._ws.execute(bg_cmd))
stderr = decode(io.stderr if isinstance(io.stderr, bytes) else None)
job_id = _parse_job_id(stderr)
if job_id is None:
return f"Failed to launch background job: {stderr}"
self._sessions[id] = job_id
return f"Started session '{id}' as Mirage job [{job_id}]"
def shell_view(self, id: str) -> str:
"""Return the latest output for a session.
Args:
id (str): Session identifier from a prior non-blocking
shell_exec call.
Returns:
str: jobs status if still running, or wait output if completed.
"""
job_id = self._sessions.get(id)
if job_id is None:
return f"Error: no session '{id}'"
ps_io = self._runner.run(self._ws.execute("ps"))
ps_out = decode(
ps_io.stdout if isinstance(ps_io.stdout, bytes) else None)
if any(line.startswith(f"{job_id}\t") for line in ps_out.splitlines()):
return ps_out
wait_io = self._runner.run(self._ws.execute(f"wait %{job_id}"))
return io_to_str(wait_io)
def shell_write_to_process(self, id: str, command: str) -> str:
"""Stub for camel API parity; Mirage shell is non-interactive.
Args:
id (str): Session identifier (unused).
command (str): Input that would be sent to stdin (unused).
Returns:
str: Explanatory error message.
"""
return ("Mirage shell is not interactive. Re-run shell_exec with "
"stdin redirected via the command, e.g. "
"'cat <<EOF | yourcmd\\nINPUT\\nEOF'.")
def shell_kill_process(self, id: str) -> str:
"""Kill the Mirage job for a session.
Args:
id (str): Session identifier from a prior non-blocking
shell_exec call.
Returns:
str: Status message.
"""
job_id = self._sessions.pop(id, None)
if job_id is None:
return f"Error: no session '{id}'"
io = self._runner.run(self._ws.execute(f"kill %{job_id}"))
if io.exit_code != 0:
return io_to_str(io) or f"kill failed for [{job_id}]"
return f"killed session '{id}' (job [{job_id}])"
def shell_ask_user_for_help(self, id: str, prompt: str) -> str:
"""Placeholder hook for human-in-the-loop frameworks.
Args:
id (str): Session identifier (unused).
prompt (str): The question the agent wants to ask.
Returns:
str: Echoed prompt.
"""
return f"User prompt recorded (no human attached): {prompt}"
def shell_write_content_to_file(self, content: str, file_path: str) -> str:
"""Write content to a file via Mirage Workspace.
Args:
content (str): UTF-8 text to write.
file_path (str): Logical Mirage path.
Returns:
str: Success or error message.
"""
quoted = shlex.quote(file_path)
io = self._runner.run(
self._ws.execute(f"cat > {quoted}", stdin=content.encode()))
if io.exit_code != 0:
return f"Error writing {file_path}: {io_to_str(io)}"
return f"Wrote {len(content)} bytes to {file_path}"
def get_tools(self) -> list[FunctionTool]:
return [
FunctionTool(self.shell_exec),
FunctionTool(self.shell_view),
FunctionTool(self.shell_write_to_process),
FunctionTool(self.shell_kill_process),
FunctionTool(self.shell_ask_user_for_help),
FunctionTool(self.shell_write_content_to_file),
]
@@ -0,0 +1,25 @@
# ========= 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. =========
from mirage.agents.claude_agent_sdk.options import build_options
from mirage.agents.claude_agent_sdk.prompt import (MIRAGE_SYSTEM_PROMPT,
build_system_prompt)
from mirage.agents.claude_agent_sdk.server import MirageServer
__all__ = [
"MirageServer",
"build_options",
"MIRAGE_SYSTEM_PROMPT",
"build_system_prompt",
]
@@ -0,0 +1,52 @@
# ========= 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. =========
try:
from claude_agent_sdk import ClaudeAgentOptions
except ImportError as exc:
raise ImportError(
"`claude-agent-sdk` not installed. "
"Install with: pip install 'mirage-ai[claude-agent-sdk]'") from exc
from mirage.agents.claude_agent_sdk.prompt import build_system_prompt
from mirage.agents.claude_agent_sdk.server import MirageServer
from mirage.workspace.workspace import Workspace
def build_options(
workspace: Workspace,
*,
system_prompt: str | None = None,
) -> ClaudeAgentOptions:
"""Build ClaudeAgentOptions backed by a Mirage Workspace.
Disables all built-in file tools and registers Mirage tools
(execute_command, read, write, edit, ls, grep) as the agent's
only file access layer.
Args:
workspace (Workspace): The workspace to serve.
system_prompt (str | None): Override the default Mirage system prompt.
Returns:
ClaudeAgentOptions: Ready to pass to claude_agent_sdk.query().
"""
server = MirageServer(workspace)
return ClaudeAgentOptions(
mcp_servers={"mirage": server},
allowed_tools=["mcp__mirage__*"],
tools=[],
system_prompt=system_prompt
or build_system_prompt(workspace=workspace),
)
@@ -0,0 +1,55 @@
# ========= 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. =========
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
EXECUTE_DESCRIPTION = (
"Run a shell-style command on the Mirage virtual filesystem. "
"Supports cat, grep, find, head, tail, ls, wc, sort, uniq, tee, pipe, "
"and any other Unix command on mounted resources (S3, disk, RAM, etc.). "
"Also supports reading structured files: "
"cat on .parquet/.orc/.csv returns a table.")
READ_DESCRIPTION = (
"Read the contents of a file on the Mirage virtual filesystem. "
"Returns line-numbered text. "
"Optionally pass 'offset' (default 0) to start at a given line "
"and 'limit' (default 2000) to cap the number of lines returned.")
WRITE_DESCRIPTION = (
"Write content to a new file on the Mirage virtual filesystem. "
"Fails if the file already exists; use edit to modify an existing file.")
EDIT_DESCRIPTION = (
"Replace a string in an existing file on the Mirage virtual filesystem. "
"Fails if old_string is not found or appears more than once. "
"Pass replace_all=true (default false) to replace every occurrence.")
LS_DESCRIPTION = ("List files and directories at the given path "
"on the Mirage virtual filesystem.")
GREP_DESCRIPTION = (
"Search for a pattern in files on the Mirage virtual filesystem. "
"Supports regex. Searches recursively under path.")
__all__ = [
"MIRAGE_SYSTEM_PROMPT",
"build_system_prompt",
"EXECUTE_DESCRIPTION",
"READ_DESCRIPTION",
"WRITE_DESCRIPTION",
"EDIT_DESCRIPTION",
"LS_DESCRIPTION",
"GREP_DESCRIPTION",
]
@@ -0,0 +1,181 @@
# ========= 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 shlex
from typing import Any
try:
from claude_agent_sdk import ToolAnnotations, create_sdk_mcp_server, tool
except ImportError as exc:
raise ImportError(
"`claude-agent-sdk` not installed. "
"Install with: pip install 'mirage-ai[claude-agent-sdk]'") from exc
from mirage import __version__
from mirage.agents.claude_agent_sdk.prompt import ( # yapf: disable
EDIT_DESCRIPTION, EXECUTE_DESCRIPTION, GREP_DESCRIPTION, LS_DESCRIPTION,
READ_DESCRIPTION, WRITE_DESCRIPTION)
from mirage.agents.io_text import io_to_str
from mirage.io.types import IOResult
from mirage.workspace.workspace import Workspace
def _text(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}]}
def _error(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}], "is_error": True}
def _io_to_result(io: IOResult) -> dict[str, Any]:
result = _text(io_to_str(io))
if io.exit_code != 0:
result["is_error"] = True
return result
class _MirageTools:
def __init__(self, workspace: Workspace) -> None:
self._ws = workspace
async def execute_command(self, args: dict[str, Any]) -> dict[str, Any]:
io = await self._ws.execute(args["command"])
return _io_to_result(io)
async def _ensure_parents(self, path: str) -> None:
parts = [p for p in path.split("/") if p]
ops = self._ws.ops
current = ""
for part in parts[:-1]:
current += "/" + part
try:
await ops.mkdir(current)
except FileExistsError:
continue
async def read(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
offset = int(args.get("offset", 0))
limit = int(args.get("limit", 2000))
ops = self._ws.ops
try:
data = await ops.read(path)
except FileNotFoundError:
return _error(f"Error: file '{path}' not found")
except ValueError as exc:
return _error(f"Error: {exc}")
text = data.decode("utf-8", errors="replace")
lines = text.splitlines(keepends=True)
sliced = lines[offset:offset + limit]
numbered = [
f"{i + offset + 1:>6}\t{line}" for i, line in enumerate(sliced)
]
return _text("".join(numbered))
async def write(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
content = args["content"]
ops = self._ws.ops
try:
await ops.stat(path)
except FileNotFoundError:
pass
else:
return _error(f"Error: file '{path}' already exists")
await self._ensure_parents(path)
data = content.encode("utf-8") if isinstance(content, str) else content
await ops.write(path, data)
return _text(f"Written: {path}")
async def edit(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
old_string = args["old_string"]
new_string = args["new_string"]
replace_all = bool(args.get("replace_all", False))
ops = self._ws.ops
try:
data = await ops.read(path)
except FileNotFoundError:
return _error(f"Error: file '{path}' not found")
content = data.decode("utf-8", errors="replace")
count = content.count(old_string)
if count == 0:
return _error(f"Error: string not found in file: '{old_string}'")
if count > 1 and not replace_all:
return _error(
f"Error: string appears {count} times. Pass replace_all=true")
new_content = content.replace(
old_string, new_string) if replace_all else content.replace(
old_string, new_string, 1)
await ops.write(path, new_content.encode("utf-8"))
occurrences = count if replace_all else 1
return _text(f"Edited: {path} ({occurrences} occurrence(s))")
async def ls(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
io = await self._ws.execute(f"ls {shlex.quote(path)}")
return _io_to_result(io)
async def grep(self, args: dict[str, Any]) -> dict[str, Any]:
pattern = args["pattern"]
path = args["path"]
io = await self._ws.execute(
f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}")
return _text(io_to_str(io))
def MirageServer(workspace: Workspace):
"""Create an in-process Mirage server for the Claude Agent SDK.
Args:
workspace (Workspace): The workspace to serve.
Returns:
An SDK server object to pass to ClaudeAgentOptions(mcp_servers=...).
"""
tools_impl = _MirageTools(workspace)
return create_sdk_mcp_server(
name="mirage",
version=__version__,
tools=[
tool("execute_command", EXECUTE_DESCRIPTION,
{"command": str})(tools_impl.execute_command),
tool("read",
READ_DESCRIPTION, {"path": str},
annotations=ToolAnnotations(readOnlyHint=True))(
tools_impl.read),
tool("write", WRITE_DESCRIPTION, {
"path": str,
"content": str
})(tools_impl.write),
tool("edit", EDIT_DESCRIPTION, {
"path": str,
"old_string": str,
"new_string": str
})(tools_impl.edit),
tool("ls",
LS_DESCRIPTION, {"path": str},
annotations=ToolAnnotations(readOnlyHint=True))(
tools_impl.ls),
tool("grep",
GREP_DESCRIPTION, {
"pattern": str,
"path": str
},
annotations=ToolAnnotations(readOnlyHint=True))(
tools_impl.grep),
],
)
+29
View File
@@ -0,0 +1,29 @@
# ========= 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. =========
from mirage.io.types import IOResult
def decode(value: bytes | None) -> str:
if value is None:
return ""
return value.decode("utf-8", errors="replace")
def io_to_str(io: IOResult) -> str:
stdout = decode(io.stdout if isinstance(io.stdout, bytes) else None)
stderr = decode(io.stderr if isinstance(io.stderr, bytes) else None)
if stderr:
return f"{stdout}\n{stderr}" if stdout else stderr
return stdout
@@ -0,0 +1,25 @@
# ========= 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. =========
from mirage.agents.langchain._messages import extract_text
from mirage.agents.langchain.backend import LangchainWorkspace
from mirage.agents.langchain.prompt import (MIRAGE_SYSTEM_PROMPT,
build_system_prompt)
__all__ = [
"LangchainWorkspace",
"MIRAGE_SYSTEM_PROMPT",
"build_system_prompt",
"extract_text",
]
@@ -0,0 +1,58 @@
# ========= 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. =========
from deepagents.backends.protocol import ExecuteResponse, FileInfo, GrepMatch
from mirage.agents.io_text import decode
from mirage.io.types import IOResult
def io_to_execute_response(io: IOResult) -> ExecuteResponse:
stdout = decode(io.stdout)
stderr = decode(io.stderr)
output = stdout
if stderr:
output = f"{stdout}\n{stderr}" if stdout else stderr
return ExecuteResponse(output=output, exit_code=io.exit_code)
def io_to_grep_matches(io: IOResult) -> list[GrepMatch]:
stdout = decode(io.stdout).strip()
if not stdout:
return []
matches: list[GrepMatch] = []
for line in stdout.split("\n"):
parts = line.split(":", 2)
if len(parts) >= 3:
try:
line_num = int(parts[1])
except ValueError:
continue
matches.append(
GrepMatch(path=parts[0], line=line_num, text=parts[2]))
return matches
def io_to_file_infos(io: IOResult) -> list[FileInfo]:
stdout = decode(io.stdout).strip()
if not stdout:
return []
infos: list[FileInfo] = []
for entry in stdout.split("\n"):
entry = entry.strip()
if not entry:
continue
is_dir = entry.endswith("/")
infos.append(FileInfo(path=entry.rstrip("/"), is_dir=is_dir))
return infos
@@ -0,0 +1,41 @@
# ========= 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. =========
from typing import Any
def extract_text(messages: list[Any]) -> list[str]:
"""Extract text content from LangGraph messages.
Args:
messages (list[Any]): LangGraph message objects.
Returns:
list[str]: Non-empty text strings.
"""
texts: list[str] = []
for msg in messages:
if not hasattr(msg, "content"):
continue
content = msg.content
if isinstance(content, str):
if content.strip():
texts.append(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block["text"].strip()
if text:
texts.append(text)
return texts
+252
View File
@@ -0,0 +1,252 @@
# ========= 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 asyncio
import shlex
from deepagents.backends.protocol import (EditResult, ExecuteResponse,
FileDownloadResponse, FileInfo,
FileUploadResponse, GrepMatch,
SandboxBackendProtocol, WriteResult)
from mirage.agents.langchain._convert import (io_to_execute_response,
io_to_file_infos,
io_to_grep_matches)
from mirage.io.types import IOResult
from mirage.types import DEFAULT_SESSION_ID
from mirage.workspace.workspace import Workspace
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 = DEFAULT_SESSION_ID,
) -> None:
self._ws = workspace
self._id = sandbox_id
self._session_id = session_id
def _run(self, coro):
return asyncio.run(coro)
@property
def id(self) -> str:
return self._id
async def _exec(self, command: str) -> IOResult:
return await self._ws.execute(command, session_id=self._session_id)
# ── 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_info ─────────────────────────────────────────────
def ls_info(self, path: str) -> list[FileInfo]:
return self._run(self.als_info(path))
async def als_info(self, path: str) -> list[FileInfo]:
io = await self._exec(f"ls {shlex.quote(path)}")
stdout = (io.stdout or b"").decode("utf-8", errors="replace").strip()
if not stdout:
return []
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 result
# ── read ─────────────────────────────────────────────────
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
return self._run(self.aread(file_path, offset, limit))
async def aread(self,
file_path: str,
offset: int = 0,
limit: int = 2000) -> str:
ops = self._ws.ops
try:
data = await ops.read(file_path)
except (FileNotFoundError, ValueError) as exc:
return f"Error: {exc}"
text = data.decode("utf-8", errors="replace")
lines = text.splitlines(keepends=True)
sliced = lines[offset:offset + limit]
numbered = []
for i, line in enumerate(sliced, start=offset + 1):
numbered.append(f"{i:>6}\t{line}")
return "".join(numbered)
# ── 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):
pass
parent = "/".join(file_path.rstrip("/").split("/")[:-1]) or "/"
try:
await ops.mkdir(parent)
except (FileExistsError, ValueError):
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_raw ─────────────────────────────────────────────
def grep_raw(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
) -> list[GrepMatch] | str:
return self._run(self.agrep_raw(pattern, path, glob))
async def agrep_raw(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
) -> list[GrepMatch] | str:
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))
return io_to_grep_matches(io)
# ── glob_info ────────────────────────────────────────────
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
return self._run(self.aglob_info(pattern, path))
async def aglob_info(self,
pattern: str,
path: str = "/") -> list[FileInfo]:
name = pattern.split("/")[-1] if "/" in pattern else pattern
io = await self._exec(
f"find {shlex.quote(path)} -name {shlex.quote(name)}")
return 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):
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
+17
View File
@@ -0,0 +1,17 @@
# ========= 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. =========
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]
@@ -0,0 +1,31 @@
# ========= 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. =========
from mirage.agents.openai_agents.editor import MirageEditor
from mirage.agents.openai_agents.prompt import (MIRAGE_SYSTEM_PROMPT,
build_system_prompt)
from mirage.agents.openai_agents.runner import MirageRunner
from mirage.agents.openai_agents.sandbox import (MirageSandboxClient,
MirageSandboxSession)
from mirage.agents.openai_agents.shell import MirageShellExecutor
__all__ = [
"MirageEditor",
"MirageRunner",
"MirageSandboxClient",
"MirageSandboxSession",
"MirageShellExecutor",
"MIRAGE_SYSTEM_PROMPT",
"build_system_prompt",
]
@@ -0,0 +1,62 @@
# ========= 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. =========
from agents.apply_diff import apply_diff
from agents.editor import (ApplyPatchEditor, ApplyPatchOperation,
ApplyPatchResult)
from mirage.workspace.workspace import Workspace
class MirageEditor(ApplyPatchEditor):
"""ApplyPatchEditor backed by a Mirage Workspace.
Args:
workspace (Workspace): The workspace for file operations.
"""
def __init__(self, workspace: Workspace) -> None:
self._ws = workspace
async def create_file(self, op: ApplyPatchOperation) -> ApplyPatchResult:
ops = self._ws.ops
parent = "/".join(op.path.rstrip("/").split("/")[:-1]) or "/"
try:
await ops.mkdir(parent)
except (FileExistsError, ValueError):
pass
content = apply_diff("", op.diff or "", mode="create")
await ops.write(op.path, content.encode("utf-8"))
return ApplyPatchResult(status="completed")
async def update_file(self, op: ApplyPatchOperation) -> ApplyPatchResult:
ops = self._ws.ops
try:
data = await ops.read(op.path)
except (FileNotFoundError, ValueError):
return ApplyPatchResult(status="failed",
output=f"File not found: {op.path}")
current = data.decode("utf-8", errors="replace")
new_content = apply_diff(current, op.diff or "")
await ops.write(op.path, new_content.encode("utf-8"))
return ApplyPatchResult(status="completed")
async def delete_file(self, op: ApplyPatchOperation) -> ApplyPatchResult:
ops = self._ws.ops
try:
await ops.unlink(op.path)
except (FileNotFoundError, ValueError):
return ApplyPatchResult(status="failed",
output=f"File not found: {op.path}")
return ApplyPatchResult(status="completed")
@@ -0,0 +1,17 @@
# ========= 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. =========
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]
@@ -0,0 +1,121 @@
# ========= 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
from agents import Runner
from openai import AsyncOpenAI
from mirage.types import FileType
from mirage.workspace.workspace import Workspace
_VISION_TYPES = {
FileType.IMAGE_PNG,
FileType.IMAGE_JPEG,
FileType.IMAGE_GIF,
}
_MIMETYPE_FOR = {
FileType.IMAGE_PNG: "image/png",
FileType.IMAGE_JPEG: "image/jpeg",
FileType.IMAGE_GIF: "image/gif",
FileType.PDF: "application/pdf",
}
class MirageRunner:
"""Run OpenAI agents with mirage-resolved multimodal attachments.
Args:
workspace (Workspace): The workspace to resolve paths against.
client (AsyncOpenAI | None): OpenAI async client, used for PDF
uploads via the Files API. Required if any attachment is
a PDF; constructed with default settings if not given.
"""
def __init__(
self,
workspace: Workspace,
client: AsyncOpenAI | None = None,
) -> None:
self._ws = workspace
self._client = client
async def _block_for_path(self, path: str) -> dict:
st = await self._ws.ops.stat(path)
data = await self._ws.ops.read(path)
if st.type in _VISION_TYPES:
mime = _MIMETYPE_FOR[st.type]
b64 = base64.b64encode(data).decode("ascii")
return {
"type": "input_image",
"image_url": f"data:{mime};base64,{b64}",
}
if st.type == FileType.PDF:
if self._client is None:
self._client = AsyncOpenAI()
filename = path.rsplit("/", 1)[-1]
uploaded = await self._client.files.create(
file=(filename, data),
purpose="user_data",
)
return {"type": "input_file", "file_id": uploaded.id}
return {
"type": "input_text",
"text": data.decode("utf-8", errors="replace"),
}
async def build_blocks(
self,
prompt: str,
paths: list[str],
) -> list[dict]:
"""Build the user-message content blocks for a prompt + paths.
Args:
prompt (str): User-facing instruction text.
paths (list[str]): Mirage paths to attach (any resource).
Returns:
list[dict]: Content blocks ready to embed in a user message.
"""
blocks: list[dict] = [{"type": "input_text", "text": prompt}]
for path in paths:
blocks.append(await self._block_for_path(path))
return blocks
async def run_with_attachments(
self,
agent,
prompt: str,
paths: list[str],
):
"""Run the agent with mirage paths as multimodal attachments.
Args:
agent: The OpenAI Agents SDK Agent instance.
prompt (str): User-facing instruction text.
paths (list[str]): Mirage paths to attach.
Returns:
The result from `agents.Runner.run`.
"""
blocks = await self.build_blocks(prompt, paths)
return await Runner.run(
agent,
[{
"role": "user",
"content": blocks
}],
)
@@ -0,0 +1,186 @@
# ========= 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 io
import uuid
from pathlib import Path
from typing import Literal
from agents.sandbox.manifest import Manifest
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.session.sandbox_client import BaseSandboxClient
from agents.sandbox.session.sandbox_session import SandboxSession
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase, SnapshotSpec
from agents.sandbox.types import ExecResult, User
from mirage.workspace.snapshot import apply_state_dict, read_tar
from mirage.workspace.workspace import Workspace
class MirageSandboxSessionState(SandboxSessionState):
type: Literal["mirage"] = "mirage"
class MirageSandboxSession(BaseSandboxSession):
def __init__(
self,
workspace: Workspace,
state: MirageSandboxSessionState,
) -> None:
self._ws = workspace
self.state = state
async def _exec_internal(
self,
*command: str | Path,
timeout: float | None = None,
) -> ExecResult:
cmd_str = " ".join(str(c) for c in command)
io_result = await self._ws.execute(cmd_str)
stdout = (io_result.stdout or b"")
stderr = (io_result.stderr or b"")
if isinstance(stdout, str):
stdout = stdout.encode("utf-8")
if isinstance(stderr, str):
stderr = stderr.encode("utf-8")
return ExecResult(
exit_code=io_result.exit_code,
stdout=stdout,
stderr=stderr,
)
async def read(
self,
path: Path,
*,
user: str | User | None = None,
) -> io.IOBase:
data = await self._ws.ops.read(str(path))
return io.BytesIO(data)
async def write(
self,
path: Path,
data: io.IOBase,
*,
user: str | User | None = None,
) -> None:
content = data.read()
if isinstance(content, str):
content = content.encode("utf-8")
parent = str(path.parent)
if parent and parent != ".":
try:
await self._ws.ops.mkdir(parent)
except (FileExistsError, ValueError):
pass
await self._ws.ops.write(str(path), content)
def _prepare_exec_command(
self,
*command: str | Path,
shell: bool | list[str],
user: str | User | None,
) -> list[str]:
return [str(c) for c in command]
async def running(self) -> bool:
return not self._ws._closed
async def persist_workspace(self) -> io.IOBase:
buf = io.BytesIO()
await self._ws.snapshot(buf)
buf.seek(0)
return buf
async def hydrate_workspace(self, data: io.IOBase) -> None:
# Restore the snapshot's non-mount state (cache, sessions,
# inodes, history, jobs) AND each resource's content (via
# load_state) into THIS workspace. The workspace must already
# have the same mount shape that was saved — Workspace.load()
# is the alternative that constructs a fresh Workspace from
# scratch.
if hasattr(data, "seek"):
data.seek(0)
state = read_tar(data)
await apply_state_dict(self._ws, state)
class MirageSandboxClient(BaseSandboxClient[None]):
# In-process integration: every sandbox session shares one Workspace
# instance owned by the agent's process. No HTTP, no daemon -- the
# agent and the workspace run on the same event loop.
#
# If you need cross-process isolation (each agent talks to a
# workspace hosted in a separate daemon process), see
# docs/plans/2026-04-17-workspace-server-cli.md -- a
# MirageRemoteSandboxClient that speaks HTTP to `mirage daemon`
# would slot into the same BaseSandboxClient interface.
backend_id: str = "mirage"
supports_default_options: bool = True
def __init__(self, workspace: Workspace) -> None:
self._ws = workspace
self._sessions: dict[uuid.UUID, MirageSandboxSession] = {}
async def create(
self,
*,
snapshot: SnapshotSpec | SnapshotBase | None = None,
manifest: Manifest | None = None,
options: None = None,
) -> SandboxSession:
session_id = uuid.uuid4()
snapshot_id = str(session_id)
snap: SnapshotBase
if isinstance(snapshot, SnapshotSpec):
snap = snapshot.build(snapshot_id)
elif isinstance(snapshot, SnapshotBase):
snap = snapshot
else:
snap = NoopSnapshot(id=snapshot_id)
state = MirageSandboxSessionState(
session_id=session_id,
snapshot=snap,
manifest=manifest or Manifest(root="/"),
)
session = MirageSandboxSession(workspace=self._ws, state=state)
self._sessions[session_id] = session
return self._wrap_session(session)
async def delete(self, session: SandboxSession) -> SandboxSession:
sid = session.state.session_id
self._sessions.pop(sid, None)
return session
async def resume(
self,
state: SandboxSessionState,
) -> SandboxSession:
sid = state.session_id
session = self._sessions.get(sid)
if session is None:
raise ValueError(f"No session to resume: {sid}")
return self._wrap_session(session)
def deserialize_session_state(
self,
payload: dict[str, object],
) -> SandboxSessionState:
return MirageSandboxSessionState.model_validate(payload)
@@ -0,0 +1,35 @@
# ========= 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. =========
from mirage.agents.io_text import io_to_str
from mirage.workspace.workspace import Workspace
class MirageShellExecutor:
"""ShellTool executor backed by a Mirage Workspace.
Args:
workspace (Workspace): The workspace to execute commands in.
"""
def __init__(self, workspace: Workspace) -> None:
self._ws = workspace
async def __call__(self, request) -> str:
commands = request.data.action.commands
outputs: list[str] = []
for cmd in commands:
io = await self._ws.execute(cmd)
outputs.append(io_to_str(io))
return "\n".join(outputs)
@@ -0,0 +1,23 @@
# ========= 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. =========
from mirage.agents.openhands.terminal import (MirageTerminalExecutor,
register_mirage_terminal)
from mirage.agents.openhands.workspace import MirageWorkspace
__all__ = [
"MirageWorkspace",
"MirageTerminalExecutor",
"register_mirage_terminal",
]
+107
View File
@@ -0,0 +1,107 @@
# ========= 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 functools
from typing import TYPE_CHECKING, Any
from openhands.sdk.llm import TextContent
from openhands.sdk.tool import ToolExecutor, register_tool
from openhands.tools.terminal import TerminalTool
from openhands.tools.terminal.definition import (TerminalAction,
TerminalObservation)
from openhands.tools.terminal.metadata import CmdOutputMetadata
from mirage.agents.openhands.workspace import MirageWorkspace
if TYPE_CHECKING:
from openhands.sdk.conversation.impl.local_conversation import \
LocalConversation
class MirageTerminalExecutor(ToolExecutor[TerminalAction,
TerminalObservation]):
"""Routes OpenHands TerminalTool actions through a Mirage Workspace.
Args:
workspace: MirageWorkspace adapter wrapping a Mirage Workspace.
Each TerminalAction is executed via
``workspace.execute_command``, which dispatches the shell
command through Mirage's accessor stack so virtual mounts
(S3, Slack, ...) appear as paths.
"""
def __init__(self, workspace: MirageWorkspace) -> None:
self._mw = workspace
def __call__(
self,
action: TerminalAction,
conversation: "LocalConversation | None" = None,
) -> TerminalObservation:
if action.is_input:
return TerminalObservation(
command=action.command,
content=[
TextContent(text=("MirageTerminalExecutor does not "
"support is_input=True"))
],
exit_code=-1,
metadata=CmdOutputMetadata(exit_code=-1,
working_dir=self._mw.working_dir),
)
timeout = action.timeout if action.timeout is not None else 30.0
result = self._mw.execute_command(action.command, timeout=timeout)
text = (result.stdout or "") + (result.stderr or "")
metadata = CmdOutputMetadata(
exit_code=result.exit_code,
working_dir=self._mw.working_dir,
)
return TerminalObservation(
command=action.command,
content=[TextContent(text=text)],
exit_code=result.exit_code,
timeout=result.timeout_occurred,
metadata=metadata,
)
def _mirage_terminal_factory(
workspace: MirageWorkspace,
*,
conv_state: Any,
**params: Any,
) -> list[TerminalTool]:
executor = MirageTerminalExecutor(workspace)
return list(
TerminalTool.create(conv_state=conv_state, executor=executor,
**params))
def register_mirage_terminal(
workspace: MirageWorkspace,
name: str = "mirage_terminal",
) -> str:
"""Register a TerminalTool variant routed through Mirage.
Args:
workspace: MirageWorkspace adapter to bind to the executor.
name: Tool name to register under. Defaults to
"mirage_terminal".
Returns:
str: The registered tool name, ready to use as
``Tool(name=...)`` in the agent's tool list.
"""
register_tool(name, functools.partial(_mirage_terminal_factory, workspace))
return name
+253
View File
@@ -0,0 +1,253 @@
# ========= 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 asyncio
import logging
import shlex
import threading
from pathlib import Path
from typing import Any
from openhands.sdk.git.models import GitChange, GitDiff
from openhands.sdk.workspace.local import LocalWorkspace
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
from pydantic import Field, PrivateAttr
from mirage.workspace.workspace import Workspace as MirageBackingWorkspace
logger = logging.getLogger(__name__)
def _run_loop_in_thread(state: dict, ready: threading.Event) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
state["loop"] = loop
ready.set()
try:
loop.run_forever()
finally:
loop.close()
async def _execute_with_timeout(
ws: MirageBackingWorkspace,
command: str,
timeout: float,
) -> Any:
return await asyncio.wait_for(ws.execute(command), timeout=timeout)
class _AsyncBridge:
def __init__(self) -> None:
self._state: dict[str, Any] = {}
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def _ensure(self) -> asyncio.AbstractEventLoop:
with self._lock:
loop = self._state.get("loop")
if loop is not None:
return loop
ready = threading.Event()
t = threading.Thread(
target=_run_loop_in_thread,
args=(self._state, ready),
name="mirage-openhands-loop",
daemon=True,
)
t.start()
ready.wait()
self._thread = t
return self._state["loop"]
def run(self, coro: Any, timeout: float | None = None) -> Any:
loop = self._ensure()
fut = asyncio.run_coroutine_threadsafe(coro, loop)
return fut.result(timeout=timeout)
def shutdown(self) -> None:
with self._lock:
loop = self._state.get("loop")
if loop is None:
return
self._state.pop("loop", None)
loop.call_soon_threadsafe(loop.stop)
if self._thread is not None:
self._thread.join(timeout=5)
self._thread = None
class MirageWorkspace(LocalWorkspace):
"""OpenHands LocalWorkspace adapter backed by a Mirage Workspace.
Inherits LocalWorkspace so OpenHands' Conversation isinstance check
passes, but overrides every operation to route through a Mirage
Workspace's virtual filesystem and shell. The async-to-sync bridge
dispatches coroutines onto a dedicated background event loop.
Args:
workspace: Mirage Workspace instance to delegate filesystem and
shell operations to.
working_dir: Default working directory used as cwd for shell
commands when no explicit cwd is provided. Must be a valid
host path (OpenHands validates it on the host fs). Defaults
to "/".
"""
working_dir: str = Field(
default="/",
description="Default working directory inside the Mirage workspace.",
)
_ws: Any = PrivateAttr()
_bridge: Any = PrivateAttr()
def __init__(
self,
*,
workspace: MirageBackingWorkspace,
working_dir: str = "/",
**kwargs: Any,
) -> None:
super().__init__(working_dir=working_dir, **kwargs)
self._ws = workspace
self._bridge = _AsyncBridge()
@property
def workspace(self) -> MirageBackingWorkspace:
return self._ws
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
try:
if not self._ws._closed:
self._bridge.run(self._ws.close())
finally:
self._bridge.shutdown()
def execute_command(
self,
command: str,
cwd: str | Path | None = None,
timeout: float = 30.0,
) -> CommandResult:
cwd_str = str(cwd) if cwd is not None else self.working_dir
if cwd_str and cwd_str not in ("", "."):
full_command = f"cd {shlex.quote(cwd_str)} && {command}"
else:
full_command = command
try:
io_result = self._bridge.run(
_execute_with_timeout(self._ws, full_command, timeout))
stdout = self._coerce_text(getattr(io_result, "stdout", b""))
stderr = self._coerce_text(getattr(io_result, "stderr", b""))
exit_code = int(getattr(io_result, "exit_code", 0) or 0)
return CommandResult(
command=command,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
timeout_occurred=False,
)
except asyncio.TimeoutError:
return CommandResult(
command=command,
exit_code=-1,
stdout="",
stderr=f"Command timed out after {timeout}s",
timeout_occurred=True,
)
def file_upload(
self,
source_path: str | Path,
destination_path: str | Path,
) -> FileOperationResult:
src = Path(source_path)
dst = str(destination_path)
try:
data = src.read_bytes()
parent = str(Path(dst).parent)
if parent and parent not in (".", "/"):
self._ensure_parent(parent)
self._bridge.run(self._ws.ops.write(dst, data))
return FileOperationResult(
success=True,
source_path=str(src),
destination_path=dst,
file_size=len(data),
)
except Exception as e:
logger.error("file_upload %s -> %s failed: %s", src, dst, e)
return FileOperationResult(
success=False,
source_path=str(src),
destination_path=dst,
error=str(e),
)
def file_download(
self,
source_path: str | Path,
destination_path: str | Path,
) -> FileOperationResult:
src = str(source_path)
dst = Path(destination_path)
try:
data = self._bridge.run(self._ws.ops.read(src))
if isinstance(data, str):
data = data.encode("utf-8")
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(data)
return FileOperationResult(
success=True,
source_path=src,
destination_path=str(dst),
file_size=len(data),
)
except Exception as e:
logger.error("file_download %s -> %s failed: %s", src, dst, e)
return FileOperationResult(
success=False,
source_path=src,
destination_path=str(dst),
error=str(e),
)
def git_changes(self, path: str | Path) -> list[GitChange]:
raise NotImplementedError(
"Mirage workspaces do not expose git semantics over their "
"virtual mounts; query the underlying resource directly.")
def git_diff(self, path: str | Path) -> GitDiff:
raise NotImplementedError(
"Mirage workspaces do not expose git semantics over their "
"virtual mounts; query the underlying resource directly.")
def _ensure_parent(self, parent: str) -> None:
result = self._bridge.run(
self._ws.execute(f"mkdir -p {shlex.quote(parent)}"))
exit_code = int(getattr(result, "exit_code", 0) or 0)
if exit_code != 0:
stderr = self._coerce_text(getattr(result, "stderr", b""))
raise RuntimeError(
f"mkdir -p {parent!r} failed (exit {exit_code}): {stderr}")
@staticmethod
def _coerce_text(value: Any) -> str:
if isinstance(value, (bytes, bytearray)):
return value.decode("utf-8", errors="replace")
if value is None:
return ""
return str(value)
+59
View File
@@ -0,0 +1,59 @@
# ========= 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. =========
MIRAGE_SYSTEM_PROMPT = """\
Your filesystem is powered by Mirage — a virtual filesystem that mounts \
cloud storage, local files, and in-memory data as a unified file tree.
Capabilities beyond standard filesystem:
- cat on .parquet, .orc, .feather files returns a formatted table
- head -n 5 on data files returns the first 5 rows
- grep works natively on CSV, JSON, Parquet — not just text
- Pipes work: cat data.parquet | grep error | sort | uniq | wc -l
- head, tail, cut, wc, sort, uniq, tee, xargs are all available
You can write Python code and execute it. The workspace is pre-configured \
with your data sources mounted at their respective paths.
Use the execute tool for complex operations. \
Use read/write/edit for simple file operations.
"""
def build_system_prompt(
mount_info: dict[str, str] | None = None,
extra_instructions: str = "",
workspace: "object | None" = None,
) -> str:
"""Build a system prompt with optional mount info and extra instructions.
Args:
mount_info (dict[str, str] | None): Map of mount prefix to description.
extra_instructions (str): Additional instructions to append.
workspace (Workspace | None): If provided, uses workspace.file_prompt.
Returns:
str: The complete system prompt.
"""
parts = [MIRAGE_SYSTEM_PROMPT]
if workspace is not None and hasattr(workspace, "file_prompt"):
parts.append("Mounted data sources:\n" + workspace.file_prompt)
elif mount_info:
parts.append("\nMounted data sources:")
for prefix, description in mount_info.items():
parts.append(f"- {prefix}{description}")
parts.append("")
if extra_instructions:
parts.append(extra_instructions)
return "\n".join(parts)
@@ -0,0 +1,23 @@
# ========= 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. =========
from mirage.agents.pydantic_ai.backend import PydanticAIWorkspace
from mirage.agents.pydantic_ai.prompt import (MIRAGE_SYSTEM_PROMPT,
build_system_prompt)
__all__ = [
"PydanticAIWorkspace",
"MIRAGE_SYSTEM_PROMPT",
"build_system_prompt",
]
@@ -0,0 +1,60 @@
# ========= 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. =========
from pydantic_ai_backends.types import ExecuteResponse, FileInfo, GrepMatch
from mirage.agents.io_text import decode
from mirage.io.types import IOResult
def io_to_execute_response(io: IOResult) -> ExecuteResponse:
stdout = decode(io.stdout)
stderr = decode(io.stderr)
output = stdout
if stderr:
output = f"{stdout}\n{stderr}" if stdout else stderr
return ExecuteResponse(output=output, exit_code=io.exit_code)
def io_to_grep_matches(io: IOResult) -> list[GrepMatch]:
stdout = decode(io.stdout).strip()
if not stdout:
return []
matches: list[GrepMatch] = []
for line in stdout.split("\n"):
parts = line.split(":", 2)
if len(parts) >= 3:
try:
line_num = int(parts[1])
except ValueError:
continue
matches.append(
GrepMatch(path=parts[0], line_number=line_num, line=parts[2]))
return matches
def io_to_file_infos(io: IOResult) -> list[FileInfo]:
stdout = decode(io.stdout).strip()
if not stdout:
return []
infos: list[FileInfo] = []
for entry in stdout.split("\n"):
entry = entry.strip()
if not entry:
continue
is_dir = entry.endswith("/")
clean = entry.rstrip("/")
name = clean.rsplit("/", 1)[-1] if "/" in clean else clean
infos.append(FileInfo(name=name, path=clean, is_dir=is_dir, size=None))
return infos
+247
View File
@@ -0,0 +1,247 @@
# ========= 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 shlex
from pydantic_ai import BinaryContent, ToolReturn
from pydantic_ai_backends.protocol import SandboxProtocol
from pydantic_ai_backends.types import (EditResult, ExecuteResponse, FileInfo,
GrepMatch, WriteResult)
from mirage.agents.pydantic_ai._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.core.filetype.pdf import pages_to_images
from mirage.io.types import IOResult
from mirage.types import DEFAULT_SESSION_ID
from mirage.workspace.workspace import Workspace
class PydanticAIWorkspace(SandboxProtocol):
"""Pydantic AI backend backed by a Mirage Workspace.
File operations (read, write, edit, ls) 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 = DEFAULT_SESSION_ID,
) -> None:
self._ws = workspace
self._id = sandbox_id
self._session_id = session_id
def _run(self, coro):
return run_async_from_sync(coro)
@property
def id(self) -> str:
return self._id
async def _exec(self, command: str) -> IOResult:
return await self._ws.execute(command, session_id=self._session_id)
def _read_bytes(self, path: str) -> bytes:
return self._run(self._aread_bytes(path))
async def _aread_bytes(self, path: str) -> bytes:
ops = self._ws.ops
return await ops.read(path)
# -- 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_info -------------------------------------------------------
def ls_info(self, path: str) -> list[FileInfo]:
return self._run(self.als_info(path))
async def als_info(self, path: str) -> list[FileInfo]:
io = await self._exec(f"ls {shlex.quote(path)}")
stdout = (io.stdout or b"").decode("utf-8", errors="replace").strip()
if not stdout:
return []
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(name=clean,
path=f"{base}/{clean}",
is_dir=is_dir,
size=None))
return result
# -- read ----------------------------------------------------------
def read(self,
path: str,
offset: int = 0,
limit: int = 2000) -> str | ToolReturn:
return self._run(self.aread(path, offset, limit))
async def aread(self,
path: str,
offset: int = 0,
limit: int = 2000) -> str | ToolReturn:
ops = self._ws.ops
if path.lower().endswith(".pdf"):
return await self._aread_pdf(path)
try:
data = await ops.read(path)
except (FileNotFoundError, ValueError) as exc:
return f"Error: {exc}"
text = data.decode("utf-8", errors="replace")
lines = text.splitlines(keepends=True)
sliced = lines[offset:offset + limit]
numbered = []
for i, line in enumerate(sliced, start=offset + 1):
numbered.append(f"{i:>6}\t{line}")
return "".join(numbered)
async def _aread_pdf(self, path: str) -> str | ToolReturn:
ops = self._ws.ops
try:
raw = await ops.read(path)
except (FileNotFoundError, ValueError) as exc:
return f"Error: {exc}"
images = pages_to_images(raw)
filename = path.rsplit("/", 1)[-1]
content: list[str | BinaryContent] = []
for page_num, png_bytes in images:
content.append(f"Page {page_num}:")
content.append(
BinaryContent(data=png_bytes, media_type="image/png"))
return ToolReturn(
return_value=f"PDF: {filename} ({len(images)} pages)",
content=content,
)
# -- write ---------------------------------------------------------
def write(self, path: str, content: str | bytes) -> WriteResult:
return self._run(self.awrite(path, content))
async def awrite(self, path: str, content: str | bytes) -> WriteResult:
ops = self._ws.ops
try:
await ops.stat(path)
return WriteResult(error=f"Error: file '{path}' already exists")
except (FileNotFoundError, ValueError):
pass
parent = "/".join(path.rstrip("/").split("/")[:-1]) or "/"
try:
await ops.mkdir(parent)
except (FileExistsError, ValueError):
pass
data = content.encode("utf-8") if isinstance(content, str) else content
await ops.write(path, data)
return WriteResult(path=path)
# -- edit ----------------------------------------------------------
def edit(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
return self._run(self.aedit(path, old_string, new_string, replace_all))
async def aedit(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
ops = self._ws.ops
try:
data = await ops.read(path)
except (FileNotFoundError, ValueError):
return EditResult(error=f"Error: 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(path, new_content.encode("utf-8"))
return EditResult(path=path, occurrences=count if replace_all else 1)
# -- grep_raw ------------------------------------------------------
def grep_raw(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
ignore_hidden: bool = True,
) -> list[GrepMatch] | str:
return self._run(self.agrep_raw(pattern, path, glob, ignore_hidden))
async def agrep_raw(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
ignore_hidden: bool = True,
) -> list[GrepMatch] | str:
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))
return io_to_grep_matches(io)
# -- glob_info -----------------------------------------------------
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
return self._run(self.aglob_info(pattern, path))
async def aglob_info(self,
pattern: str,
path: str = "/") -> list[FileInfo]:
name = pattern.split("/")[-1] if "/" in pattern else pattern
io = await self._exec(
f"find {shlex.quote(path)} -name {shlex.quote(name)}")
return io_to_file_infos(io)
@@ -0,0 +1,17 @@
# ========= 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. =========
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]