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.
- gemcode/__init__.py +3 -0
- gemcode/__main__.py +3 -0
- gemcode/agent.py +146 -0
- gemcode/audit.py +16 -0
- gemcode/callbacks.py +473 -0
- gemcode/capability_routing.py +137 -0
- gemcode/cli.py +658 -0
- gemcode/compaction.py +35 -0
- gemcode/computer_use/__init__.py +0 -0
- gemcode/computer_use/browser_computer.py +275 -0
- gemcode/config.py +247 -0
- gemcode/interactions.py +15 -0
- gemcode/invoke.py +151 -0
- gemcode/kairos_daemon.py +221 -0
- gemcode/limits.py +83 -0
- gemcode/live_audio_engine.py +124 -0
- gemcode/mcp_loader.py +57 -0
- gemcode/memory/__init__.py +0 -0
- gemcode/memory/embedding_memory_service.py +292 -0
- gemcode/memory/file_memory_service.py +176 -0
- gemcode/modality_tools.py +216 -0
- gemcode/model_routing.py +179 -0
- gemcode/paths.py +29 -0
- gemcode/permissions.py +5 -0
- gemcode/plugins/__init__.py +0 -0
- gemcode/plugins/terminal_hooks_plugin.py +168 -0
- gemcode/plugins/tool_recovery_plugin.py +135 -0
- gemcode/prompt_suggestions.py +80 -0
- gemcode/query/__init__.py +36 -0
- gemcode/query/config.py +35 -0
- gemcode/query/deps.py +20 -0
- gemcode/query/engine.py +55 -0
- gemcode/query/stop_hooks.py +63 -0
- gemcode/query/token_budget.py +109 -0
- gemcode/query/transitions.py +41 -0
- gemcode/session_runtime.py +81 -0
- gemcode/thinking.py +136 -0
- gemcode/tool_prompt_manifest.py +118 -0
- gemcode/tool_registry.py +50 -0
- gemcode/tools/__init__.py +25 -0
- gemcode/tools/edit.py +53 -0
- gemcode/tools/filesystem.py +73 -0
- gemcode/tools/search.py +85 -0
- gemcode/tools/shell.py +73 -0
- gemcode/tools_inspector.py +132 -0
- gemcode/trust.py +54 -0
- gemcode/tui/app.py +697 -0
- gemcode/tui/scrollback.py +312 -0
- gemcode/vertex.py +22 -0
- gemcode/web/__init__.py +2 -0
- gemcode/web/claude_sse_adapter.py +282 -0
- gemcode/web/terminal_repl.py +147 -0
- gemcode-0.2.2.dist-info/METADATA +440 -0
- gemcode-0.2.2.dist-info/RECORD +58 -0
- gemcode-0.2.2.dist-info/WHEEL +5 -0
- gemcode-0.2.2.dist-info/entry_points.txt +2 -0
- gemcode-0.2.2.dist-info/licenses/LICENSE +151 -0
- gemcode-0.2.2.dist-info/top_level.txt +1 -0
gemcode/__init__.py
ADDED
gemcode/__main__.py
ADDED
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
|