gemcode 0.2.2__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 (58) hide show
  1. gemcode/__init__.py +3 -0
  2. gemcode/__main__.py +3 -0
  3. gemcode/agent.py +146 -0
  4. gemcode/audit.py +16 -0
  5. gemcode/callbacks.py +473 -0
  6. gemcode/capability_routing.py +137 -0
  7. gemcode/cli.py +658 -0
  8. gemcode/compaction.py +35 -0
  9. gemcode/computer_use/__init__.py +0 -0
  10. gemcode/computer_use/browser_computer.py +275 -0
  11. gemcode/config.py +247 -0
  12. gemcode/interactions.py +15 -0
  13. gemcode/invoke.py +151 -0
  14. gemcode/kairos_daemon.py +221 -0
  15. gemcode/limits.py +83 -0
  16. gemcode/live_audio_engine.py +124 -0
  17. gemcode/mcp_loader.py +57 -0
  18. gemcode/memory/__init__.py +0 -0
  19. gemcode/memory/embedding_memory_service.py +292 -0
  20. gemcode/memory/file_memory_service.py +176 -0
  21. gemcode/modality_tools.py +216 -0
  22. gemcode/model_routing.py +179 -0
  23. gemcode/paths.py +29 -0
  24. gemcode/permissions.py +5 -0
  25. gemcode/plugins/__init__.py +0 -0
  26. gemcode/plugins/terminal_hooks_plugin.py +168 -0
  27. gemcode/plugins/tool_recovery_plugin.py +135 -0
  28. gemcode/prompt_suggestions.py +80 -0
  29. gemcode/query/__init__.py +36 -0
  30. gemcode/query/config.py +35 -0
  31. gemcode/query/deps.py +20 -0
  32. gemcode/query/engine.py +55 -0
  33. gemcode/query/stop_hooks.py +63 -0
  34. gemcode/query/token_budget.py +109 -0
  35. gemcode/query/transitions.py +41 -0
  36. gemcode/session_runtime.py +81 -0
  37. gemcode/thinking.py +136 -0
  38. gemcode/tool_prompt_manifest.py +118 -0
  39. gemcode/tool_registry.py +50 -0
  40. gemcode/tools/__init__.py +25 -0
  41. gemcode/tools/edit.py +53 -0
  42. gemcode/tools/filesystem.py +73 -0
  43. gemcode/tools/search.py +85 -0
  44. gemcode/tools/shell.py +73 -0
  45. gemcode/tools_inspector.py +132 -0
  46. gemcode/trust.py +54 -0
  47. gemcode/tui/app.py +697 -0
  48. gemcode/tui/scrollback.py +312 -0
  49. gemcode/vertex.py +22 -0
  50. gemcode/web/__init__.py +2 -0
  51. gemcode/web/claude_sse_adapter.py +282 -0
  52. gemcode/web/terminal_repl.py +147 -0
  53. gemcode-0.2.2.dist-info/METADATA +440 -0
  54. gemcode-0.2.2.dist-info/RECORD +58 -0
  55. gemcode-0.2.2.dist-info/WHEEL +5 -0
  56. gemcode-0.2.2.dist-info/entry_points.txt +2 -0
  57. gemcode-0.2.2.dist-info/licenses/LICENSE +151 -0
  58. gemcode-0.2.2.dist-info/top_level.txt +1 -0
gemcode/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """GemCode: Gemini + ADK coding agent."""
2
+
3
+ __version__ = "0.1.0"
gemcode/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from gemcode.cli import main
2
+
3
+ main()
gemcode/agent.py ADDED
@@ -0,0 +1,146 @@
1
+ """
2
+ Root LlmAgent definition (Claude Code: agent config + tool list, analogous to tools.ts + prompts).
3
+
4
+ See `session_runtime.py` for Runner/session wiring (outer layer).
5
+ See `tool_registry.py` for tool categories (read vs mutating vs shell).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from pathlib import Path
12
+
13
+ from google.adk.agents.llm_agent import LlmAgent
14
+
15
+ from gemcode.callbacks import (
16
+ make_after_model_callback,
17
+ make_after_tool_callback,
18
+ make_before_tool_callback,
19
+ make_on_model_error_callback,
20
+ make_on_tool_error_callback,
21
+ )
22
+ from gemcode.compaction import make_before_model_callback
23
+ from gemcode.config import GemCodeConfig
24
+ from gemcode.limits import make_before_model_limits_callback, make_before_model_token_budget_callback
25
+ from gemcode.thinking import build_thinking_config
26
+ from gemcode.tools import build_function_tools
27
+ from gemcode.tool_prompt_manifest import build_tool_manifest
28
+
29
+
30
+ def _chain_before_model_callbacks(*callbacks):
31
+ cbs = [c for c in callbacks if c is not None]
32
+ if not cbs:
33
+ return None
34
+ if len(cbs) == 1:
35
+ return cbs[0]
36
+
37
+ async def chained(callback_context, llm_request):
38
+ for cb in cbs:
39
+ out = cb(callback_context, llm_request)
40
+ if inspect.isawaitable(out):
41
+ out = await out
42
+ if out is not None:
43
+ return out
44
+ return None
45
+
46
+ return chained
47
+
48
+
49
+ def _load_gemini_md(project_root: Path) -> str:
50
+ for name in ("GEMINI.md", "gemini.md"):
51
+ p = project_root / name
52
+ if p.is_file():
53
+ return p.read_text(encoding="utf-8", errors="replace")[:50_000]
54
+ return ""
55
+
56
+
57
+ def build_instruction(cfg: GemCodeConfig) -> str:
58
+ base = """You are GemCode, an expert software engineering agent.
59
+ You work only inside the user's project directory. Use tools to read and explore before editing.
60
+ Prefer small, testable edits. Explain assumptions briefly."""
61
+
62
+ tool_manifest = build_tool_manifest(cfg)
63
+
64
+ if tool_manifest:
65
+ base = f"{base}\n\n{tool_manifest}"
66
+ extra = _load_gemini_md(cfg.project_root)
67
+ if extra.strip():
68
+ return f"{base}\n\n## Project instructions (GEMINI.md)\n{extra}"
69
+ return base
70
+
71
+
72
+ def build_root_agent(cfg: GemCodeConfig, extra_tools: list | None = None) -> LlmAgent:
73
+ """Create the root LlmAgent with tools and callbacks (no Runner)."""
74
+ tools = build_function_tools(cfg)
75
+ if getattr(cfg, "enable_memory", False):
76
+ # ADK preload_memory injects retrieved memories into the next llm_request.
77
+ from google.adk.tools import preload_memory
78
+
79
+ tools = [preload_memory, *tools]
80
+ if extra_tools:
81
+ tools = [*tools, *extra_tools]
82
+
83
+ before_model = _chain_before_model_callbacks(
84
+ make_before_model_callback(cfg),
85
+ make_before_model_limits_callback(cfg),
86
+ make_before_model_token_budget_callback(cfg),
87
+ )
88
+ cb_kwargs: dict = {
89
+ "before_tool_callback": make_before_tool_callback(cfg),
90
+ "after_tool_callback": make_after_tool_callback(cfg),
91
+ "after_model_callback": make_after_model_callback(cfg),
92
+ "on_tool_error_callback": make_on_tool_error_callback(cfg),
93
+ "on_model_error_callback": make_on_model_error_callback(cfg),
94
+ }
95
+ if before_model is not None:
96
+ cb_kwargs["before_model_callback"] = before_model
97
+
98
+ # Claude-like thinking: enabled by default (Gemini dynamic), but allow
99
+ # explicit overrides for disable/budgets/levels.
100
+ gen_cfg = None
101
+ thinking_cfg = build_thinking_config(cfg)
102
+ tool_cfg = None
103
+ model_id = getattr(cfg, "model", "") or ""
104
+ is_gemini_3 = "gemini-3" in model_id.lower()
105
+ comb_mode = (getattr(cfg, "tool_combination_mode", None) or "deep_research").lower()
106
+ enable_for_run = False
107
+ if comb_mode in ("auto", "deep_research"):
108
+ enable_for_run = bool(getattr(cfg, "enable_deep_research", False))
109
+ elif comb_mode == "always":
110
+ enable_for_run = True
111
+ elif comb_mode == "never":
112
+ enable_for_run = False
113
+ else:
114
+ # Unknown values: stay conservative.
115
+ enable_for_run = bool(getattr(cfg, "enable_deep_research", False))
116
+
117
+ if enable_for_run and is_gemini_3:
118
+ from google.genai import types
119
+
120
+ # Gemini "tool context circulation" enables built-in tools results to
121
+ # be combined with your client-side function tools in the same workflow.
122
+ tool_cfg = types.ToolConfig(include_server_side_tool_invocations=True)
123
+
124
+ if thinking_cfg is not None or tool_cfg is not None:
125
+ from google.genai import types
126
+
127
+ gen_cfg = types.GenerateContentConfig(
128
+ thinking_config=thinking_cfg,
129
+ tool_config=tool_cfg,
130
+ )
131
+
132
+ return LlmAgent(
133
+ model=cfg.model,
134
+ name="gemcode",
135
+ instruction=build_instruction(cfg),
136
+ tools=tools,
137
+ generate_content_config=gen_cfg,
138
+ **cb_kwargs,
139
+ )
140
+
141
+
142
+ def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None):
143
+ """Backward-compatible: prefer `gemcode.session_runtime.create_runner`."""
144
+ from gemcode.session_runtime import create_runner as _cr
145
+
146
+ return _cr(cfg, extra_tools=extra_tools)
gemcode/audit.py ADDED
@@ -0,0 +1,16 @@
1
+ """Structured audit log for tool invocations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def append_audit(project_root: Path, record: dict[str, Any]) -> None:
12
+ log_dir = project_root / ".gemcode"
13
+ log_dir.mkdir(parents=True, exist_ok=True)
14
+ path = log_dir / "audit.log"
15
+ line = json.dumps({"ts": time.time(), **record}, ensure_ascii=False)
16
+ path.open("a", encoding="utf-8").write(line + "\n")
gemcode/callbacks.py ADDED
@@ -0,0 +1,473 @@
1
+ """
2
+ ADK callbacks: permissions, audit, tool failure circuit breaker, usage logging.
3
+
4
+ Maps to Claude Code patterns:
5
+ - before_tool / after_tool ≈ permission gates + telemetry around tool execution
6
+ - after_model ≈ cost / usage hooks (see cost-tracker.ts role)
7
+ - Session state for streak counters ≈ autoCompact failure tracking (MVP: tool errors)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from typing import Any
15
+
16
+ from google.adk.tools.base_tool import BaseTool
17
+
18
+ from gemcode.audit import append_audit
19
+ from gemcode.config import GemCodeConfig
20
+ from gemcode.limits import SESSION_TOTAL_TOKENS_KEY
21
+ from gemcode.query.token_budget import BudgetTracker, check_token_budget, create_budget_tracker
22
+ from gemcode.tool_registry import MUTATING_TOOLS, SHELL_TOOLS
23
+
24
+ _STATE_FAILURE_KEY = "gemcode:consecutive_tool_failures"
25
+ TERMINAL_REASON_KEY = "gemcode:terminal_reason"
26
+ _BT_BASE_TOTAL_TOKENS = "gemcode:bt_base_total_tokens"
27
+ _BT_TOKEN_BUDGET_STOP = "gemcode:bt_token_budget_stop"
28
+ _ERROR_KIND_PERMISSION_DENIED = "permission_denied"
29
+ _ERROR_KIND_CIRCUIT_BREAKER = "circuit_breaker"
30
+ _ERROR_KIND_TOOL_EXCEPTION = "tool_exception"
31
+ _ERROR_KIND_PERMISSION_BLOCK = "permission_block"
32
+ _BT_CC = "gemcode:bt_cc"
33
+ _BT_LD = "gemcode:bt_ld"
34
+ _BT_LG = "gemcode:bt_lg"
35
+ _BT_T0 = "gemcode:bt_t0"
36
+
37
+ def _truthy_env(name: str, *, default: bool = False) -> bool:
38
+ v = os.environ.get(name)
39
+ if v is None:
40
+ return default
41
+ return v.lower() in ("1", "true", "yes", "on")
42
+
43
+
44
+ def _maybe_tool_summary_enabled() -> bool:
45
+ # Mirrors Claude's "emit tool use summaries" gate conceptually.
46
+ return _truthy_env("GEMCODE_EMIT_TOOL_USE_SUMMARIES", default=False)
47
+
48
+
49
+ def _redact_args(name: str, args: dict[str, Any]) -> dict[str, Any]:
50
+ out = dict(args)
51
+ if "content" in out and isinstance(out["content"], str) and len(out["content"]) > 2000:
52
+ out["content"] = out["content"][:2000] + "[...]"
53
+ return out
54
+
55
+
56
+ def _max_consecutive_failures() -> int:
57
+ return int(os.environ.get("GEMCODE_MAX_CONSECUTIVE_TOOL_FAILURES", "8"))
58
+
59
+
60
+ def _is_computer_use_tool(tool: BaseTool) -> bool:
61
+ """
62
+ Detect ADK ComputerUseTool instances without enumerating every method name.
63
+
64
+ ADK tool objects are named after the BaseComputer method (e.g. `click_at`),
65
+ so we detect by the tool class/module instead of by `tool.name`.
66
+ """
67
+ try:
68
+ cls = tool.__class__
69
+ mod = getattr(cls, "__module__", "") or ""
70
+ name = getattr(cls, "__name__", "") or ""
71
+ return name == "ComputerUseTool" and "computer_use" in mod
72
+ except Exception:
73
+ return False
74
+
75
+
76
+ def make_before_tool_callback(cfg: GemCodeConfig):
77
+ """Permission gate + circuit breaker (open after too many tool errors in a row)."""
78
+
79
+ def _tool_confirmation_state(tool_context) -> bool | None:
80
+ """
81
+ Returns:
82
+ - True => tool call was confirmed by the user for this invocation context
83
+ - False => user explicitly rejected
84
+ - None => no confirmation info present
85
+ """
86
+ if tool_context is None:
87
+ return None
88
+ try:
89
+ tc = getattr(tool_context, "tool_confirmation", None)
90
+ if tc is None:
91
+ return None
92
+ confirmed = getattr(tc, "confirmed", None)
93
+ if confirmed is None:
94
+ return None
95
+ return bool(confirmed)
96
+ except Exception:
97
+ return None
98
+
99
+ def before_tool(
100
+ tool: BaseTool,
101
+ args: dict[str, Any],
102
+ tool_context,
103
+ ) -> dict[str, Any] | None:
104
+ name = getattr(tool, "name", None) or ""
105
+ is_computer_tool = _is_computer_use_tool(tool)
106
+ record = {"tool": name, "args": _redact_args(name, args)}
107
+ append_audit(cfg.project_root, record)
108
+
109
+ streak = 0
110
+ if tool_context is not None:
111
+ try:
112
+ st = tool_context.state
113
+ streak = st.get(_STATE_FAILURE_KEY, 0)
114
+ except Exception:
115
+ pass
116
+
117
+ if streak >= _max_consecutive_failures():
118
+ if tool_context is not None:
119
+ try:
120
+ st = tool_context.state
121
+ if not st.get(TERMINAL_REASON_KEY):
122
+ st[TERMINAL_REASON_KEY] = "tool_circuit_breaker"
123
+ except Exception:
124
+ pass
125
+ return {
126
+ "error": (
127
+ f"Stopped after {streak} consecutive tool failures (circuit breaker). "
128
+ "Start a new session or fix the underlying issue."
129
+ ),
130
+ "error_kind": _ERROR_KIND_CIRCUIT_BREAKER,
131
+ }
132
+
133
+ if name in MUTATING_TOOLS or is_computer_tool:
134
+ if cfg.permission_mode == "strict":
135
+ if is_computer_tool:
136
+ return {
137
+ "error": "strict mode: computer use disabled",
138
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
139
+ }
140
+ return {
141
+ "error": "strict mode: file writes disabled",
142
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
143
+ }
144
+ if not cfg.yes_to_all:
145
+ # In-run HITL: request ADK tool confirmation and pause execution until
146
+ # the user approves in the current terminal session.
147
+ if getattr(cfg, "interactive_permission_ask", False):
148
+ tc_state = _tool_confirmation_state(tool_context)
149
+ if tc_state is True:
150
+ return None
151
+ if tc_state is False:
152
+ return {
153
+ "error": "This tool call was rejected.",
154
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
155
+ }
156
+ if tool_context is not None and hasattr(
157
+ tool_context, "request_confirmation"
158
+ ):
159
+ if is_computer_tool:
160
+ tool_context.request_confirmation(
161
+ hint="Approve to allow browser automation for the requested computer-use action."
162
+ )
163
+ else:
164
+ tool_context.request_confirmation(
165
+ hint="Approve to apply the requested file mutation (write_file/search_replace)."
166
+ )
167
+ return {
168
+ "error": "This tool call requires confirmation.",
169
+ "error_kind": _ERROR_KIND_PERMISSION_BLOCK,
170
+ }
171
+
172
+ # Default behavior: user must re-run with --yes.
173
+ if is_computer_tool:
174
+ return {
175
+ "error": (
176
+ "Computer-use tools require confirmation: re-run with --yes to allow browser automation."
177
+ ),
178
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
179
+ }
180
+ return {
181
+ "error": (
182
+ "Mutating tools require confirmation: re-run with --yes to allow "
183
+ "write_file and search_replace."
184
+ ),
185
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
186
+ }
187
+ if name in SHELL_TOOLS:
188
+ if cfg.permission_mode == "strict":
189
+ return {
190
+ "error": "strict mode: shell tools disabled",
191
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
192
+ }
193
+ if not cfg.yes_to_all:
194
+ if getattr(cfg, "interactive_permission_ask", False):
195
+ tc_state = _tool_confirmation_state(tool_context)
196
+ if tc_state is True:
197
+ return None
198
+ if tc_state is False:
199
+ return {
200
+ "error": "This tool call was rejected.",
201
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
202
+ }
203
+ if tool_context is not None and hasattr(tool_context, "request_confirmation"):
204
+ cmd = args.get("command")
205
+ cmd_args = args.get("args")
206
+ hint = f"Approve to run command: {cmd} {cmd_args}" if cmd_args else f"Approve to run command: {cmd}"
207
+ tool_context.request_confirmation(hint=hint)
208
+ return {
209
+ "error": "This tool call requires confirmation.",
210
+ "error_kind": _ERROR_KIND_PERMISSION_BLOCK,
211
+ }
212
+ return {
213
+ "error": (
214
+ "Shell tools require confirmation: re-run with --yes or --interactive-ask to allow run_command."
215
+ ),
216
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
217
+ }
218
+ return None
219
+
220
+ return before_tool
221
+
222
+
223
+ def make_after_tool_callback(cfg: GemCodeConfig):
224
+ """Track consecutive tool failures in session state (Claude-style circuit breaker)."""
225
+
226
+ def after_tool(
227
+ tool: BaseTool,
228
+ args: dict[str, Any],
229
+ tool_context,
230
+ tool_response: dict,
231
+ ) -> dict | None:
232
+ name = getattr(tool, "name", None) or ""
233
+ if tool_context is None:
234
+ return None
235
+ try:
236
+ st = tool_context.state
237
+ except Exception:
238
+ return None
239
+ err = isinstance(tool_response, dict) and tool_response.get("error")
240
+ err_kind = (
241
+ isinstance(tool_response, dict) and tool_response.get("error_kind")
242
+ )
243
+ if err:
244
+ # Only count failures that are actionable tool execution errors.
245
+ # Permission denials are policy rejections (not "tool failures") and
246
+ # should not trigger the circuit breaker.
247
+ if err_kind not in (
248
+ _ERROR_KIND_PERMISSION_DENIED,
249
+ _ERROR_KIND_CIRCUIT_BREAKER,
250
+ _ERROR_KIND_PERMISSION_BLOCK,
251
+ ):
252
+ st[_STATE_FAILURE_KEY] = st.get(_STATE_FAILURE_KEY, 0) + 1
253
+ append_audit(
254
+ cfg.project_root,
255
+ {
256
+ "phase": "tool_failure",
257
+ "tool": name,
258
+ "circuit": "fail",
259
+ "streak": st[_STATE_FAILURE_KEY],
260
+ "error_kind": err_kind,
261
+ },
262
+ )
263
+ elif err_kind in (_ERROR_KIND_PERMISSION_DENIED, _ERROR_KIND_PERMISSION_BLOCK):
264
+ # Policy denials shouldn't keep the failure streak alive.
265
+ st[_STATE_FAILURE_KEY] = 0
266
+ else:
267
+ st[_STATE_FAILURE_KEY] = 0
268
+ if _maybe_tool_summary_enabled():
269
+ summary: dict[str, Any] = {
270
+ "phase": "tool_result",
271
+ "tool": name,
272
+ }
273
+ if isinstance(tool_response, dict) and tool_response.get("error"):
274
+ summary["ok"] = False
275
+ summary["error_kind"] = err_kind
276
+ # Keep error string short; args already redacted in before_tool.
277
+ e = tool_response.get("error")
278
+ summary["error"] = str(e)[:2000] if e is not None else "unknown_error"
279
+ else:
280
+ summary["ok"] = True
281
+ # Common lightweight metadata across our tools.
282
+ for k in ("truncated", "total_bytes", "exit_code", "stdout", "stderr"):
283
+ if isinstance(tool_response, dict) and k in tool_response:
284
+ v = tool_response.get(k)
285
+ if isinstance(v, str) and len(v) > 2000:
286
+ summary[k] = v[:2000] + "[...]"
287
+ else:
288
+ summary[k] = v
289
+ append_audit(cfg.project_root, summary)
290
+ # Also print a concise, user-visible summary in CLI contexts.
291
+ # (Claude Code renders tool cards; this is the lightweight equivalent.)
292
+ try:
293
+ # Full-screen TUIs get corrupted by stray stderr prints.
294
+ if _truthy_env("GEMCODE_TUI_ACTIVE", default=False):
295
+ return None
296
+ ok = bool(summary.get("ok"))
297
+ prefix = "[tool ok]" if ok else "[tool err]"
298
+ details = ""
299
+ if isinstance(summary.get("exit_code"), int):
300
+ details += f" exit={summary['exit_code']}"
301
+ if not ok and summary.get("error_kind"):
302
+ details += f" kind={summary.get('error_kind')}"
303
+ if not ok and summary.get("error"):
304
+ details += f" error={str(summary.get('error'))[:200]}"
305
+ print(f"{prefix} {name}{details}", file=sys.stderr)
306
+ except Exception:
307
+ pass
308
+ return None
309
+
310
+ return after_tool
311
+
312
+
313
+ def _load_budget_tracker(st: Any) -> BudgetTracker:
314
+ if st.get(_BT_T0, 0) in (0, None):
315
+ bt = create_budget_tracker()
316
+ st[_BT_T0] = bt.started_at_ms
317
+ st[_BT_CC] = 0
318
+ st[_BT_LD] = 0
319
+ st[_BT_LG] = 0
320
+ return bt
321
+ return BudgetTracker(
322
+ continuation_count=int(st.get(_BT_CC, 0)),
323
+ last_delta_tokens=int(st.get(_BT_LD, 0)),
324
+ last_global_turn_tokens=int(st.get(_BT_LG, 0)),
325
+ started_at_ms=int(st.get(_BT_T0, 0)),
326
+ )
327
+
328
+
329
+ def _save_budget_tracker(st: Any, bt: BudgetTracker) -> None:
330
+ st[_BT_CC] = bt.continuation_count
331
+ st[_BT_LD] = bt.last_delta_tokens
332
+ st[_BT_LG] = bt.last_global_turn_tokens
333
+ st[_BT_T0] = bt.started_at_ms
334
+
335
+
336
+ def make_after_model_callback(cfg: GemCodeConfig):
337
+ """Log usage, accumulate session totals, optional token-budget audit (cf. cost-tracker)."""
338
+
339
+ def after_model(callback_context, llm_response):
340
+ um = getattr(llm_response, "usage_metadata", None)
341
+ st = callback_context.state
342
+ d: dict[str, Any] = {}
343
+ if um is not None:
344
+ for attr in (
345
+ "prompt_token_count",
346
+ "candidates_token_count",
347
+ "cached_content_token_count",
348
+ "total_token_count",
349
+ ):
350
+ if hasattr(um, attr):
351
+ v = getattr(um, attr)
352
+ if v is not None:
353
+ d[attr] = v
354
+ if d:
355
+ append_audit(cfg.project_root, {"phase": "model_usage", **d})
356
+
357
+ total_this = d.get("total_token_count")
358
+ if isinstance(total_this, int) and total_this >= 0:
359
+ prev_total = int(st.get(SESSION_TOTAL_TOKENS_KEY, 0) or 0)
360
+ current_total = prev_total + total_this
361
+ st[SESSION_TOTAL_TOKENS_KEY] = current_total
362
+
363
+ base = st.get(_BT_BASE_TOTAL_TOKENS, -1)
364
+ if base in (-1, None):
365
+ st[_BT_BASE_TOTAL_TOKENS] = prev_total
366
+ base = prev_total
367
+
368
+ # Per-user-message token budget: tokens consumed since turn start.
369
+ turn_tokens = int(current_total) - int(base)
370
+
371
+ if cfg.token_budget and cfg.token_budget > 0:
372
+ bt = _load_budget_tracker(st)
373
+ decision = check_token_budget(bt, None, cfg.token_budget, turn_tokens)
374
+ _save_budget_tracker(st, bt)
375
+ if decision.action == "continue":
376
+ # Make sure any prior stop is unset (defensive).
377
+ st[_BT_TOKEN_BUDGET_STOP] = False
378
+ append_audit(
379
+ cfg.project_root,
380
+ {
381
+ "phase": "token_budget",
382
+ "decision": "continue",
383
+ "msg": decision.nudge_message,
384
+ },
385
+ )
386
+ elif decision.action == "stop":
387
+ st[_BT_TOKEN_BUDGET_STOP] = True
388
+ if not st.get(TERMINAL_REASON_KEY):
389
+ st[TERMINAL_REASON_KEY] = "token_budget_stop"
390
+ if decision.completion_event:
391
+ append_audit(
392
+ cfg.project_root,
393
+ {
394
+ "phase": "token_budget",
395
+ "decision": "stop",
396
+ **decision.completion_event,
397
+ },
398
+ )
399
+
400
+ return None
401
+
402
+ return after_model
403
+
404
+
405
+ def make_on_tool_error_callback(cfg: GemCodeConfig):
406
+ """Turn tool exceptions into structured tool results (Claude-like is_error)."""
407
+
408
+ async def on_tool_error(
409
+ *, tool: BaseTool, args: dict[str, Any], tool_context, error: Exception
410
+ ):
411
+ name = getattr(tool, "name", None) or ""
412
+ if tool_context is not None:
413
+ try:
414
+ st = tool_context.state
415
+ if not st.get(TERMINAL_REASON_KEY):
416
+ st[TERMINAL_REASON_KEY] = "tool_exception"
417
+ except Exception:
418
+ pass
419
+ append_audit(
420
+ cfg.project_root,
421
+ {
422
+ "phase": "tool_exception",
423
+ "tool": name,
424
+ "error": f"{type(error).__name__}: {error}",
425
+ "args": _redact_args(name, args or {}),
426
+ },
427
+ )
428
+ # Returning an error dict makes ADK proceed as a tool_result with is_error.
429
+ return {
430
+ "error": f"{type(error).__name__}: {error}",
431
+ "error_kind": _ERROR_KIND_TOOL_EXCEPTION,
432
+ }
433
+
434
+ return on_tool_error
435
+
436
+
437
+ def make_on_model_error_callback(cfg: GemCodeConfig):
438
+ """Structured model errors to the user + audit trail."""
439
+
440
+ async def on_model_error(*, callback_context, llm_request, error: Exception):
441
+ try:
442
+ st = callback_context.state
443
+ if st is not None and not st.get(TERMINAL_REASON_KEY):
444
+ st[TERMINAL_REASON_KEY] = "model_error"
445
+ except Exception:
446
+ pass
447
+ append_audit(
448
+ cfg.project_root,
449
+ {
450
+ "phase": "model_exception",
451
+ "error": f"{type(error).__name__}: {error}",
452
+ },
453
+ )
454
+ # Best-effort fallback content; do not attempt full Claude recovery loop.
455
+ from google.adk.models.llm_response import LlmResponse
456
+ from google.genai import types
457
+
458
+ return LlmResponse(
459
+ content=types.Content(
460
+ role="model",
461
+ parts=[
462
+ types.Part(
463
+ text=(
464
+ "GemCode: model call failed. "
465
+ "Re-run the request or reduce prompt size."
466
+ )
467
+ )
468
+ ],
469
+ ),
470
+ turn_complete=True,
471
+ )
472
+
473
+ return on_model_error