sid-harness 0.3.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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Minimal MCP (Model Context Protocol) client over stdio and HTTP (SUB-56).
|
|
2
|
+
|
|
3
|
+
JSON-RPC 2.0. The stdio transport uses the spec's newline-delimited framing
|
|
4
|
+
(one JSON-RPC message per line); the HTTP transport POSTs a single JSON-RPC
|
|
5
|
+
message and reads the JSON response. Standard library only.
|
|
6
|
+
|
|
7
|
+
This is a client, not an engine: the tools it exposes are registered into the
|
|
8
|
+
SID ``ToolRegistry`` (``registry.py``) and settle through the same loop.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
DEFAULT_PROTOCOL_VERSION = "2024-11-05"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MCPError(RuntimeError):
|
|
23
|
+
"""An MCP call could not be completed (transport, protocol, or server error)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StdioTransport:
|
|
27
|
+
"""Newline-delimited JSON-RPC over a child process's stdin/stdout."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
command: str,
|
|
32
|
+
args: list[str] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
cwd: str | None = None,
|
|
35
|
+
env: dict[str, str] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
try:
|
|
38
|
+
self._proc = subprocess.Popen(
|
|
39
|
+
[command, *(args or [])],
|
|
40
|
+
stdin=subprocess.PIPE,
|
|
41
|
+
stdout=subprocess.PIPE,
|
|
42
|
+
stderr=subprocess.PIPE,
|
|
43
|
+
text=True,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
bufsize=1,
|
|
46
|
+
cwd=cwd,
|
|
47
|
+
env=env,
|
|
48
|
+
)
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
raise MCPError(f"could not start MCP stdio server {command!r}: {exc}") from exc
|
|
51
|
+
|
|
52
|
+
def request(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
self._write(message)
|
|
54
|
+
assert self._proc.stdout is not None
|
|
55
|
+
while True:
|
|
56
|
+
line = self._proc.stdout.readline()
|
|
57
|
+
if not line:
|
|
58
|
+
raise MCPError("MCP stdio server closed the stream before replying")
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if not line:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
payload = json.loads(line)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
continue
|
|
66
|
+
if isinstance(payload, dict) and payload.get("id") == message.get("id"):
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
def notify(self, message: dict[str, Any]) -> None:
|
|
70
|
+
self._write(message)
|
|
71
|
+
|
|
72
|
+
def _write(self, message: dict[str, Any]) -> None:
|
|
73
|
+
if self._proc.stdin is None:
|
|
74
|
+
raise MCPError("MCP stdio server stdin is unavailable")
|
|
75
|
+
self._proc.stdin.write(json.dumps(message) + "\n")
|
|
76
|
+
self._proc.stdin.flush()
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
if self._proc.stdin is not None:
|
|
80
|
+
try:
|
|
81
|
+
self._proc.stdin.close()
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
try:
|
|
85
|
+
self._proc.terminate()
|
|
86
|
+
self._proc.wait(timeout=5)
|
|
87
|
+
except Exception: # pragma: no cover - best-effort teardown
|
|
88
|
+
try:
|
|
89
|
+
self._proc.kill()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HTTPTransport:
|
|
95
|
+
"""JSON-RPC over HTTP POST (one request, one JSON response)."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
url: str,
|
|
100
|
+
*,
|
|
101
|
+
headers: dict[str, str] | None = None,
|
|
102
|
+
timeout: float = 30.0,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.url = url
|
|
105
|
+
self.headers = {"Content-Type": "application/json", **(headers or {})}
|
|
106
|
+
self.timeout = timeout
|
|
107
|
+
|
|
108
|
+
def request(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
109
|
+
data = json.dumps(message).encode("utf-8")
|
|
110
|
+
req = urllib.request.Request(
|
|
111
|
+
self.url, data=data, headers=self.headers, method="POST"
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
115
|
+
body = response.read().decode("utf-8")
|
|
116
|
+
except urllib.error.HTTPError as exc:
|
|
117
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
118
|
+
raise MCPError(f"MCP HTTP {exc.code}: {detail[:300]}") from exc
|
|
119
|
+
except urllib.error.URLError as exc:
|
|
120
|
+
raise MCPError(f"MCP endpoint unreachable at {self.url}: {exc.reason}") from exc
|
|
121
|
+
try:
|
|
122
|
+
parsed = json.loads(body)
|
|
123
|
+
except json.JSONDecodeError as exc:
|
|
124
|
+
raise MCPError("MCP endpoint returned a non-JSON response") from exc
|
|
125
|
+
if not isinstance(parsed, dict):
|
|
126
|
+
raise MCPError("MCP endpoint returned an unexpected response shape")
|
|
127
|
+
return parsed
|
|
128
|
+
|
|
129
|
+
def notify(self, message: dict[str, Any]) -> None:
|
|
130
|
+
data = json.dumps(message).encode("utf-8")
|
|
131
|
+
req = urllib.request.Request(
|
|
132
|
+
self.url, data=data, headers=self.headers, method="POST"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(req, timeout=self.timeout):
|
|
136
|
+
pass
|
|
137
|
+
except urllib.error.HTTPError as exc:
|
|
138
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
139
|
+
raise MCPError(f"MCP HTTP {exc.code}: {detail[:300]}") from exc
|
|
140
|
+
except urllib.error.URLError as exc:
|
|
141
|
+
raise MCPError(f"MCP endpoint unreachable at {self.url}: {exc.reason}") from exc
|
|
142
|
+
|
|
143
|
+
def close(self) -> None: # nothing persistent to release
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class MCPClient:
|
|
148
|
+
"""A small MCP client: initialize, list tools, call a tool."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
transport: Any,
|
|
153
|
+
*,
|
|
154
|
+
client_name: str = "sid-harness",
|
|
155
|
+
client_version: str = "0.1.0",
|
|
156
|
+
protocol_version: str = DEFAULT_PROTOCOL_VERSION,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._transport = transport
|
|
159
|
+
self._client_name = client_name
|
|
160
|
+
self._client_version = client_version
|
|
161
|
+
self._protocol_version = protocol_version
|
|
162
|
+
self._request_id = 0
|
|
163
|
+
self.initialized = False
|
|
164
|
+
|
|
165
|
+
def _next_id(self) -> int:
|
|
166
|
+
self._request_id += 1
|
|
167
|
+
return self._request_id
|
|
168
|
+
|
|
169
|
+
def _request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
170
|
+
message: dict[str, Any] = {
|
|
171
|
+
"jsonrpc": "2.0",
|
|
172
|
+
"id": self._next_id(),
|
|
173
|
+
"method": method,
|
|
174
|
+
}
|
|
175
|
+
if params is not None:
|
|
176
|
+
message["params"] = params
|
|
177
|
+
response = self._transport.request(message)
|
|
178
|
+
error = response.get("error")
|
|
179
|
+
if error:
|
|
180
|
+
raise MCPError(f"MCP {method} failed: {error}")
|
|
181
|
+
result = response.get("result")
|
|
182
|
+
return result if isinstance(result, dict) else {}
|
|
183
|
+
|
|
184
|
+
def _notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
185
|
+
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
186
|
+
if params is not None:
|
|
187
|
+
message["params"] = params
|
|
188
|
+
self._transport.notify(message)
|
|
189
|
+
|
|
190
|
+
def initialize(self) -> dict[str, Any]:
|
|
191
|
+
result = self._request(
|
|
192
|
+
"initialize",
|
|
193
|
+
{
|
|
194
|
+
"protocolVersion": self._protocol_version,
|
|
195
|
+
"capabilities": {},
|
|
196
|
+
"clientInfo": {"name": self._client_name, "version": self._client_version},
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
self._notify("notifications/initialized")
|
|
200
|
+
self.initialized = True
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
204
|
+
result = self._request("tools/list", {})
|
|
205
|
+
tools = result.get("tools")
|
|
206
|
+
if not isinstance(tools, list):
|
|
207
|
+
raise MCPError("MCP tools/list returned an unexpected shape")
|
|
208
|
+
return tools
|
|
209
|
+
|
|
210
|
+
def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
211
|
+
return self._request("tools/call", {"name": name, "arguments": arguments or {}})
|
|
212
|
+
|
|
213
|
+
def close(self) -> None:
|
|
214
|
+
self._transport.close()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _tool_schema(tool: dict[str, Any], *, exposed_name: str) -> dict[str, Any]:
|
|
218
|
+
return {
|
|
219
|
+
"type": "function",
|
|
220
|
+
"function": {
|
|
221
|
+
"name": exposed_name,
|
|
222
|
+
"description": tool.get("description") or f"MCP tool {exposed_name}",
|
|
223
|
+
"parameters": tool.get("inputSchema")
|
|
224
|
+
or {"type": "object", "properties": {}, "additionalProperties": False},
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def mcp_tool_schemas(
|
|
230
|
+
tools: list[dict[str, Any]], *, prefix: str | None = None
|
|
231
|
+
) -> list[dict[str, Any]]:
|
|
232
|
+
"""Convert MCP tool descriptors to the loop's function-schema shape."""
|
|
233
|
+
schemas: list[dict[str, Any]] = []
|
|
234
|
+
for tool in tools:
|
|
235
|
+
name = str(tool.get("name") or "").strip()
|
|
236
|
+
if not name:
|
|
237
|
+
continue
|
|
238
|
+
exposed = f"{prefix}_{name}" if prefix else name
|
|
239
|
+
schemas.append(_tool_schema(tool, exposed_name=exposed))
|
|
240
|
+
return schemas
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def register_mcp_tools(
|
|
244
|
+
registry: Any,
|
|
245
|
+
client: MCPClient,
|
|
246
|
+
*,
|
|
247
|
+
prefix: str = "mcp",
|
|
248
|
+
mutating: bool = True,
|
|
249
|
+
tools: list[dict[str, Any]] | None = None,
|
|
250
|
+
) -> list[str]:
|
|
251
|
+
"""Register an MCP server's tools into a ``ToolRegistry``.
|
|
252
|
+
|
|
253
|
+
MCP tools default to ``mutating=True``: the harness cannot prove a remote
|
|
254
|
+
tool is read-only, so the plan gate guards them like a workspace edit.
|
|
255
|
+
"""
|
|
256
|
+
listed = tools if tools is not None else client.list_tools()
|
|
257
|
+
registered: list[str] = []
|
|
258
|
+
for tool in listed:
|
|
259
|
+
name = str(tool.get("name") or "").strip()
|
|
260
|
+
if not name:
|
|
261
|
+
continue
|
|
262
|
+
exposed = f"{prefix}_{name}" if prefix else name
|
|
263
|
+
|
|
264
|
+
def handler(arguments: dict[str, Any], _name: str = name) -> dict[str, Any]:
|
|
265
|
+
return client.call_tool(_name, arguments)
|
|
266
|
+
|
|
267
|
+
registry.register(
|
|
268
|
+
_tool_schema(tool, exposed_name=exposed),
|
|
269
|
+
handler,
|
|
270
|
+
source="mcp",
|
|
271
|
+
mutating=mutating,
|
|
272
|
+
)
|
|
273
|
+
registered.append(exposed)
|
|
274
|
+
return registered
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
__all__ = [
|
|
278
|
+
"DEFAULT_PROTOCOL_VERSION",
|
|
279
|
+
"HTTPTransport",
|
|
280
|
+
"MCPClient",
|
|
281
|
+
"MCPError",
|
|
282
|
+
"StdioTransport",
|
|
283
|
+
"mcp_tool_schemas",
|
|
284
|
+
"register_mcp_tools",
|
|
285
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Plan/todo layer for the SID tool loop (SUB-56).
|
|
2
|
+
|
|
3
|
+
The gate emits a plan before edits and refuses workspace edits until that plan
|
|
4
|
+
is approved. ``PlanGate`` is consumed by ``ToolRegistry.dispatch``
|
|
5
|
+
(``registry.py``); ``TodoList`` is a small, honest todo store for the session.
|
|
6
|
+
No vendor is named here and no execution path is added — this only decides
|
|
7
|
+
whether the shared loop's mutating tools may run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
TODO_STATUSES = ("pending", "in_progress", "completed", "cancelled")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PlanError(RuntimeError):
|
|
20
|
+
"""A plan or todo action was invalid."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Todo:
|
|
25
|
+
id: str
|
|
26
|
+
text: str
|
|
27
|
+
status: str = "pending"
|
|
28
|
+
|
|
29
|
+
def as_dict(self) -> dict[str, str]:
|
|
30
|
+
return {"id": self.id, "text": self.text, "status": self.status}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TodoList:
|
|
34
|
+
"""Ordered todos with a bounded status vocabulary."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._items: list[Todo] = []
|
|
38
|
+
self._seq = 0
|
|
39
|
+
|
|
40
|
+
def add(self, text: str) -> Todo:
|
|
41
|
+
if not isinstance(text, str) or not text.strip():
|
|
42
|
+
raise PlanError("todo text must be a non-empty string")
|
|
43
|
+
self._seq += 1
|
|
44
|
+
todo = Todo(id=f"t{self._seq}", text=text.strip())
|
|
45
|
+
self._items.append(todo)
|
|
46
|
+
return todo
|
|
47
|
+
|
|
48
|
+
def update(self, todo_id: str, status: str) -> Todo:
|
|
49
|
+
if status not in TODO_STATUSES:
|
|
50
|
+
raise PlanError(
|
|
51
|
+
f"unknown todo status {status!r}; expected one of {', '.join(TODO_STATUSES)}"
|
|
52
|
+
)
|
|
53
|
+
for todo in self._items:
|
|
54
|
+
if todo.id == todo_id:
|
|
55
|
+
todo.status = status
|
|
56
|
+
return todo
|
|
57
|
+
raise PlanError(f"unknown todo {todo_id!r}")
|
|
58
|
+
|
|
59
|
+
def items(self) -> list[Todo]:
|
|
60
|
+
return list(self._items)
|
|
61
|
+
|
|
62
|
+
def open_items(self) -> list[Todo]:
|
|
63
|
+
return [todo for todo in self._items if todo.status in ("pending", "in_progress")]
|
|
64
|
+
|
|
65
|
+
def as_dicts(self) -> list[dict[str, str]]:
|
|
66
|
+
return [todo.as_dict() for todo in self._items]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PlanGate:
|
|
70
|
+
"""Refuse mutating tool calls until a plan has been emitted and approved."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, *, require_plan: bool = True) -> None:
|
|
73
|
+
self.require_plan = require_plan
|
|
74
|
+
self._plan: list[str] | None = None
|
|
75
|
+
self._approved = False
|
|
76
|
+
self._rejected_reason: str | None = None
|
|
77
|
+
self.todos = TodoList()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def plan(self) -> list[str] | None:
|
|
81
|
+
return list(self._plan) if self._plan is not None else None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def approved(self) -> bool:
|
|
85
|
+
return self._approved
|
|
86
|
+
|
|
87
|
+
def set_plan(self, steps: Iterable[str]) -> dict[str, Any]:
|
|
88
|
+
cleaned = [str(step).strip() for step in steps if str(step).strip()]
|
|
89
|
+
if not cleaned:
|
|
90
|
+
raise PlanError("a plan needs at least one step")
|
|
91
|
+
self._plan = cleaned
|
|
92
|
+
self._approved = False
|
|
93
|
+
self._rejected_reason = None
|
|
94
|
+
return self.snapshot()
|
|
95
|
+
|
|
96
|
+
def approve(self) -> dict[str, Any]:
|
|
97
|
+
if self._plan is None:
|
|
98
|
+
raise PlanError("no plan to approve; call set_plan first")
|
|
99
|
+
self._approved = True
|
|
100
|
+
self._rejected_reason = None
|
|
101
|
+
return self.snapshot()
|
|
102
|
+
|
|
103
|
+
def reject(self, reason: str) -> dict[str, Any]:
|
|
104
|
+
self._approved = False
|
|
105
|
+
self._rejected_reason = reason.strip() or "plan rejected"
|
|
106
|
+
return self.snapshot()
|
|
107
|
+
|
|
108
|
+
def authorize(self, tool_name: str) -> bool:
|
|
109
|
+
if not self.require_plan:
|
|
110
|
+
return True
|
|
111
|
+
return self._approved
|
|
112
|
+
|
|
113
|
+
def refusal(self, tool_name: str) -> str:
|
|
114
|
+
if self._plan is None:
|
|
115
|
+
return (
|
|
116
|
+
f"plan gate: {tool_name} is refused until a plan is emitted and "
|
|
117
|
+
"approved (call set_plan, then approve)."
|
|
118
|
+
)
|
|
119
|
+
if self._rejected_reason:
|
|
120
|
+
return (
|
|
121
|
+
f"plan gate: {tool_name} is refused — the plan was rejected "
|
|
122
|
+
f"({self._rejected_reason})."
|
|
123
|
+
)
|
|
124
|
+
return (
|
|
125
|
+
f"plan gate: {tool_name} is refused until the current plan is "
|
|
126
|
+
"approved (call approve)."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def snapshot(self) -> dict[str, Any]:
|
|
130
|
+
return {
|
|
131
|
+
"plan": self.plan,
|
|
132
|
+
"approved": self._approved,
|
|
133
|
+
"rejected_reason": self._rejected_reason,
|
|
134
|
+
"todos": self.todos.as_dicts(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = ["PlanError", "PlanGate", "Todo", "TodoList", "TODO_STATUSES"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Additive tool registry over the shared SID native loop contract.
|
|
2
|
+
|
|
3
|
+
The loop's tool contract lives in the gateway's ``app.native_agent`` — the four
|
|
4
|
+
base tools, ``_safe_path``, and ``_tool_result``. This module EXTENDS that
|
|
5
|
+
contract; it never forks it:
|
|
6
|
+
|
|
7
|
+
- ``ToolRegistry.schemas()`` returns the base schemas UNCHANGED (identity-equal
|
|
8
|
+
to ``native_agent.TOOLS``) followed by registered extension schemas.
|
|
9
|
+
- ``ToolRegistry.dispatch()`` routes every base tool name through
|
|
10
|
+
``native_agent._tool_result`` — the one implementation — and only handles
|
|
11
|
+
extension names itself. There is no second execution engine and no second
|
|
12
|
+
verify path.
|
|
13
|
+
- A registered name may not shadow a base tool: the safety and execution
|
|
14
|
+
contract has exactly one owner.
|
|
15
|
+
|
|
16
|
+
``PlanGate`` (``plan.py``) plugs into ``dispatch`` to refuse workspace edits
|
|
17
|
+
until a plan is approved.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from sid_harness.gateway import native_agent
|
|
27
|
+
|
|
28
|
+
BASE_TOOL_NAMES: tuple[str, ...] = tuple(
|
|
29
|
+
schema["function"]["name"] for schema in native_agent.TOOLS
|
|
30
|
+
)
|
|
31
|
+
BASE_TOOL_SCHEMAS: tuple[dict[str, Any], ...] = tuple(native_agent.TOOLS)
|
|
32
|
+
# Every base tool that mutates the workspace, sourced from the shared loop so a
|
|
33
|
+
# new mutating tool can never be added without being gated here too.
|
|
34
|
+
MUTATING_BASE_TOOLS = frozenset(
|
|
35
|
+
name for name in BASE_TOOL_NAMES if name in native_agent.MUTATING_TOOL_NAMES
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
ToolHandler = Callable[[dict[str, Any]], Any]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ToolRegistrationError(ValueError):
|
|
42
|
+
"""A tool could not be registered without breaking the shared contract."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class RegisteredTool:
|
|
47
|
+
name: str
|
|
48
|
+
schema: dict[str, Any]
|
|
49
|
+
handler: ToolHandler
|
|
50
|
+
source: str
|
|
51
|
+
mutating: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ToolRegistry:
|
|
55
|
+
"""Base SID tools plus registered extensions, dispatched through one seam."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, gate: Any | None = None) -> None:
|
|
58
|
+
self._gate = gate
|
|
59
|
+
self._tools: dict[str, RegisteredTool] = {}
|
|
60
|
+
|
|
61
|
+
def set_gate(self, gate: Any | None) -> None:
|
|
62
|
+
self._gate = gate
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def gate(self) -> Any | None:
|
|
66
|
+
return self._gate
|
|
67
|
+
|
|
68
|
+
def register(
|
|
69
|
+
self,
|
|
70
|
+
schema: dict[str, Any],
|
|
71
|
+
handler: ToolHandler,
|
|
72
|
+
*,
|
|
73
|
+
source: str = "extension",
|
|
74
|
+
mutating: bool = False,
|
|
75
|
+
) -> RegisteredTool:
|
|
76
|
+
try:
|
|
77
|
+
name = schema["function"]["name"]
|
|
78
|
+
except (KeyError, TypeError) as exc:
|
|
79
|
+
raise ToolRegistrationError(
|
|
80
|
+
"tool schema must be an OpenAI-compatible function schema"
|
|
81
|
+
) from exc
|
|
82
|
+
if not isinstance(name, str) or not name:
|
|
83
|
+
raise ToolRegistrationError("tool schema requires a non-empty function name")
|
|
84
|
+
if name in BASE_TOOL_NAMES:
|
|
85
|
+
raise ToolRegistrationError(
|
|
86
|
+
f"{name!r} shadows a base SID tool; the base contract has one owner"
|
|
87
|
+
)
|
|
88
|
+
if name in self._tools:
|
|
89
|
+
raise ToolRegistrationError(f"tool {name!r} is already registered")
|
|
90
|
+
if not callable(handler):
|
|
91
|
+
raise ToolRegistrationError("tool handler must be callable")
|
|
92
|
+
tool = RegisteredTool(
|
|
93
|
+
name=name,
|
|
94
|
+
schema=schema,
|
|
95
|
+
handler=handler,
|
|
96
|
+
source=source,
|
|
97
|
+
mutating=mutating,
|
|
98
|
+
)
|
|
99
|
+
self._tools[name] = tool
|
|
100
|
+
return tool
|
|
101
|
+
|
|
102
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
103
|
+
"""Base schemas (unchanged) followed by extension schemas."""
|
|
104
|
+
return [*native_agent.TOOLS, *(tool.schema for tool in self._tools.values())]
|
|
105
|
+
|
|
106
|
+
def names(self) -> list[str]:
|
|
107
|
+
return [*BASE_TOOL_NAMES, *self._tools]
|
|
108
|
+
|
|
109
|
+
def extension_names(self) -> list[str]:
|
|
110
|
+
return list(self._tools)
|
|
111
|
+
|
|
112
|
+
def is_mutating(self, name: str) -> bool:
|
|
113
|
+
if name in MUTATING_BASE_TOOLS:
|
|
114
|
+
return True
|
|
115
|
+
tool = self._tools.get(name)
|
|
116
|
+
return bool(tool and tool.mutating)
|
|
117
|
+
|
|
118
|
+
def dispatch(
|
|
119
|
+
self,
|
|
120
|
+
name: str,
|
|
121
|
+
arguments: Any,
|
|
122
|
+
files: dict[str, str],
|
|
123
|
+
run_tests: Any,
|
|
124
|
+
) -> Any:
|
|
125
|
+
"""Route one tool call: base tools to the shared implementation, else here."""
|
|
126
|
+
if name in BASE_TOOL_NAMES:
|
|
127
|
+
if self.is_mutating(name) and not self._authorized(name):
|
|
128
|
+
return {"error": self._refusal(name)}
|
|
129
|
+
return native_agent._tool_result(name, arguments, files, run_tests)
|
|
130
|
+
tool = self._tools.get(name)
|
|
131
|
+
if tool is None:
|
|
132
|
+
return {"error": f"unknown tool: {name}"}
|
|
133
|
+
if tool.mutating and not self._authorized(name):
|
|
134
|
+
return {"error": self._refusal(name)}
|
|
135
|
+
try:
|
|
136
|
+
return tool.handler(arguments if isinstance(arguments, dict) else {})
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
return {"error": f"tool failed: {type(exc).__name__}: {exc}"}
|
|
139
|
+
|
|
140
|
+
def _authorized(self, name: str) -> bool:
|
|
141
|
+
if self._gate is None:
|
|
142
|
+
return True
|
|
143
|
+
return bool(self._gate.authorize(name))
|
|
144
|
+
|
|
145
|
+
def _refusal(self, name: str) -> str:
|
|
146
|
+
if self._gate is None:
|
|
147
|
+
return f"tool {name} is not authorized"
|
|
148
|
+
return str(self._gate.refusal(name))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = [
|
|
152
|
+
"BASE_TOOL_NAMES",
|
|
153
|
+
"BASE_TOOL_SCHEMAS",
|
|
154
|
+
"MUTATING_BASE_TOOLS",
|
|
155
|
+
"RegisteredTool",
|
|
156
|
+
"ToolHandler",
|
|
157
|
+
"ToolRegistrationError",
|
|
158
|
+
"ToolRegistry",
|
|
159
|
+
]
|