gdmcode 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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""WebSocket protocol version constants and compatibility checks.
|
|
2
|
+
|
|
3
|
+
Every WebSocket connection (bridge, remote, IDE, Chrome extension) must begin
|
|
4
|
+
with a 'hello' handshake. The server sends its version and capability set;
|
|
5
|
+
the client checks compatibility before sending commands.
|
|
6
|
+
|
|
7
|
+
Versioning policy:
|
|
8
|
+
- Major version bump = breaking change; both sides must update
|
|
9
|
+
- Minor version bump = additive only; backward-compatible
|
|
10
|
+
- Client major != server major → close with code 4000 + error message
|
|
11
|
+
- Client minor > server minor → log warning, continue (server may lack new features)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PROTOCOL_VERSION",
|
|
19
|
+
"PROTOCOL_MAJOR",
|
|
20
|
+
"PROTOCOL_MINOR",
|
|
21
|
+
"CapabilitySet",
|
|
22
|
+
"is_compatible",
|
|
23
|
+
"make_hello_message",
|
|
24
|
+
"make_version_mismatch_error",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
PROTOCOL_VERSION: str = "1.0"
|
|
28
|
+
PROTOCOL_MAJOR: int = 1
|
|
29
|
+
PROTOCOL_MINOR: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CapabilitySet:
|
|
34
|
+
"""Server capability advertisement sent in the hello message."""
|
|
35
|
+
|
|
36
|
+
dom_actions: bool = True
|
|
37
|
+
screenshot: bool = True
|
|
38
|
+
navigation: bool = True
|
|
39
|
+
mobile: bool = False
|
|
40
|
+
voice: bool = False
|
|
41
|
+
remote: bool = False
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, bool]:
|
|
44
|
+
return {
|
|
45
|
+
"dom_actions": self.dom_actions,
|
|
46
|
+
"screenshot": self.screenshot,
|
|
47
|
+
"navigation": self.navigation,
|
|
48
|
+
"mobile": self.mobile,
|
|
49
|
+
"voice": self.voice,
|
|
50
|
+
"remote": self.remote,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_compatible(client_version: str) -> tuple[bool, str]:
|
|
55
|
+
"""Check if client_version is compatible with this server.
|
|
56
|
+
|
|
57
|
+
Returns (compatible: bool, reason: str).
|
|
58
|
+
- compatible=True, reason="" if versions match exactly
|
|
59
|
+
- compatible=True, reason=warning if minor version mismatch (client ahead)
|
|
60
|
+
- compatible=False, reason=error if major version mismatch or invalid format
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
parts = client_version.split(".")
|
|
64
|
+
client_major = int(parts[0])
|
|
65
|
+
client_minor = int(parts[1]) if len(parts) > 1 else 0
|
|
66
|
+
except (ValueError, IndexError):
|
|
67
|
+
return False, f"Invalid version format: {client_version!r}"
|
|
68
|
+
|
|
69
|
+
if client_major != PROTOCOL_MAJOR:
|
|
70
|
+
return False, (
|
|
71
|
+
f"Client v{client_version} required, got server v{PROTOCOL_VERSION}. "
|
|
72
|
+
"Please update the gdm extension."
|
|
73
|
+
)
|
|
74
|
+
if client_minor > PROTOCOL_MINOR:
|
|
75
|
+
return True, (
|
|
76
|
+
f"Client v{client_version} is ahead of server v{PROTOCOL_VERSION}"
|
|
77
|
+
" — some client features may not be supported"
|
|
78
|
+
)
|
|
79
|
+
return True, ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def make_hello_message(
|
|
83
|
+
server_name: str = "gdm-bridge",
|
|
84
|
+
capabilities: CapabilitySet | None = None,
|
|
85
|
+
) -> dict:
|
|
86
|
+
"""Build the hello message the server sends on WebSocket open."""
|
|
87
|
+
caps = capabilities or CapabilitySet()
|
|
88
|
+
return {
|
|
89
|
+
"type": "hello",
|
|
90
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
91
|
+
"capabilities": caps.to_dict(),
|
|
92
|
+
"server": server_name,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def make_version_mismatch_error(client_version: str) -> dict:
|
|
97
|
+
"""Build the error message sent before closing on major version mismatch."""
|
|
98
|
+
_, reason = is_compatible(client_version)
|
|
99
|
+
return {
|
|
100
|
+
"type": "error",
|
|
101
|
+
"code": "version_mismatch",
|
|
102
|
+
"message": reason,
|
|
103
|
+
}
|
src/session/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Session management — multiplexed I/O and permission bridge."""
|
|
2
|
+
from src.session.input_broker import InputBroker, InputMessage
|
|
3
|
+
from src.session.event_fanout import EventFanout, SessionEvent
|
|
4
|
+
from src.session.permission_bridge import PermissionBridge
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"InputBroker", "InputMessage",
|
|
8
|
+
"EventFanout", "SessionEvent",
|
|
9
|
+
"PermissionBridge",
|
|
10
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""EventFanout — bounded pub/sub event distribution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = ["EventFanout", "SessionEvent"]
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SessionEvent:
|
|
15
|
+
type: str
|
|
16
|
+
payload: Any = None
|
|
17
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
18
|
+
|
|
19
|
+
class EventFanout:
|
|
20
|
+
"""Bounded per-subscriber queues with put_nowait drop (no blocking)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._subscribers: dict[str, queue.Queue[SessionEvent]] = {}
|
|
24
|
+
|
|
25
|
+
def subscribe(self, name: str, maxsize: int = 100) -> queue.Queue[SessionEvent]:
|
|
26
|
+
"""Register a subscriber. Returns the queue to read from."""
|
|
27
|
+
q: queue.Queue[SessionEvent] = queue.Queue(maxsize=maxsize)
|
|
28
|
+
self._subscribers[name] = q
|
|
29
|
+
log.debug("EventFanout: subscribed %s", name)
|
|
30
|
+
return q
|
|
31
|
+
|
|
32
|
+
def publish(self, event: SessionEvent) -> None:
|
|
33
|
+
"""Publish event to all subscribers. put_nowait — drops on full queue."""
|
|
34
|
+
for name, q in list(self._subscribers.items()):
|
|
35
|
+
try:
|
|
36
|
+
q.put_nowait(event)
|
|
37
|
+
except queue.Full:
|
|
38
|
+
log.warning("EventFanout: subscriber %s queue full — event dropped", name)
|
|
39
|
+
|
|
40
|
+
def unsubscribe(self, name: str) -> None:
|
|
41
|
+
"""Remove subscriber."""
|
|
42
|
+
self._subscribers.pop(name, None)
|
|
43
|
+
log.debug("EventFanout: unsubscribed %s", name)
|
|
44
|
+
|
|
45
|
+
def subscriber_count(self) -> int:
|
|
46
|
+
return len(self._subscribers)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""InputBroker — thread-safe multiplexed input queue."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
__all__ = ["InputBroker", "InputMessage"]
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class InputMessage:
|
|
15
|
+
source: str # "terminal" | "remote" | "voice"
|
|
16
|
+
text: str
|
|
17
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
18
|
+
|
|
19
|
+
class InputBroker:
|
|
20
|
+
"""Thread-safe queue that multiplexes terminal + remote + voice input."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, maxsize: int = 0) -> None:
|
|
23
|
+
self._queue: queue.Queue[InputMessage] = queue.Queue(maxsize=maxsize)
|
|
24
|
+
|
|
25
|
+
def put(self, source: str, message: str) -> None:
|
|
26
|
+
"""Enqueue input from any source. Non-blocking."""
|
|
27
|
+
self._queue.put(InputMessage(source=source, text=message))
|
|
28
|
+
log.debug("InputBroker.put source=%s len=%d", source, len(message))
|
|
29
|
+
|
|
30
|
+
def get(self, timeout: float | None = None) -> InputMessage | None:
|
|
31
|
+
"""Blocking dequeue. Returns None on timeout."""
|
|
32
|
+
try:
|
|
33
|
+
return self._queue.get(timeout=timeout)
|
|
34
|
+
except queue.Empty:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def qsize(self) -> int:
|
|
38
|
+
return self._queue.qsize()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""PermissionBridge — sync↔async permission request gateway with UUID tracking."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
__all__ = ["PermissionBridge", "PermissionBridgeConflict"]
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PermissionBridgeConflict(Exception):
|
|
14
|
+
"""Raised when a second request arrives while one is already pending."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _PendingRequest:
|
|
19
|
+
request_id: str
|
|
20
|
+
prompt: str
|
|
21
|
+
event: threading.Event
|
|
22
|
+
result: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermissionBridge:
|
|
26
|
+
"""Bridges sync PermissionContext._prompt_user() to async remote/voice handlers.
|
|
27
|
+
|
|
28
|
+
v1 constraint: only ONE outstanding permission request per bridge instance.
|
|
29
|
+
Concurrent requests raise PermissionBridgeConflict.
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
bridge = PermissionBridge()
|
|
33
|
+
|
|
34
|
+
# In sync caller thread:
|
|
35
|
+
approved = bridge.request("Delete /etc/hosts?", timeout=60.0)
|
|
36
|
+
|
|
37
|
+
# In async handler thread:
|
|
38
|
+
bridge.respond(request_id, True)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, fanout: "EventFanout | None" = None) -> None:
|
|
42
|
+
self._fanout = fanout
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._pending: _PendingRequest | None = None
|
|
45
|
+
|
|
46
|
+
def request(self, prompt: str, timeout: float = 60.0) -> bool:
|
|
47
|
+
"""Block until approved/denied or timeout.
|
|
48
|
+
|
|
49
|
+
Returns True if approved, False on denial or timeout.
|
|
50
|
+
Raises PermissionBridgeConflict if a request is already pending.
|
|
51
|
+
"""
|
|
52
|
+
request_id = str(uuid.uuid4())
|
|
53
|
+
with self._lock:
|
|
54
|
+
if self._pending is not None:
|
|
55
|
+
raise PermissionBridgeConflict(
|
|
56
|
+
f"Permission request already pending: {self._pending.prompt!r}"
|
|
57
|
+
)
|
|
58
|
+
event = threading.Event()
|
|
59
|
+
self._pending = _PendingRequest(
|
|
60
|
+
request_id=request_id, prompt=prompt, event=event
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Publish event so remote/voice handlers see it
|
|
64
|
+
if self._fanout is not None:
|
|
65
|
+
from src.session.event_fanout import SessionEvent
|
|
66
|
+
self._fanout.publish(SessionEvent(
|
|
67
|
+
type="permission_request",
|
|
68
|
+
payload={"request_id": request_id, "prompt": prompt},
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
log.info("PermissionBridge: awaiting decision for request_id=%s", request_id)
|
|
72
|
+
approved = event.wait(timeout=timeout)
|
|
73
|
+
|
|
74
|
+
with self._lock:
|
|
75
|
+
result = self._pending.result if approved else False
|
|
76
|
+
self._pending = None
|
|
77
|
+
|
|
78
|
+
if not approved:
|
|
79
|
+
log.warning("PermissionBridge: request %s timed out → DENY", request_id)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def respond(self, request_id: str, approved: bool) -> bool:
|
|
83
|
+
"""Called from async handler. Returns False if request_id not found."""
|
|
84
|
+
with self._lock:
|
|
85
|
+
if self._pending is None or self._pending.request_id != request_id:
|
|
86
|
+
log.warning(
|
|
87
|
+
"PermissionBridge.respond: unknown request_id=%s (pending=%s)",
|
|
88
|
+
request_id,
|
|
89
|
+
self._pending.request_id if self._pending else None,
|
|
90
|
+
)
|
|
91
|
+
return False
|
|
92
|
+
self._pending.result = approved
|
|
93
|
+
self._pending.event.set()
|
|
94
|
+
log.info("PermissionBridge: responded request_id=%s approved=%s", request_id, approved)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def pending_request_id(self) -> str | None:
|
|
98
|
+
"""Returns the current pending request_id, or None."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
return self._pending.request_id if self._pending else None
|
src/tools/__init__.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Tool registry and base class for all gdm tools.
|
|
2
|
+
|
|
3
|
+
Every concrete tool:
|
|
4
|
+
1. Subclasses ToolBase
|
|
5
|
+
2. Declares `name`, `description`, `input_schema`
|
|
6
|
+
3. Implements `execute(params) -> ToolResult`
|
|
7
|
+
4. Calls ToolRegistry.register(cls) at module level
|
|
8
|
+
|
|
9
|
+
The registry is a module-level singleton — import the module, tools are registered.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, ClassVar
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ToolBase",
|
|
20
|
+
"ToolResult",
|
|
21
|
+
"ToolRegistry",
|
|
22
|
+
"REGISTRY",
|
|
23
|
+
"BrowserTools",
|
|
24
|
+
"BrowserToolResult",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# ToolResult
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ToolResult:
|
|
36
|
+
"""Structured output from a tool invocation."""
|
|
37
|
+
|
|
38
|
+
output: str
|
|
39
|
+
"""Human-readable output shown to the model and user."""
|
|
40
|
+
|
|
41
|
+
error: str | None = None
|
|
42
|
+
"""If set, the tool failed. The agent will decide whether to retry."""
|
|
43
|
+
|
|
44
|
+
exit_code: int | None = None
|
|
45
|
+
"""For shell tools — the process exit code."""
|
|
46
|
+
|
|
47
|
+
truncated: bool = False
|
|
48
|
+
"""True when output was truncated to fit the context window."""
|
|
49
|
+
|
|
50
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
"""Optional extra data (file paths modified, bytes read, etc.)."""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ok(self) -> bool:
|
|
55
|
+
"""True when the tool succeeded (error is None or empty string)."""
|
|
56
|
+
return not self.error
|
|
57
|
+
|
|
58
|
+
def as_message_content(self) -> str:
|
|
59
|
+
"""Render for inclusion in an LLM tool-result message."""
|
|
60
|
+
if self.error:
|
|
61
|
+
lines = [f"ERROR: {self.error}"]
|
|
62
|
+
if self.exit_code is not None:
|
|
63
|
+
lines.append(f"Exit code: {self.exit_code}")
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
parts = [self.output]
|
|
66
|
+
if self.truncated:
|
|
67
|
+
parts.append("\n[Output truncated — request the next chunk if needed]")
|
|
68
|
+
return "\n".join(parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# ToolBase
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
class ToolBase(ABC):
|
|
76
|
+
"""Abstract base for every gdm tool."""
|
|
77
|
+
|
|
78
|
+
name: ClassVar[str]
|
|
79
|
+
"""Unique snake_case identifier. Sent verbatim to the model."""
|
|
80
|
+
|
|
81
|
+
description: ClassVar[str]
|
|
82
|
+
"""One- or two-sentence description the model uses to decide when to call this tool."""
|
|
83
|
+
|
|
84
|
+
input_schema: ClassVar[dict[str, Any]]
|
|
85
|
+
"""JSON Schema object for the tool's parameters."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def execute(self, params: dict[str, Any]) -> ToolResult:
|
|
89
|
+
"""Run the tool and return a result.
|
|
90
|
+
|
|
91
|
+
Must never raise — catch all exceptions and return ToolResult(error=...).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def to_openai_spec(self) -> dict[str, Any]:
|
|
95
|
+
"""Return the tool definition dict for the OpenAI tools array."""
|
|
96
|
+
return {
|
|
97
|
+
"type": "function",
|
|
98
|
+
"function": {
|
|
99
|
+
"name": self.name,
|
|
100
|
+
"description": self.description,
|
|
101
|
+
"parameters": self.input_schema,
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# ToolRegistry
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
class ToolRegistry:
|
|
111
|
+
"""Global registry of all available tool instances.
|
|
112
|
+
|
|
113
|
+
Usage::
|
|
114
|
+
|
|
115
|
+
from src.tools import REGISTRY
|
|
116
|
+
spec_list = REGISTRY.openai_specs() # pass to client.chat.completions
|
|
117
|
+
result = REGISTRY.call("bash", {"command": "ls"})
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
self._tools: dict[str, ToolBase] = {}
|
|
122
|
+
|
|
123
|
+
def register(self, tool: ToolBase) -> None:
|
|
124
|
+
"""Register a tool instance. Called once per tool at import time."""
|
|
125
|
+
if tool.name in self._tools:
|
|
126
|
+
log.warning("Tool %r already registered — overwriting", tool.name)
|
|
127
|
+
self._tools[tool.name] = tool
|
|
128
|
+
|
|
129
|
+
def get(self, name: str) -> ToolBase | None:
|
|
130
|
+
"""Return a tool by name, or None if not found."""
|
|
131
|
+
return self._tools.get(name)
|
|
132
|
+
|
|
133
|
+
def all_tools(self) -> list[ToolBase]:
|
|
134
|
+
"""Return all registered tools."""
|
|
135
|
+
return list(self._tools.values())
|
|
136
|
+
|
|
137
|
+
def openai_specs(self, exclude: frozenset[str] | None = None) -> list[dict[str, Any]]:
|
|
138
|
+
"""Return OpenAI tool-spec dicts, optionally excluding denied tools."""
|
|
139
|
+
if exclude is None:
|
|
140
|
+
exclude = frozenset()
|
|
141
|
+
return [t.to_openai_spec() for t in self._tools.values() if t.name not in exclude]
|
|
142
|
+
|
|
143
|
+
def call(self, name: str, params: dict[str, Any]) -> ToolResult:
|
|
144
|
+
"""Dispatch a tool call by name. Returns an error result if not found."""
|
|
145
|
+
tool = self._tools.get(name)
|
|
146
|
+
if tool is None:
|
|
147
|
+
return ToolResult(output="", error=f"Unknown tool: {name!r}")
|
|
148
|
+
try:
|
|
149
|
+
return tool.execute(params)
|
|
150
|
+
except Exception as exc: # noqa: BLE001
|
|
151
|
+
log.exception("Tool %r raised unexpectedly", name)
|
|
152
|
+
return ToolResult(output="", error=f"Tool raised: {exc}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Module-level singleton — all concrete tool modules import and extend this.
|
|
156
|
+
REGISTRY: ToolRegistry = ToolRegistry()
|
|
157
|
+
|
|
158
|
+
# Re-export browser tools so callers can do `from src.tools import BrowserTools`.
|
|
159
|
+
# Import after REGISTRY is defined to avoid circular imports.
|
|
160
|
+
from src.tools.browser_tools import BrowserToolResult, BrowserTools, BrowserToolsSync # noqa: E402, F401
|
src/tools/_atomic.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Atomic file-write helpers for gdm tools.
|
|
2
|
+
|
|
3
|
+
Uses write-to-temp + fsync + os.replace() to ensure a SIGTERM or power failure
|
|
4
|
+
during a write never leaves a half-written (corrupted) file.
|
|
5
|
+
|
|
6
|
+
Each temp file has a UUID component so concurrent writes to the same path
|
|
7
|
+
(from multiple ThreadPoolExecutor workers) cannot collide.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
__all__ = ["_atomic_write", "sweep_orphans"]
|
|
16
|
+
|
|
17
|
+
_TMP_SUFFIX = ".gdm_tmp"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _atomic_write(path: str | Path, content: str | bytes, *, encoding: str = "utf-8") -> None:
|
|
21
|
+
"""Write content to path atomically via a uniquely-named temp file.
|
|
22
|
+
|
|
23
|
+
Steps: open unique temp → write → fsync → os.replace() → fsync parent dir.
|
|
24
|
+
On any failure the temp file is deleted and the original is untouched.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: if path is a symlink (refusing to replace a symlink target).
|
|
28
|
+
"""
|
|
29
|
+
path = Path(path)
|
|
30
|
+
if path.is_symlink():
|
|
31
|
+
raise ValueError(f"Refusing to atomically replace symlink: {path}")
|
|
32
|
+
tmp = path.with_name(path.name + f".{uuid.uuid4().hex}{_TMP_SUFFIX}")
|
|
33
|
+
try:
|
|
34
|
+
mode = "wb" if isinstance(content, bytes) else "w"
|
|
35
|
+
kwargs: dict = {} if isinstance(content, bytes) else {"encoding": encoding}
|
|
36
|
+
with open(tmp, mode, **kwargs) as fh:
|
|
37
|
+
fh.write(content)
|
|
38
|
+
fh.flush()
|
|
39
|
+
os.fsync(fh.fileno())
|
|
40
|
+
os.replace(tmp, path)
|
|
41
|
+
# fsync parent directory so the directory entry is durable (POSIX only)
|
|
42
|
+
try:
|
|
43
|
+
dir_fd = os.open(str(path.parent), os.O_RDONLY)
|
|
44
|
+
try:
|
|
45
|
+
os.fsync(dir_fd)
|
|
46
|
+
finally:
|
|
47
|
+
os.close(dir_fd)
|
|
48
|
+
except (AttributeError, OSError):
|
|
49
|
+
pass # Windows does not support O_RDONLY on directories — best-effort
|
|
50
|
+
except BaseException:
|
|
51
|
+
try:
|
|
52
|
+
tmp.unlink(missing_ok=True)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sweep_orphans(root: str | Path) -> int:
|
|
59
|
+
"""Delete any leftover *.gdm_tmp files under root. Returns count deleted.
|
|
60
|
+
|
|
61
|
+
Called once at startup (background thread) to clean up temp files left by
|
|
62
|
+
previous crashes.
|
|
63
|
+
"""
|
|
64
|
+
root = Path(root)
|
|
65
|
+
count = 0
|
|
66
|
+
for p in root.rglob(f"*{_TMP_SUFFIX}"):
|
|
67
|
+
try:
|
|
68
|
+
p.unlink()
|
|
69
|
+
count += 1
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
return count
|