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
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:
@@ -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. =========
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,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)
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user