forge-agent-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge_agent_runtime-0.1.0.dist-info/METADATA +364 -0
- forge_agent_runtime-0.1.0.dist-info/RECORD +25 -0
- forge_agent_runtime-0.1.0.dist-info/WHEEL +4 -0
- forge_agent_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- forge_agent_runtime-0.1.0.dist-info/licenses/LICENSE +17 -0
- forge_runtime/__init__.py +32 -0
- forge_runtime/api.py +139 -0
- forge_runtime/cli.py +400 -0
- forge_runtime/config.py +41 -0
- forge_runtime/evaluation.py +22 -0
- forge_runtime/graph.py +85 -0
- forge_runtime/models.py +137 -0
- forge_runtime/orchestrator.py +289 -0
- forge_runtime/providers/__init__.py +1 -0
- forge_runtime/providers/anthropic_provider.py +256 -0
- forge_runtime/providers/base.py +10 -0
- forge_runtime/providers/factory.py +46 -0
- forge_runtime/providers/mock.py +24 -0
- forge_runtime/providers/openai_provider.py +264 -0
- forge_runtime/registry.py +245 -0
- forge_runtime/sandbox.py +872 -0
- forge_runtime/sandbox_assets/Dockerfile +51 -0
- forge_runtime/sandbox_assets/__init__.py +1 -0
- forge_runtime/storage.py +82 -0
- forge_runtime/workspace.py +280 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
ENV DEBIAN_FRONTEND=noninteractive \
|
|
4
|
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
5
|
+
PYTHONDONTWRITEBYTECODE=1 \
|
|
6
|
+
PYTHONUNBUFFERED=1
|
|
7
|
+
|
|
8
|
+
RUN apt-get update \
|
|
9
|
+
&& apt-get install -y --no-install-recommends \
|
|
10
|
+
bash \
|
|
11
|
+
build-essential \
|
|
12
|
+
ca-certificates \
|
|
13
|
+
coreutils \
|
|
14
|
+
curl \
|
|
15
|
+
git \
|
|
16
|
+
make \
|
|
17
|
+
nodejs \
|
|
18
|
+
npm \
|
|
19
|
+
tar \
|
|
20
|
+
wget \
|
|
21
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
22
|
+
|
|
23
|
+
# Keep the in-container toolchain aligned with the supported project ranges.
|
|
24
|
+
RUN python -m pip install --no-cache-dir \
|
|
25
|
+
"pytest>=8,<9" \
|
|
26
|
+
"pytest-asyncio>=0.23,<2" \
|
|
27
|
+
"pytest-cov>=5,<8" \
|
|
28
|
+
"ruff>=0.5" \
|
|
29
|
+
"mypy>=1.10" \
|
|
30
|
+
"coverage>=7,<8" \
|
|
31
|
+
"uv" \
|
|
32
|
+
"pydantic>=2.7,<3" \
|
|
33
|
+
"pydantic-settings>=2.2,<3" \
|
|
34
|
+
"httpx>=0.27,<1" \
|
|
35
|
+
"typer>=0.12,<1" \
|
|
36
|
+
"rich>=13.7,<15" \
|
|
37
|
+
"fastapi>=0.115,<1" \
|
|
38
|
+
"uvicorn[standard]>=0.30,<1" \
|
|
39
|
+
"agentforge-toolkit==1.1.0"
|
|
40
|
+
|
|
41
|
+
ENV HOME=/tmp \
|
|
42
|
+
TMPDIR=/tmp \
|
|
43
|
+
PATH=/usr/local/bin:/usr/bin:/bin
|
|
44
|
+
|
|
45
|
+
RUN mkdir -p /opt/forge-node_modules
|
|
46
|
+
|
|
47
|
+
WORKDIR /workspace
|
|
48
|
+
|
|
49
|
+
# The runtime overrides UID/GID and drops Linux capabilities; Docker controls bridge/offline networking,
|
|
50
|
+
# and transfers a sanitized snapshot to bounded tmpfs (not a host project bind mount).
|
|
51
|
+
CMD ["python", "--version"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled Docker build context for the Forge Runtime sandbox."""
|
forge_runtime/storage.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""SQLite persistence for local development and single-process execution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from forge_runtime.models import RunRecord, RuntimeEvent, TaskRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SQLiteStore:
|
|
13
|
+
def __init__(self, database_url: str) -> None:
|
|
14
|
+
prefix = "sqlite:///"
|
|
15
|
+
if not database_url.startswith(prefix):
|
|
16
|
+
raise ValueError("Initial storage backend supports sqlite:/// URLs only.")
|
|
17
|
+
raw = Path(database_url[len(prefix):])
|
|
18
|
+
self.path = raw if raw.is_absolute() else (Path.cwd() / raw).resolve()
|
|
19
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
self._initialize()
|
|
21
|
+
|
|
22
|
+
def _connect(self) -> sqlite3.Connection:
|
|
23
|
+
db = sqlite3.connect(self.path, timeout=30)
|
|
24
|
+
db.row_factory = sqlite3.Row
|
|
25
|
+
return db
|
|
26
|
+
|
|
27
|
+
def _initialize(self) -> None:
|
|
28
|
+
with self._connect() as db:
|
|
29
|
+
db.executescript("""
|
|
30
|
+
PRAGMA journal_mode=WAL;
|
|
31
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
32
|
+
id TEXT PRIMARY KEY, goal TEXT NOT NULL, status TEXT NOT NULL,
|
|
33
|
+
plan_json TEXT, summary TEXT, created_at TEXT NOT NULL,
|
|
34
|
+
started_at TEXT, finished_at TEXT);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
36
|
+
id TEXT PRIMARY KEY, run_id TEXT NOT NULL, title TEXT NOT NULL,
|
|
37
|
+
agent TEXT NOT NULL, status TEXT NOT NULL, attempt INTEGER NOT NULL,
|
|
38
|
+
output TEXT, error TEXT, started_at TEXT, finished_at TEXT,
|
|
39
|
+
FOREIGN KEY(run_id) REFERENCES runs(id));
|
|
40
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
41
|
+
id TEXT PRIMARY KEY, run_id TEXT NOT NULL, task_id TEXT,
|
|
42
|
+
level TEXT NOT NULL, event_type TEXT NOT NULL, message TEXT NOT NULL,
|
|
43
|
+
data_json TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
44
|
+
FOREIGN KEY(run_id) REFERENCES runs(id));
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_run ON tasks(run_id);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at);
|
|
47
|
+
""")
|
|
48
|
+
|
|
49
|
+
def save_run(self, run: RunRecord) -> None:
|
|
50
|
+
with self._connect() as db:
|
|
51
|
+
db.execute("INSERT OR REPLACE INTO runs VALUES (?,?,?,?,?,?,?,?)", (
|
|
52
|
+
run.id, run.goal, run.status.value,
|
|
53
|
+
run.plan.model_dump_json() if run.plan else None, run.summary,
|
|
54
|
+
run.created_at.isoformat(), run.started_at.isoformat() if run.started_at else None,
|
|
55
|
+
run.finished_at.isoformat() if run.finished_at else None))
|
|
56
|
+
|
|
57
|
+
def save_task(self, task: TaskRecord) -> None:
|
|
58
|
+
with self._connect() as db:
|
|
59
|
+
db.execute("INSERT OR REPLACE INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?)", (
|
|
60
|
+
task.id, task.run_id, task.title, task.agent, task.status.value, task.attempt,
|
|
61
|
+
task.output, task.error, task.started_at.isoformat() if task.started_at else None,
|
|
62
|
+
task.finished_at.isoformat() if task.finished_at else None))
|
|
63
|
+
|
|
64
|
+
def add_event(self, event: RuntimeEvent) -> None:
|
|
65
|
+
with self._connect() as db:
|
|
66
|
+
db.execute("INSERT INTO events VALUES (?,?,?,?,?,?,?,?)", (
|
|
67
|
+
event.id, event.run_id, event.task_id, event.level.value, event.event_type,
|
|
68
|
+
event.message, json.dumps(event.data, default=str), event.created_at.isoformat()))
|
|
69
|
+
|
|
70
|
+
def get_run(self, run_id: str) -> dict[str, Any] | None:
|
|
71
|
+
with self._connect() as db:
|
|
72
|
+
row = db.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
|
|
73
|
+
if row is None:
|
|
74
|
+
return None
|
|
75
|
+
result = dict(row)
|
|
76
|
+
result["tasks"] = [dict(x) for x in db.execute("SELECT * FROM tasks WHERE run_id=? ORDER BY rowid", (run_id,))]
|
|
77
|
+
result["events"] = [dict(x) for x in db.execute("SELECT * FROM events WHERE run_id=? ORDER BY created_at", (run_id,))]
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def list_runs(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
81
|
+
with self._connect() as db:
|
|
82
|
+
return [dict(row) for row in db.execute("SELECT id,goal,status,created_at,finished_at FROM runs ORDER BY created_at DESC LIMIT ?", (limit,))]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Capability-scoped file tools for LLM coding agents.
|
|
2
|
+
|
|
3
|
+
All paths are confined to one configured workspace. There is deliberately no shell,
|
|
4
|
+
process execution, network access, deletion, or arbitrary MCP execution in this module.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkspaceError(RuntimeError):
|
|
15
|
+
"""A workspace operation is invalid, unavailable, or not authorized."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WorkspaceTools:
|
|
19
|
+
EXCLUDED_DIRS = {
|
|
20
|
+
".git", ".venv", "venv", "node_modules", "__pycache__", ".next",
|
|
21
|
+
".dart_tool", ".idea", ".pytest_cache", ".mypy_cache", ".ruff_cache",
|
|
22
|
+
".forge", ".aws", ".ssh", ".secrets", ".terraform",
|
|
23
|
+
"dist", "build", "coverage", "htmlcov",
|
|
24
|
+
}
|
|
25
|
+
SENSITIVE_FILE_NAMES = {
|
|
26
|
+
".env", ".npmrc", ".pypirc", ".netrc", ".git-credentials",
|
|
27
|
+
"id_rsa", "id_ed25519", "credentials", "credentials.json",
|
|
28
|
+
"service-account.json", "secrets.json", "secret.json",
|
|
29
|
+
}
|
|
30
|
+
SENSITIVE_SUFFIXES = {".pem", ".key", ".p12", ".pfx"}
|
|
31
|
+
MAX_READ_BYTES = 256_000
|
|
32
|
+
MAX_WRITE_BYTES = 256_000
|
|
33
|
+
MAX_SEARCH_FILES = 1_000
|
|
34
|
+
MAX_SEARCH_RESULTS = 40
|
|
35
|
+
MAX_LIST_RESULTS = 250
|
|
36
|
+
|
|
37
|
+
def __init__(self, root: Path | str = ".") -> None:
|
|
38
|
+
self.root = Path(root).expanduser().resolve()
|
|
39
|
+
if not self.root.exists() or not self.root.is_dir():
|
|
40
|
+
raise WorkspaceError(f"Workspace root is not an existing directory: {self.root}")
|
|
41
|
+
|
|
42
|
+
def _resolve(self, value: str, *, allow_root: bool = False) -> Path:
|
|
43
|
+
if not isinstance(value, str) or not value.strip():
|
|
44
|
+
raise WorkspaceError("A non-empty relative workspace path is required.")
|
|
45
|
+
normalized = value.replace("\\", "/")
|
|
46
|
+
pure = PurePosixPath(normalized)
|
|
47
|
+
if pure.is_absolute() or any(part == ".." for part in pure.parts):
|
|
48
|
+
raise WorkspaceError("Absolute paths and '..' traversal are not allowed.")
|
|
49
|
+
if pure.parts and ":" in pure.parts[0]:
|
|
50
|
+
raise WorkspaceError("Drive-qualified paths are not allowed.")
|
|
51
|
+
if any(part in self.EXCLUDED_DIRS for part in pure.parts):
|
|
52
|
+
raise WorkspaceError("This workspace path is excluded from agent access.")
|
|
53
|
+
if not pure.parts or pure.as_posix() == ".":
|
|
54
|
+
if allow_root:
|
|
55
|
+
return self.root
|
|
56
|
+
raise WorkspaceError("A file path is required.")
|
|
57
|
+
candidate = (self.root / Path(*pure.parts)).resolve()
|
|
58
|
+
try:
|
|
59
|
+
candidate.relative_to(self.root)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise WorkspaceError("Resolved path escapes the configured workspace.") from exc
|
|
62
|
+
return candidate
|
|
63
|
+
|
|
64
|
+
def _is_sensitive_path(self, path: Path) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
relative = path.relative_to(self.root)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return True
|
|
69
|
+
parts = tuple(part.lower() for part in relative.parts)
|
|
70
|
+
if any(part in self.EXCLUDED_DIRS for part in parts):
|
|
71
|
+
return True
|
|
72
|
+
if not parts:
|
|
73
|
+
return False
|
|
74
|
+
name = parts[-1]
|
|
75
|
+
if name in self.SENSITIVE_FILE_NAMES:
|
|
76
|
+
return True
|
|
77
|
+
if name.startswith(".env.") and name != ".env.example":
|
|
78
|
+
return True
|
|
79
|
+
return Path(name).suffix.lower() in self.SENSITIVE_SUFFIXES
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _display(path: Path, root: Path) -> str:
|
|
83
|
+
return path.relative_to(root).as_posix() or "."
|
|
84
|
+
|
|
85
|
+
def list_files(self, directory: str = ".") -> str:
|
|
86
|
+
target = self._resolve(directory, allow_root=True)
|
|
87
|
+
if not target.exists() or not target.is_dir():
|
|
88
|
+
raise WorkspaceError(f"Directory does not exist: {directory}")
|
|
89
|
+
entries: list[str] = []
|
|
90
|
+
for path in sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
|
|
91
|
+
if path.name in self.EXCLUDED_DIRS or path.is_symlink() or self._is_sensitive_path(path):
|
|
92
|
+
continue
|
|
93
|
+
suffix = "/" if path.is_dir() else ""
|
|
94
|
+
entries.append(self._display(path, self.root) + suffix)
|
|
95
|
+
if len(entries) >= self.MAX_LIST_RESULTS:
|
|
96
|
+
entries.append("... result limit reached")
|
|
97
|
+
break
|
|
98
|
+
return "\n".join(entries) if entries else "(empty directory)"
|
|
99
|
+
|
|
100
|
+
def read_file(self, path: str) -> str:
|
|
101
|
+
target = self._resolve(path)
|
|
102
|
+
if self._is_sensitive_path(target):
|
|
103
|
+
raise WorkspaceError("Secret or credential files are protected from agent reads.")
|
|
104
|
+
if target.is_symlink() or not target.is_file():
|
|
105
|
+
raise WorkspaceError(f"File does not exist or is not a regular file: {path}")
|
|
106
|
+
size = target.stat().st_size
|
|
107
|
+
if size > self.MAX_READ_BYTES:
|
|
108
|
+
raise WorkspaceError(
|
|
109
|
+
f"File is {size} bytes; maximum supported read is {self.MAX_READ_BYTES} bytes."
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
return target.read_text(encoding="utf-8")
|
|
113
|
+
except UnicodeDecodeError as exc:
|
|
114
|
+
raise WorkspaceError("Only UTF-8 text files can be read by this tool.") from exc
|
|
115
|
+
|
|
116
|
+
def search_files(self, query: str, directory: str = ".") -> str:
|
|
117
|
+
if not isinstance(query, str) or not query.strip():
|
|
118
|
+
raise WorkspaceError("Search query cannot be empty.")
|
|
119
|
+
target = self._resolve(directory, allow_root=True)
|
|
120
|
+
if not target.exists() or not target.is_dir():
|
|
121
|
+
raise WorkspaceError(f"Directory does not exist: {directory}")
|
|
122
|
+
needle = query.casefold()
|
|
123
|
+
matches: list[str] = []
|
|
124
|
+
scanned = 0
|
|
125
|
+
for base, dirs, files in os.walk(target, followlinks=False):
|
|
126
|
+
dirs[:] = [
|
|
127
|
+
name for name in dirs
|
|
128
|
+
if name not in self.EXCLUDED_DIRS and not (Path(base) / name).is_symlink()
|
|
129
|
+
]
|
|
130
|
+
for filename in files:
|
|
131
|
+
file_path = Path(base) / filename
|
|
132
|
+
if file_path.is_symlink() or self._is_sensitive_path(file_path):
|
|
133
|
+
continue
|
|
134
|
+
scanned += 1
|
|
135
|
+
if scanned > self.MAX_SEARCH_FILES:
|
|
136
|
+
matches.append("... search file limit reached")
|
|
137
|
+
return "\n".join(matches) if matches else "No matches."
|
|
138
|
+
try:
|
|
139
|
+
if file_path.stat().st_size > 512_000:
|
|
140
|
+
continue
|
|
141
|
+
text = file_path.read_text(encoding="utf-8")
|
|
142
|
+
except (OSError, UnicodeDecodeError):
|
|
143
|
+
continue
|
|
144
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
145
|
+
if needle in line.casefold():
|
|
146
|
+
matches.append(
|
|
147
|
+
f"{self._display(file_path, self.root)}:{line_number}: {line[:300]}"
|
|
148
|
+
)
|
|
149
|
+
if len(matches) >= self.MAX_SEARCH_RESULTS:
|
|
150
|
+
matches.append("... result limit reached")
|
|
151
|
+
return "\n".join(matches)
|
|
152
|
+
return "\n".join(matches) if matches else "No matches."
|
|
153
|
+
|
|
154
|
+
def write_file(self, path: str, content: str, *, writable: bool) -> str:
|
|
155
|
+
if not writable:
|
|
156
|
+
raise WorkspaceError(
|
|
157
|
+
"This AgentForge role is read-only. File writes are not permitted."
|
|
158
|
+
)
|
|
159
|
+
target = self._resolve(path)
|
|
160
|
+
if target.is_symlink():
|
|
161
|
+
raise WorkspaceError("Writing through symbolic links is not permitted.")
|
|
162
|
+
if self._is_sensitive_path(target):
|
|
163
|
+
raise WorkspaceError("Secret or credential files are protected from agent writes.")
|
|
164
|
+
if not isinstance(content, str):
|
|
165
|
+
raise WorkspaceError("File content must be text.")
|
|
166
|
+
encoded = content.encode("utf-8")
|
|
167
|
+
if len(encoded) > self.MAX_WRITE_BYTES:
|
|
168
|
+
raise WorkspaceError(
|
|
169
|
+
f"Write is {len(encoded)} bytes; maximum supported write is "
|
|
170
|
+
f"{self.MAX_WRITE_BYTES} bytes."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
# Re-resolve after directory creation to catch symlink swaps in parent paths.
|
|
175
|
+
resolved_parent = target.parent.resolve()
|
|
176
|
+
try:
|
|
177
|
+
resolved_parent.relative_to(self.root)
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
raise WorkspaceError("Target parent escapes the configured workspace.") from exc
|
|
180
|
+
resolved_target = resolved_parent / target.name
|
|
181
|
+
if self._is_sensitive_path(resolved_target):
|
|
182
|
+
raise WorkspaceError("Secret or credential files are protected from agent writes.")
|
|
183
|
+
if resolved_target.exists() and resolved_target.is_symlink():
|
|
184
|
+
raise WorkspaceError("Writing through symbolic links is not permitted.")
|
|
185
|
+
|
|
186
|
+
temporary_name: str | None = None
|
|
187
|
+
try:
|
|
188
|
+
with tempfile.NamedTemporaryFile(
|
|
189
|
+
mode="wb", prefix=".forge-write-", dir=resolved_parent, delete=False
|
|
190
|
+
) as temporary:
|
|
191
|
+
temporary.write(encoded)
|
|
192
|
+
temporary.flush()
|
|
193
|
+
os.fsync(temporary.fileno())
|
|
194
|
+
temporary_name = temporary.name
|
|
195
|
+
os.replace(temporary_name, resolved_target)
|
|
196
|
+
finally:
|
|
197
|
+
if temporary_name and os.path.exists(temporary_name):
|
|
198
|
+
os.unlink(temporary_name)
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
f"Wrote {self._display(resolved_target, self.root)} "
|
|
202
|
+
f"({len(encoded)} UTF-8 bytes)."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def tool_schemas(self, *, writable: bool) -> list[dict[str, Any]]:
|
|
206
|
+
tools = [
|
|
207
|
+
{
|
|
208
|
+
"name": "workspace_list_files",
|
|
209
|
+
"description": "List files and directories inside the assigned project workspace.",
|
|
210
|
+
"parameters": {
|
|
211
|
+
"type": "object",
|
|
212
|
+
"properties": {"directory": {"type": "string", "default": "."}},
|
|
213
|
+
"additionalProperties": False,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"name": "workspace_read_file",
|
|
218
|
+
"description": "Read a UTF-8 text file inside the assigned project workspace.",
|
|
219
|
+
"parameters": {
|
|
220
|
+
"type": "object",
|
|
221
|
+
"properties": {"path": {"type": "string"}},
|
|
222
|
+
"required": ["path"],
|
|
223
|
+
"additionalProperties": False,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"name": "workspace_search_files",
|
|
228
|
+
"description": "Search text in project files and return matching lines with paths.",
|
|
229
|
+
"parameters": {
|
|
230
|
+
"type": "object",
|
|
231
|
+
"properties": {
|
|
232
|
+
"query": {"type": "string"},
|
|
233
|
+
"directory": {"type": "string", "default": "."},
|
|
234
|
+
},
|
|
235
|
+
"required": ["query"],
|
|
236
|
+
"additionalProperties": False,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
]
|
|
240
|
+
if writable:
|
|
241
|
+
tools.append(
|
|
242
|
+
{
|
|
243
|
+
"name": "workspace_write_file",
|
|
244
|
+
"description": (
|
|
245
|
+
"Create or replace a UTF-8 text file inside the project workspace. "
|
|
246
|
+
"Only use this for files required by the assigned task."
|
|
247
|
+
),
|
|
248
|
+
"parameters": {
|
|
249
|
+
"type": "object",
|
|
250
|
+
"properties": {
|
|
251
|
+
"path": {"type": "string"},
|
|
252
|
+
"content": {"type": "string"},
|
|
253
|
+
},
|
|
254
|
+
"required": ["path", "content"],
|
|
255
|
+
"additionalProperties": False,
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
return tools
|
|
260
|
+
|
|
261
|
+
def execute_tool(self, name: str, arguments: dict[str, Any], *, writable: bool) -> str:
|
|
262
|
+
try:
|
|
263
|
+
if name == "workspace_list_files":
|
|
264
|
+
return self.list_files(str(arguments.get("directory", ".")))
|
|
265
|
+
if name == "workspace_read_file":
|
|
266
|
+
return self.read_file(str(arguments.get("path", "")))
|
|
267
|
+
if name == "workspace_search_files":
|
|
268
|
+
return self.search_files(
|
|
269
|
+
str(arguments.get("query", "")),
|
|
270
|
+
str(arguments.get("directory", ".")),
|
|
271
|
+
)
|
|
272
|
+
if name == "workspace_write_file":
|
|
273
|
+
return self.write_file(
|
|
274
|
+
str(arguments.get("path", "")),
|
|
275
|
+
str(arguments.get("content", "")),
|
|
276
|
+
writable=writable,
|
|
277
|
+
)
|
|
278
|
+
return f"Tool error: unsupported tool name {name!r}."
|
|
279
|
+
except WorkspaceError as exc:
|
|
280
|
+
return f"Tool error: {exc}"
|