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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. 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)
@@ -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)