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,402 @@
|
|
|
1
|
+
"""Tool orchestrator — permission-checked, audited tool dispatch.
|
|
2
|
+
|
|
3
|
+
Every tool call the agent wants to make flows through ToolOrchestrator.execute().
|
|
4
|
+
This is the single choke point for:
|
|
5
|
+
1. Permission gating (PermissionContext)
|
|
6
|
+
2. Tool result cache (read-only tools, mtime-keyed LRU)
|
|
7
|
+
3. Tool execution (REGISTRY)
|
|
8
|
+
4. Audit logging (AuditLogger)
|
|
9
|
+
5. Post-write hooks (FileCache invalidation + CodeIndex re-index)
|
|
10
|
+
|
|
11
|
+
Tools are guaranteed to be registered before the first execute() call because
|
|
12
|
+
_ensure_tools_registered() runs at module import time.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import concurrent.futures as _cf
|
|
17
|
+
import copy
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import threading
|
|
22
|
+
from collections import OrderedDict
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from src._internal.constants import _ALWAYS_DENY_TOOLS, _WRITE_TOOLS
|
|
26
|
+
from src.exceptions import ToolPermissionError
|
|
27
|
+
from src.security import AuditLogger
|
|
28
|
+
from src.tools import REGISTRY, ToolResult
|
|
29
|
+
|
|
30
|
+
__all__ = ["ToolOrchestrator"]
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_TOOL_TIMEOUT_S: int = 30
|
|
35
|
+
# Module-level executor: tools run in a separate thread so a hung tool cannot
|
|
36
|
+
# freeze the agent loop. daemon=True so threads don't block process exit.
|
|
37
|
+
_TOOL_EXECUTOR: _cf.ThreadPoolExecutor = _cf.ThreadPoolExecutor(
|
|
38
|
+
max_workers=4, thread_name_prefix="gdm-tool"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Shell tool names where we cap the tool's own subprocess timeout to orchestrator timeout.
|
|
42
|
+
_SHELL_TOOLS: frozenset[str] = frozenset({"bash", "powershell"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def shutdown_tool_executor() -> None:
|
|
46
|
+
"""Signal the tool executor to stop accepting jobs and cancel pending futures.
|
|
47
|
+
|
|
48
|
+
Called from the flush path (SIGTERM, atexit) to avoid lingering tool threads
|
|
49
|
+
after the agent loop has finished.
|
|
50
|
+
"""
|
|
51
|
+
_TOOL_EXECUTOR.shutdown(wait=False, cancel_futures=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Tool result cache
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
# Only path-scoped read tools may be cached. Expand this set as new read tools
|
|
59
|
+
# are added — never add non-existent tools (spec: audit finding).
|
|
60
|
+
_CACHEABLE_TOOLS: frozenset[str] = frozenset({"read_file"})
|
|
61
|
+
# Both write tools must invalidate cached reads of the same path.
|
|
62
|
+
_INVALIDATING_TOOLS: frozenset[str] = _WRITE_TOOLS # {"write_file", "edit_file"}
|
|
63
|
+
_MAX_CACHE_ENTRIES: int = 100
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _ToolResultCache:
|
|
67
|
+
"""Thread-safe mtime-keyed LRU cache for pure read tool results.
|
|
68
|
+
|
|
69
|
+
Key = tool_name : abs_path : mtime : sorted_extra_args_json.
|
|
70
|
+
Returns deepcopy of stored result to prevent caller mutation of cache.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
self._store: OrderedDict[str, ToolResult] = OrderedDict()
|
|
76
|
+
self._hits: int = 0
|
|
77
|
+
self._misses: int = 0
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
def _cache_key(self, tool_name: str, args: dict[str, Any]) -> str | None:
|
|
81
|
+
if tool_name not in _CACHEABLE_TOOLS:
|
|
82
|
+
return None
|
|
83
|
+
path_str = args.get("path")
|
|
84
|
+
if not path_str:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
mtime = os.path.getmtime(path_str)
|
|
88
|
+
except OSError:
|
|
89
|
+
return None
|
|
90
|
+
extra = {k: v for k, v in args.items() if k != "path"}
|
|
91
|
+
arg_sig = json.dumps(extra, sort_keys=True)
|
|
92
|
+
return f"{tool_name}:{path_str}:{mtime:.6f}:{arg_sig}"
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
def get(self, tool_name: str, args: dict[str, Any]) -> ToolResult | None:
|
|
96
|
+
key = self._cache_key(tool_name, args)
|
|
97
|
+
if key is None:
|
|
98
|
+
return None
|
|
99
|
+
with self._lock:
|
|
100
|
+
if key in self._store:
|
|
101
|
+
self._store.move_to_end(key)
|
|
102
|
+
self._hits += 1
|
|
103
|
+
log.debug("cache HIT %s(%s)", tool_name, args.get("path", ""))
|
|
104
|
+
return copy.deepcopy(self._store[key])
|
|
105
|
+
self._misses += 1
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def put(self, tool_name: str, args: dict[str, Any], result: ToolResult) -> None:
|
|
109
|
+
key = self._cache_key(tool_name, args)
|
|
110
|
+
if key is None:
|
|
111
|
+
return
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._store[key] = copy.deepcopy(result)
|
|
114
|
+
self._store.move_to_end(key)
|
|
115
|
+
while len(self._store) > _MAX_CACHE_ENTRIES:
|
|
116
|
+
self._store.popitem(last=False)
|
|
117
|
+
|
|
118
|
+
def invalidate_path(self, path_str: str) -> int:
|
|
119
|
+
"""Remove all entries whose key references *path_str*. Returns count removed."""
|
|
120
|
+
with self._lock:
|
|
121
|
+
to_delete = [k for k in self._store if f":{path_str}:" in k]
|
|
122
|
+
for k in to_delete:
|
|
123
|
+
del self._store[k]
|
|
124
|
+
return len(to_delete)
|
|
125
|
+
|
|
126
|
+
def stats(self) -> dict[str, object]:
|
|
127
|
+
with self._lock:
|
|
128
|
+
total = self._hits + self._misses
|
|
129
|
+
return {
|
|
130
|
+
"hits": self._hits,
|
|
131
|
+
"misses": self._misses,
|
|
132
|
+
"hit_rate": self._hits / total if total else 0.0,
|
|
133
|
+
"size": len(self._store),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Module-level cache instance shared across all ToolOrchestrator instances in the process.
|
|
138
|
+
_TOOL_CACHE: _ToolResultCache = _ToolResultCache()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Bootstrap — import all tool modules so they self-register with REGISTRY
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def _ensure_tools_registered() -> None:
|
|
146
|
+
"""Import all tool modules so they register themselves with REGISTRY."""
|
|
147
|
+
import src.tools.bash_tool # noqa: F401
|
|
148
|
+
import src.tools.read_tools # noqa: F401
|
|
149
|
+
import src.tools.search_tools # noqa: F401
|
|
150
|
+
import src.tools.shell_tools # noqa: F401
|
|
151
|
+
import src.tools.ask_user_tool # noqa: F401
|
|
152
|
+
import src.tools.write_tools # noqa: F401
|
|
153
|
+
import src.tools.dep_tools # noqa: F401
|
|
154
|
+
import src.tools.impact_tools # noqa: F401
|
|
155
|
+
import src.tools.browser_tool # noqa: F401
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_ensure_tools_registered()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Orchestrator
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
class ToolOrchestrator:
|
|
166
|
+
"""Permission-checked, audited dispatcher for all agent tool calls.
|
|
167
|
+
|
|
168
|
+
Every tool invocation goes through this class in order:
|
|
169
|
+
1. PermissionContext.check() -- may raise ToolPermissionError
|
|
170
|
+
2. REGISTRY.call() -- executes the tool
|
|
171
|
+
3. AuditLogger.log_tool() -- writes an immutable audit record
|
|
172
|
+
4. Post-write hooks (FileCache invalidation + CodeIndex re-index)
|
|
173
|
+
|
|
174
|
+
The execute() method never raises. All errors are surfaced as
|
|
175
|
+
ToolResult(error=...).
|
|
176
|
+
|
|
177
|
+
Usage::
|
|
178
|
+
|
|
179
|
+
orch = ToolOrchestrator(permissions=ctx, db=db, session_id=sid)
|
|
180
|
+
result = orch.execute("bash", {"command": "ls"})
|
|
181
|
+
if not result.ok:
|
|
182
|
+
console.print(result.error)
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
permissions: Any, # PermissionContext -- typed as Any to avoid circular import
|
|
189
|
+
db: Any, # GdmDatabase -- typed as Any to avoid circular import
|
|
190
|
+
session_id: str,
|
|
191
|
+
*,
|
|
192
|
+
project_id: str = "",
|
|
193
|
+
cfg: Any = None, # GdmConfig | None -- for timeout config
|
|
194
|
+
) -> None:
|
|
195
|
+
self._permissions = permissions
|
|
196
|
+
self._audit = AuditLogger(db=db, session_id=session_id)
|
|
197
|
+
self._session_id = session_id
|
|
198
|
+
self._db = db
|
|
199
|
+
self._project_id = project_id
|
|
200
|
+
self._timeout_secs: int = (
|
|
201
|
+
cfg.tools_timeout_secs if cfg is not None else _TOOL_TIMEOUT_S
|
|
202
|
+
)
|
|
203
|
+
# plugin_name.tool_name → (IpcPluginHandle, raw_tool_name)
|
|
204
|
+
self._plugin_channels: dict[str, Any] = {}
|
|
205
|
+
|
|
206
|
+
def execute(
|
|
207
|
+
self,
|
|
208
|
+
tool_name: str,
|
|
209
|
+
args: dict[str, Any],
|
|
210
|
+
*,
|
|
211
|
+
model_id: str = "unknown",
|
|
212
|
+
) -> ToolResult:
|
|
213
|
+
"""Check permissions, run tool, write audit record. Never raises.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
tool_name: name of the tool to invoke (must match REGISTRY entry)
|
|
217
|
+
args: parsed arguments dict for the tool
|
|
218
|
+
model_id: model that requested this call, for the audit log
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
ToolResult -- inspect .ok to distinguish success from failure.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
decision = self._permissions.check(tool_name, args)
|
|
225
|
+
except ToolPermissionError as exc:
|
|
226
|
+
self._audit.log_denied(
|
|
227
|
+
tool=tool_name, args=args, reason=str(exc), model=model_id
|
|
228
|
+
)
|
|
229
|
+
return ToolResult(
|
|
230
|
+
output="",
|
|
231
|
+
error=f"Tool {tool_name!r} denied: {exc}",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
_fut: _cf.Future[ToolResult] | None = None
|
|
235
|
+
try:
|
|
236
|
+
# Check cache before executing (read-only tools only)
|
|
237
|
+
cached = _TOOL_CACHE.get(tool_name, args)
|
|
238
|
+
if cached is not None:
|
|
239
|
+
log.debug("cache stats: %s", _TOOL_CACHE.stats())
|
|
240
|
+
self._audit.log_tool(
|
|
241
|
+
tool=tool_name, args=args, model=model_id, decision="cache_hit"
|
|
242
|
+
)
|
|
243
|
+
return cached
|
|
244
|
+
|
|
245
|
+
# For shell tools, cap subprocess timeout at orchestrator timeout so the
|
|
246
|
+
# subprocess is killed (not abandoned) when the orchestrator times out.
|
|
247
|
+
if tool_name in _SHELL_TOOLS:
|
|
248
|
+
effective_args = dict(args)
|
|
249
|
+
user_timeout = int(effective_args.get("timeout", self._timeout_secs))
|
|
250
|
+
effective_args["timeout"] = min(user_timeout, self._timeout_secs)
|
|
251
|
+
else:
|
|
252
|
+
effective_args = args
|
|
253
|
+
|
|
254
|
+
_fut = _TOOL_EXECUTOR.submit(REGISTRY.call, tool_name, effective_args)
|
|
255
|
+
result = _fut.result(timeout=self._timeout_secs)
|
|
256
|
+
except _cf.TimeoutError:
|
|
257
|
+
if _fut is not None:
|
|
258
|
+
_fut.cancel()
|
|
259
|
+
log.error("Tool %r timed out after %ds", tool_name, self._timeout_secs)
|
|
260
|
+
self._audit.log_tool(
|
|
261
|
+
tool=tool_name, args=args, model=model_id, decision="timeout"
|
|
262
|
+
)
|
|
263
|
+
return ToolResult(
|
|
264
|
+
output="",
|
|
265
|
+
error=f"Tool {tool_name!r} timed out after {self._timeout_secs}s",
|
|
266
|
+
)
|
|
267
|
+
except Exception as exc: # noqa: BLE001
|
|
268
|
+
log.exception("Tool %r raised unexpectedly", tool_name)
|
|
269
|
+
self._audit.log_tool(
|
|
270
|
+
tool=tool_name, args=args, model=model_id, decision="error"
|
|
271
|
+
)
|
|
272
|
+
return ToolResult(output="", error=f"Tool {tool_name!r} raised: {exc}")
|
|
273
|
+
|
|
274
|
+
self._audit.log_tool(
|
|
275
|
+
tool=tool_name,
|
|
276
|
+
args=args,
|
|
277
|
+
model=model_id,
|
|
278
|
+
decision=str(decision),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Invalidate cache on writes, then store on successful reads
|
|
282
|
+
if tool_name in _INVALIDATING_TOOLS:
|
|
283
|
+
path_arg = args.get("path") or args.get("file_path") or ""
|
|
284
|
+
if path_arg:
|
|
285
|
+
removed = _TOOL_CACHE.invalidate_path(path_arg)
|
|
286
|
+
log.debug("invalidated %d cache entries for %s", removed, path_arg)
|
|
287
|
+
elif result.ok:
|
|
288
|
+
_TOOL_CACHE.put(tool_name, args, result)
|
|
289
|
+
log.debug("cache stats: %s", _TOOL_CACHE.stats())
|
|
290
|
+
|
|
291
|
+
# Post-write hooks: invalidate FileCache + schedule CodeIndex re-index
|
|
292
|
+
# + append a structural diff snippet to the result output
|
|
293
|
+
if tool_name in _WRITE_TOOLS and result.ok:
|
|
294
|
+
self._post_write_hooks(tool_name, args)
|
|
295
|
+
path_arg = args.get("path") or args.get("file_path") or ""
|
|
296
|
+
if path_arg:
|
|
297
|
+
diff = self._structural_diff(path_arg)
|
|
298
|
+
if diff:
|
|
299
|
+
result.output = (result.output or "") + "\n\n" + diff
|
|
300
|
+
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
def _post_write_hooks(self, tool_name: str, args: dict[str, Any]) -> None:
|
|
304
|
+
"""Invalidate file cache and trigger code re-index after a write tool."""
|
|
305
|
+
if not self._project_id or self._db is None:
|
|
306
|
+
return
|
|
307
|
+
path_arg = args.get("path") or args.get("file_path") or ""
|
|
308
|
+
if not path_arg:
|
|
309
|
+
return
|
|
310
|
+
from pathlib import Path
|
|
311
|
+
path = Path(path_arg)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
from src.memory.file_cache import FileCache
|
|
315
|
+
FileCache(self._db, self._project_id).invalidate(path)
|
|
316
|
+
except Exception as exc: # noqa: BLE001
|
|
317
|
+
log.debug("FileCache.invalidate failed for %s: %s", path_arg, exc)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
from src.memory.code_index import CodeIndex
|
|
321
|
+
from pathlib import Path as _Path
|
|
322
|
+
CodeIndex(db=self._db, project_id=self._project_id, project_root=_Path.cwd()).index_file(path)
|
|
323
|
+
except Exception as exc: # noqa: BLE001
|
|
324
|
+
log.debug("CodeIndex.index_file failed for %s: %s", path_arg, exc)
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _structural_diff(path_str: str) -> str:
|
|
328
|
+
"""Return a short diff snippet after a write, for context.
|
|
329
|
+
|
|
330
|
+
Tries difftastic (``difft``) first for semantic diffs; falls back to
|
|
331
|
+
``git diff --stat HEAD <path>``. Returns empty string on any failure —
|
|
332
|
+
this is a best-effort display feature only.
|
|
333
|
+
"""
|
|
334
|
+
import subprocess
|
|
335
|
+
from pathlib import Path
|
|
336
|
+
|
|
337
|
+
path = Path(path_str)
|
|
338
|
+
if not path.exists():
|
|
339
|
+
return ""
|
|
340
|
+
|
|
341
|
+
# Try difftastic — semantic, language-aware diff
|
|
342
|
+
try:
|
|
343
|
+
import shutil
|
|
344
|
+
if shutil.which("difft"):
|
|
345
|
+
r = subprocess.run(
|
|
346
|
+
["difft", "--color=never", path_str],
|
|
347
|
+
capture_output=True, text=True, timeout=10,
|
|
348
|
+
)
|
|
349
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
350
|
+
lines = r.stdout.strip().splitlines()
|
|
351
|
+
snippet = "\n".join(lines[:30])
|
|
352
|
+
if len(lines) > 30:
|
|
353
|
+
snippet += f"\n… ({len(lines) - 30} more lines)"
|
|
354
|
+
return f"[difftastic]\n{snippet}"
|
|
355
|
+
except Exception: # noqa: BLE001
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
# Fallback: git diff --stat
|
|
359
|
+
try:
|
|
360
|
+
r = subprocess.run(
|
|
361
|
+
["git", "diff", "--stat", "HEAD", "--", path_str],
|
|
362
|
+
capture_output=True, text=True, timeout=5,
|
|
363
|
+
)
|
|
364
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
365
|
+
return f"[git diff --stat]\n{r.stdout.strip()[:500]}"
|
|
366
|
+
except Exception: # noqa: BLE001
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
return ""
|
|
370
|
+
|
|
371
|
+
def get_permitted_specs(self) -> list[dict[str, Any]]:
|
|
372
|
+
"""Return OpenAI tool specs, excluding always-denied tools.
|
|
373
|
+
|
|
374
|
+
Permission enforcement happens at execute() time. This filters only
|
|
375
|
+
the hardcoded always-deny list to avoid advertising unusable tools.
|
|
376
|
+
Session-level denials are enforced at execution time to avoid
|
|
377
|
+
triggering interactive prompts during spec construction.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
List of OpenAI function spec dicts ready for the tools param.
|
|
381
|
+
"""
|
|
382
|
+
return REGISTRY.openai_specs(exclude=_ALWAYS_DENY_TOOLS)
|
|
383
|
+
|
|
384
|
+
def register_plugin_tool(self, plugin_name: str, tool_schema: dict,
|
|
385
|
+
ipc_channel: Any) -> None:
|
|
386
|
+
"""Register a plugin tool — calls go through IPC, never in-process."""
|
|
387
|
+
tool_name = f"plugin.{plugin_name}.{tool_schema['name']}"
|
|
388
|
+
self._plugin_channels[tool_name] = (ipc_channel, tool_schema["name"])
|
|
389
|
+
log.info("Registered plugin tool: %s", tool_name)
|
|
390
|
+
|
|
391
|
+
def _dispatch_plugin(self, tool_name: str, args: dict) -> Any:
|
|
392
|
+
handle, raw_name = self._plugin_channels[tool_name]
|
|
393
|
+
resp = handle.call_tool(raw_name, args)
|
|
394
|
+
if "error" in resp:
|
|
395
|
+
raise RuntimeError(f"Plugin tool error: {resp['error']}")
|
|
396
|
+
return resp.get("result")
|
|
397
|
+
|
|
398
|
+
def dispatch_plugin(self, tool_name: str, args: dict) -> Any:
|
|
399
|
+
"""Dispatch a registered plugin tool by its namespaced tool_name."""
|
|
400
|
+
if tool_name not in self._plugin_channels:
|
|
401
|
+
raise KeyError(f"Unknown plugin tool: {tool_name}")
|
|
402
|
+
return self._dispatch_plugin(tool_name, args)
|
src/agent/transcript.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""TranscriptStore — in-memory sliding window of conversation turns.
|
|
2
|
+
|
|
3
|
+
Mirrors the ClawCode architecture: two-layer memory where TranscriptStore
|
|
4
|
+
holds the live session window and SessionStore persists it to disk.
|
|
5
|
+
|
|
6
|
+
Design choices:
|
|
7
|
+
- Turns stored as plain dicts (matches OpenAI message format directly)
|
|
8
|
+
- token_count is always up-to-date (tracked incrementally, not recomputed)
|
|
9
|
+
- Oldest turns are evicted when budget is exceeded (compress first, then drop)
|
|
10
|
+
- No model calls here — pure data structure
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from collections import deque
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Iterator
|
|
18
|
+
|
|
19
|
+
__all__ = ["Turn", "TranscriptStore"]
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# OpenAI role values
|
|
24
|
+
Role = str # "user" | "assistant" | "tool" | "system"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Turn:
|
|
29
|
+
"""A single conversation turn."""
|
|
30
|
+
|
|
31
|
+
role: Role
|
|
32
|
+
content: str
|
|
33
|
+
tokens: int = 0
|
|
34
|
+
tool_name: str | None = None # set when role == "tool"
|
|
35
|
+
tool_call_id: str | None = None # correlation between assistant call + tool result
|
|
36
|
+
tool_calls: list[dict] | None = None # raw tool_calls list when role == "assistant"
|
|
37
|
+
non_droppable: bool = False # if True, compression must preserve this turn verbatim
|
|
38
|
+
non_droppable_reason: str = "" # human-readable reason (e.g. "unverified write: path.py")
|
|
39
|
+
|
|
40
|
+
def to_message(self) -> dict: # type: ignore[type-arg]
|
|
41
|
+
"""Serialise to OpenAI message dict (API-safe — no internal metadata)."""
|
|
42
|
+
msg: dict = {"role": self.role, "content": self.content} # type: ignore[type-arg]
|
|
43
|
+
if self.tool_name:
|
|
44
|
+
msg["name"] = self.tool_name
|
|
45
|
+
if self.tool_call_id:
|
|
46
|
+
msg["tool_call_id"] = self.tool_call_id
|
|
47
|
+
if self.tool_calls is not None:
|
|
48
|
+
msg["tool_calls"] = self.tool_calls
|
|
49
|
+
return msg
|
|
50
|
+
|
|
51
|
+
def to_compress_dict(self) -> dict: # type: ignore[type-arg]
|
|
52
|
+
"""Serialise including compression metadata (non_droppable flag).
|
|
53
|
+
|
|
54
|
+
Used by _maybe_compress() when passing turns to the SessionCompressor.
|
|
55
|
+
NOT sent to the API — contains internal fields the API would reject.
|
|
56
|
+
"""
|
|
57
|
+
d = self.to_message()
|
|
58
|
+
if self.non_droppable:
|
|
59
|
+
d["non_droppable"] = True
|
|
60
|
+
if self.non_droppable_reason:
|
|
61
|
+
d["non_droppable_reason"] = self.non_droppable_reason
|
|
62
|
+
return d
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_message(cls, msg: dict, tokens: int = 0) -> Turn: # type: ignore[type-arg]
|
|
66
|
+
return cls(
|
|
67
|
+
role=msg["role"],
|
|
68
|
+
content=msg.get("content") or "",
|
|
69
|
+
tokens=tokens,
|
|
70
|
+
tool_name=msg.get("name"),
|
|
71
|
+
tool_call_id=msg.get("tool_call_id"),
|
|
72
|
+
tool_calls=msg.get("tool_calls"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def assistant_with_tool_calls(
|
|
77
|
+
cls,
|
|
78
|
+
tool_calls: list[dict], # type: ignore[type-arg]
|
|
79
|
+
tokens: int = 0,
|
|
80
|
+
) -> Turn:
|
|
81
|
+
"""Build an assistant turn that carries tool_calls but no content."""
|
|
82
|
+
return cls(role="assistant", content="", tokens=tokens, tool_calls=tool_calls)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TranscriptStore:
|
|
86
|
+
"""In-memory sliding window of conversation turns with token tracking.
|
|
87
|
+
|
|
88
|
+
Usage::
|
|
89
|
+
|
|
90
|
+
store = TranscriptStore(max_tokens=120_000)
|
|
91
|
+
store.append(Turn(role="user", content="Fix the auth bug", tokens=8))
|
|
92
|
+
store.append(Turn(role="assistant", content="...", tokens=412))
|
|
93
|
+
messages = store.to_messages() # ready for API call
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, max_tokens: int = 120_000) -> None:
|
|
97
|
+
self._turns: deque[Turn] = deque()
|
|
98
|
+
self._max_tokens = max_tokens
|
|
99
|
+
self._total_tokens: int = 0
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Core API
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def append(self, turn: Turn) -> None:
|
|
106
|
+
"""Add a turn. Does NOT evict — call maybe_evict() separately."""
|
|
107
|
+
self._turns.append(turn)
|
|
108
|
+
self._total_tokens += turn.tokens
|
|
109
|
+
|
|
110
|
+
def prepend_system(self, content: str, tokens: int = 0) -> None:
|
|
111
|
+
"""Insert / replace a system turn at position 0."""
|
|
112
|
+
if self._turns and self._turns[0].role == "system":
|
|
113
|
+
old = self._turns[0]
|
|
114
|
+
self._total_tokens -= old.tokens
|
|
115
|
+
self._turns[0] = Turn(role="system", content=content, tokens=tokens)
|
|
116
|
+
else:
|
|
117
|
+
self._turns.appendleft(Turn(role="system", content=content, tokens=tokens))
|
|
118
|
+
self._total_tokens += tokens
|
|
119
|
+
|
|
120
|
+
def maybe_evict(self, budget_tokens: int | None = None) -> int:
|
|
121
|
+
"""Evict oldest non-system turns until under budget. Returns evicted count."""
|
|
122
|
+
limit = budget_tokens or self._max_tokens
|
|
123
|
+
evicted = 0
|
|
124
|
+
while self._total_tokens > limit and len(self._turns) > 1:
|
|
125
|
+
removed = self.evict_oldest(keep_system=True)
|
|
126
|
+
if removed is None:
|
|
127
|
+
break
|
|
128
|
+
evicted += 1
|
|
129
|
+
return evicted
|
|
130
|
+
|
|
131
|
+
def evict_oldest(self, keep_system: bool = True) -> Turn | None:
|
|
132
|
+
"""Evict the oldest evictable turn (or block). Returns it or None.
|
|
133
|
+
|
|
134
|
+
If the oldest evictable turn is an assistant turn with tool_calls,
|
|
135
|
+
it evicts that turn AND all immediately following tool-result turns
|
|
136
|
+
that correlate with it — keeping the message history valid.
|
|
137
|
+
"""
|
|
138
|
+
turns_list = list(self._turns)
|
|
139
|
+
for i, turn in enumerate(turns_list):
|
|
140
|
+
if keep_system and turn.role == "system":
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Gather indices to evict (at minimum: [i])
|
|
144
|
+
to_evict = [i]
|
|
145
|
+
|
|
146
|
+
# If this is an assistant turn with tool_calls, also evict
|
|
147
|
+
# all immediately following tool-result turns belonging to it.
|
|
148
|
+
if turn.tool_calls:
|
|
149
|
+
call_ids = {tc.get("id") for tc in turn.tool_calls if tc.get("id")}
|
|
150
|
+
j = i + 1
|
|
151
|
+
while j < len(turns_list):
|
|
152
|
+
nxt = turns_list[j]
|
|
153
|
+
if nxt.role == "tool" and nxt.tool_call_id in call_ids:
|
|
154
|
+
to_evict.append(j)
|
|
155
|
+
j += 1
|
|
156
|
+
else:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# Remove from high to low index to preserve indices
|
|
160
|
+
for idx in reversed(to_evict):
|
|
161
|
+
self._total_tokens -= turns_list[idx].tokens
|
|
162
|
+
remaining = [t for idx_t, t in enumerate(turns_list) if idx_t not in set(to_evict)]
|
|
163
|
+
self._turns = deque(remaining)
|
|
164
|
+
return turns_list[i]
|
|
165
|
+
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def replace_oldest_tool_results(self, summary: str, summary_tokens: int) -> int:
|
|
169
|
+
"""Compress oldest tool results into a one-line summary. Returns count replaced."""
|
|
170
|
+
replaced = 0
|
|
171
|
+
for i, turn in enumerate(self._turns):
|
|
172
|
+
if turn.role == "tool" and turn.tokens > 200:
|
|
173
|
+
self._total_tokens -= turn.tokens
|
|
174
|
+
turns_list = list(self._turns)
|
|
175
|
+
turns_list[i] = Turn(
|
|
176
|
+
role="tool",
|
|
177
|
+
content=summary,
|
|
178
|
+
tokens=summary_tokens,
|
|
179
|
+
tool_name=turn.tool_name,
|
|
180
|
+
tool_call_id=turn.tool_call_id,
|
|
181
|
+
)
|
|
182
|
+
self._turns = deque(turns_list)
|
|
183
|
+
self._total_tokens += summary_tokens
|
|
184
|
+
replaced += 1
|
|
185
|
+
if self._total_tokens <= self._max_tokens:
|
|
186
|
+
break
|
|
187
|
+
return replaced
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Serialisation
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def to_messages(self) -> list[dict]: # type: ignore[type-arg]
|
|
194
|
+
"""Return turns as a list of OpenAI message dicts."""
|
|
195
|
+
return [t.to_message() for t in self._turns]
|
|
196
|
+
|
|
197
|
+
def to_turns(self) -> list[Turn]:
|
|
198
|
+
"""Return a snapshot of all turns."""
|
|
199
|
+
return list(self._turns)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_turns(cls, turns: list[Turn], max_tokens: int = 120_000) -> TranscriptStore:
|
|
203
|
+
"""Reconstruct from a persisted turn list."""
|
|
204
|
+
store = cls(max_tokens=max_tokens)
|
|
205
|
+
for turn in turns:
|
|
206
|
+
store.append(turn)
|
|
207
|
+
return store
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Introspection
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def token_count(self) -> int:
|
|
215
|
+
return self._total_tokens
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def turn_count(self) -> int:
|
|
219
|
+
return len(self._turns)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def budget_ratio(self) -> float:
|
|
223
|
+
"""Fraction of max_tokens used (0.0–1.0+)."""
|
|
224
|
+
return self._total_tokens / self._max_tokens if self._max_tokens else 0.0
|
|
225
|
+
|
|
226
|
+
def __iter__(self) -> Iterator[Turn]:
|
|
227
|
+
return iter(self._turns)
|
|
228
|
+
|
|
229
|
+
def __len__(self) -> int:
|
|
230
|
+
return len(self._turns)
|