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/model_routing.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model routing for GemCode.
|
|
3
|
+
|
|
4
|
+
Goal: match Claude-style "multi modes" where users can select a model mode
|
|
5
|
+
explicitly, or GemCode can choose a best-fit model automatically (no extra
|
|
6
|
+
model call; heuristic only).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from gemcode.config import GemCodeConfig
|
|
15
|
+
|
|
16
|
+
ModelMode = Literal["auto", "fast", "balanced", "quality"]
|
|
17
|
+
FamilyMode = Literal["auto", "primary", "alt"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _contains_any(haystack: str, needles: list[str]) -> bool:
|
|
21
|
+
h = haystack.lower()
|
|
22
|
+
return any(n in h for n in needles)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pick_effective_model(cfg: GemCodeConfig, prompt: str) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Returns the effective model id to use for this run.
|
|
28
|
+
|
|
29
|
+
Heuristic rules (cheap, deterministic):
|
|
30
|
+
- quality for architecture/design/refactor/complex trade-offs
|
|
31
|
+
- fast for small edits, tool-driven tasks, or quick debugging
|
|
32
|
+
- balanced as the default otherwise
|
|
33
|
+
"""
|
|
34
|
+
mode_norm = (getattr(cfg, "model_mode", "fast") or "fast").lower()
|
|
35
|
+
|
|
36
|
+
# If the user explicitly picked a model id, honor it.
|
|
37
|
+
if getattr(cfg, "model_overridden", False):
|
|
38
|
+
return cfg.model
|
|
39
|
+
|
|
40
|
+
# Optional deep research routing: when user asks explicitly (flag) or when
|
|
41
|
+
# `model_mode=auto` and the prompt requests research-like output.
|
|
42
|
+
deep_research_triggers = [
|
|
43
|
+
"deep research",
|
|
44
|
+
"deep-dive",
|
|
45
|
+
"research",
|
|
46
|
+
"sources",
|
|
47
|
+
"citations",
|
|
48
|
+
"grounded",
|
|
49
|
+
"investigate",
|
|
50
|
+
"literature",
|
|
51
|
+
"benchmark",
|
|
52
|
+
]
|
|
53
|
+
prompt_norm = re.sub(r"\s+", " ", prompt or "").strip().lower()
|
|
54
|
+
if getattr(cfg, "enable_deep_research", False):
|
|
55
|
+
return getattr(cfg, "model_deep_research", None) or cfg.model
|
|
56
|
+
if mode_norm == "auto" and _contains_any(prompt_norm, deep_research_triggers):
|
|
57
|
+
return getattr(cfg, "model_deep_research", None) or cfg.model
|
|
58
|
+
|
|
59
|
+
# Capability precedence: computer-use model selection.
|
|
60
|
+
# (Deep research already handled above.)
|
|
61
|
+
if getattr(cfg, "enable_audio", False):
|
|
62
|
+
return getattr(cfg, "model_audio_live", None) or cfg.model
|
|
63
|
+
if getattr(cfg, "enable_computer_use", False):
|
|
64
|
+
return getattr(cfg, "model_computer_use", None) or cfg.model
|
|
65
|
+
|
|
66
|
+
primary_fast = cfg.model
|
|
67
|
+
primary_quality = getattr(cfg, "model_quality", None) or primary_fast
|
|
68
|
+
primary_balanced = getattr(cfg, "model_balanced", None) or primary_fast
|
|
69
|
+
|
|
70
|
+
alt_fast = getattr(cfg, "model_alt", None) or primary_fast
|
|
71
|
+
alt_quality = getattr(cfg, "model_alt_quality", None) or primary_quality
|
|
72
|
+
alt_balanced = getattr(cfg, "model_alt_balanced", None) or primary_balanced
|
|
73
|
+
|
|
74
|
+
if mode_norm not in ("auto", "fast", "balanced", "quality"):
|
|
75
|
+
return primary_fast
|
|
76
|
+
|
|
77
|
+
def decide_base_mode() -> Literal["fast", "balanced", "quality"]:
|
|
78
|
+
if mode_norm == "fast":
|
|
79
|
+
return "fast"
|
|
80
|
+
if mode_norm == "balanced":
|
|
81
|
+
return "balanced"
|
|
82
|
+
if mode_norm == "quality":
|
|
83
|
+
return "quality"
|
|
84
|
+
|
|
85
|
+
# auto mode: choose base mode using prompt heuristics.
|
|
86
|
+
p_norm = re.sub(r"\s+", " ", prompt or "").strip()
|
|
87
|
+
plen = len(p_norm)
|
|
88
|
+
|
|
89
|
+
quality_triggers = [
|
|
90
|
+
"architecture",
|
|
91
|
+
"design",
|
|
92
|
+
"system",
|
|
93
|
+
"refactor",
|
|
94
|
+
"trade",
|
|
95
|
+
"complex",
|
|
96
|
+
"scal",
|
|
97
|
+
"performance",
|
|
98
|
+
"profil",
|
|
99
|
+
"migration",
|
|
100
|
+
"schema",
|
|
101
|
+
"how would you",
|
|
102
|
+
"deep dive",
|
|
103
|
+
]
|
|
104
|
+
fast_triggers = [
|
|
105
|
+
"fix",
|
|
106
|
+
"bug",
|
|
107
|
+
"error",
|
|
108
|
+
"tests",
|
|
109
|
+
"pytest",
|
|
110
|
+
"debug",
|
|
111
|
+
"failing",
|
|
112
|
+
"lint",
|
|
113
|
+
"format",
|
|
114
|
+
"quick",
|
|
115
|
+
"small change",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
plen > 2_000
|
|
120
|
+
and _contains_any(p_norm, ["design", "architecture", "refactor", "trade"])
|
|
121
|
+
):
|
|
122
|
+
return "quality"
|
|
123
|
+
|
|
124
|
+
if _contains_any(p_norm, quality_triggers):
|
|
125
|
+
return "quality"
|
|
126
|
+
|
|
127
|
+
if _contains_any(p_norm, fast_triggers):
|
|
128
|
+
return "fast"
|
|
129
|
+
|
|
130
|
+
# If prompt is long but not explicitly complex, balanced tends to be safer.
|
|
131
|
+
if plen > 4_000:
|
|
132
|
+
return "balanced"
|
|
133
|
+
|
|
134
|
+
return "balanced"
|
|
135
|
+
|
|
136
|
+
base_mode = decide_base_mode()
|
|
137
|
+
|
|
138
|
+
# Decide model family (primary vs 2.5-alt).
|
|
139
|
+
fam = (getattr(cfg, "model_family_mode", "auto") or "auto").lower()
|
|
140
|
+
if fam not in ("auto", "primary", "alt"):
|
|
141
|
+
fam = "auto"
|
|
142
|
+
|
|
143
|
+
p_norm2 = re.sub(r"\s+", " ", prompt or "").strip()
|
|
144
|
+
# Reuse quality triggers: complex prompts get primary (3.x); simpler prompts
|
|
145
|
+
# prefer alt (2.5) by default in `auto` family mode.
|
|
146
|
+
complex_triggers = [
|
|
147
|
+
"architecture",
|
|
148
|
+
"design",
|
|
149
|
+
"system",
|
|
150
|
+
"refactor",
|
|
151
|
+
"trade",
|
|
152
|
+
"complex",
|
|
153
|
+
"performance",
|
|
154
|
+
"migration",
|
|
155
|
+
"schema",
|
|
156
|
+
"deep dive",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
choose_primary: bool
|
|
160
|
+
if fam == "primary":
|
|
161
|
+
choose_primary = True
|
|
162
|
+
elif fam == "alt":
|
|
163
|
+
choose_primary = False
|
|
164
|
+
else:
|
|
165
|
+
choose_primary = _contains_any(p_norm2, complex_triggers)
|
|
166
|
+
|
|
167
|
+
if choose_primary:
|
|
168
|
+
if base_mode == "fast":
|
|
169
|
+
return primary_fast
|
|
170
|
+
if base_mode == "balanced":
|
|
171
|
+
return primary_balanced
|
|
172
|
+
return primary_quality
|
|
173
|
+
|
|
174
|
+
if base_mode == "fast":
|
|
175
|
+
return alt_fast
|
|
176
|
+
if base_mode == "balanced":
|
|
177
|
+
return alt_balanced
|
|
178
|
+
return alt_quality
|
|
179
|
+
|
gemcode/paths.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Safe path resolution under a project root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PathEscapeError(ValueError):
|
|
9
|
+
"""Resolved path would leave the project sandbox."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_under_root(project_root: Path, rel: str) -> Path:
|
|
13
|
+
"""
|
|
14
|
+
Resolve a user-relative path against project_root.
|
|
15
|
+
|
|
16
|
+
Rejects absolute paths that escape the root (symlink traversal is still a
|
|
17
|
+
concern for production; MVP uses realpath check).
|
|
18
|
+
"""
|
|
19
|
+
root = project_root.resolve()
|
|
20
|
+
raw = Path(rel)
|
|
21
|
+
if raw.is_absolute():
|
|
22
|
+
candidate = raw.resolve()
|
|
23
|
+
else:
|
|
24
|
+
candidate = (root / raw).resolve()
|
|
25
|
+
try:
|
|
26
|
+
candidate.relative_to(root)
|
|
27
|
+
except ValueError as e:
|
|
28
|
+
raise PathEscapeError(f"Path outside project root: {rel!r}") from e
|
|
29
|
+
return candidate
|
gemcode/permissions.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ADK plugin: complements Claude-like stopHooks with GemCode terminal reasons,
|
|
3
|
+
optional memory ingestion, and post-turn hook execution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from google.adk.plugins.base_plugin import BasePlugin
|
|
13
|
+
|
|
14
|
+
from google.adk.models.google_llm import Gemini
|
|
15
|
+
from google.adk.models.llm_request import LlmRequest
|
|
16
|
+
from google.genai import types
|
|
17
|
+
|
|
18
|
+
from gemcode.config import GemCodeConfig
|
|
19
|
+
from gemcode.query.stop_hooks import run_post_turn_hooks
|
|
20
|
+
from gemcode.audit import append_audit
|
|
21
|
+
from gemcode.prompt_suggestions import build_prompt_suggestion
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GemCodeTerminalHooksPlugin(BasePlugin):
|
|
25
|
+
def __init__(self, cfg: GemCodeConfig):
|
|
26
|
+
super().__init__(name="gemcode_terminal_hooks")
|
|
27
|
+
self.cfg = cfg
|
|
28
|
+
|
|
29
|
+
def _use_interactions_for_prompt_suggestions(self) -> bool:
|
|
30
|
+
v = os.environ.get("GEMCODE_PROMPT_SUGGESTIONS_USE_INTERACTIONS", "1")
|
|
31
|
+
return v.lower() in ("1", "true", "yes", "on")
|
|
32
|
+
|
|
33
|
+
def _find_previous_interaction_id(
|
|
34
|
+
self, *, callback_context: Any, agent_name: str
|
|
35
|
+
) -> str | None:
|
|
36
|
+
# Interactions chaining uses `previous_interaction_id` extracted from the
|
|
37
|
+
# most recent model response event for the same agent.
|
|
38
|
+
try:
|
|
39
|
+
events = callback_context.session.events or []
|
|
40
|
+
except Exception:
|
|
41
|
+
return None
|
|
42
|
+
for ev in reversed(events):
|
|
43
|
+
if getattr(ev, "author", None) == agent_name and getattr(
|
|
44
|
+
ev, "interaction_id", None
|
|
45
|
+
):
|
|
46
|
+
return ev.interaction_id
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def _suggest_via_interactions(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
terminal_reason: str,
|
|
53
|
+
callback_context: Any,
|
|
54
|
+
agent: Any,
|
|
55
|
+
heuristic: str,
|
|
56
|
+
) -> str | None:
|
|
57
|
+
if not self._use_interactions_for_prompt_suggestions():
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
previous_interaction_id = self._find_previous_interaction_id(
|
|
62
|
+
callback_context=callback_context,
|
|
63
|
+
agent_name=getattr(agent, "name", "gemcode"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
prompt = (
|
|
67
|
+
"You are GemCode. Provide the best next-step guidance for the user. "
|
|
68
|
+
"Terminal reason: {reason}. "
|
|
69
|
+
"Write a single short sentence (<= 220 chars). "
|
|
70
|
+
"If it involves policy/actions, reference exact flags like `--yes` or `--session`.\n\n"
|
|
71
|
+
"Heuristic suggestion (may be imperfect): {heuristic}"
|
|
72
|
+
).format(reason=terminal_reason, heuristic=heuristic)
|
|
73
|
+
|
|
74
|
+
llm = Gemini(model=self.cfg.model, use_interactions_api=True)
|
|
75
|
+
req = LlmRequest(
|
|
76
|
+
model=self.cfg.model,
|
|
77
|
+
contents=[
|
|
78
|
+
types.Content(
|
|
79
|
+
role="user",
|
|
80
|
+
parts=[types.Part(text=prompt)],
|
|
81
|
+
)
|
|
82
|
+
],
|
|
83
|
+
config=types.GenerateContentConfig(),
|
|
84
|
+
)
|
|
85
|
+
if previous_interaction_id:
|
|
86
|
+
req.previous_interaction_id = previous_interaction_id
|
|
87
|
+
|
|
88
|
+
async for resp in llm.generate_content_async(req, stream=False):
|
|
89
|
+
if resp.content and resp.content.parts:
|
|
90
|
+
texts = [
|
|
91
|
+
getattr(p, "text", None)
|
|
92
|
+
for p in resp.content.parts
|
|
93
|
+
if getattr(p, "text", None)
|
|
94
|
+
]
|
|
95
|
+
if texts:
|
|
96
|
+
out = "".join(texts).strip()
|
|
97
|
+
if out:
|
|
98
|
+
return out
|
|
99
|
+
return None
|
|
100
|
+
except Exception as e:
|
|
101
|
+
append_audit(
|
|
102
|
+
self.cfg.project_root,
|
|
103
|
+
{
|
|
104
|
+
"phase": "prompt_suggestion_interactions",
|
|
105
|
+
"ok": False,
|
|
106
|
+
"error": str(e),
|
|
107
|
+
"terminal_reason": terminal_reason,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
async def after_agent_callback(self, *, agent: Any, callback_context: Any):
|
|
113
|
+
# callback_context is an ADK Context (mutable state + helper methods).
|
|
114
|
+
state = callback_context.state
|
|
115
|
+
terminal_reason = state.get("gemcode:terminal_reason", None)
|
|
116
|
+
if not terminal_reason:
|
|
117
|
+
terminal_reason = "completed"
|
|
118
|
+
|
|
119
|
+
append_audit(self.cfg.project_root, {"phase": "terminal", "reason": terminal_reason})
|
|
120
|
+
|
|
121
|
+
heuristic = build_prompt_suggestion(
|
|
122
|
+
self.cfg, terminal_reason=terminal_reason
|
|
123
|
+
)
|
|
124
|
+
if heuristic:
|
|
125
|
+
suggestion = heuristic
|
|
126
|
+
suggestion_via_interactions = await self._suggest_via_interactions(
|
|
127
|
+
terminal_reason=terminal_reason,
|
|
128
|
+
callback_context=callback_context,
|
|
129
|
+
agent=agent,
|
|
130
|
+
heuristic=heuristic,
|
|
131
|
+
)
|
|
132
|
+
if suggestion_via_interactions:
|
|
133
|
+
suggestion = suggestion_via_interactions
|
|
134
|
+
|
|
135
|
+
append_audit(
|
|
136
|
+
self.cfg.project_root,
|
|
137
|
+
{
|
|
138
|
+
"phase": "prompt_suggestion",
|
|
139
|
+
"terminal_reason": terminal_reason,
|
|
140
|
+
"suggestion": suggestion,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if getattr(self.cfg, "enable_memory", False):
|
|
145
|
+
try:
|
|
146
|
+
await callback_context.add_session_to_memory()
|
|
147
|
+
append_audit(self.cfg.project_root, {"phase": "memory_ingest", "ok": True})
|
|
148
|
+
except Exception as e:
|
|
149
|
+
append_audit(
|
|
150
|
+
self.cfg.project_root,
|
|
151
|
+
{"phase": "memory_ingest", "ok": False, "error": str(e)},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Execute stopHooks-like script hook at the end of the invocation.
|
|
155
|
+
try:
|
|
156
|
+
run_post_turn_hooks(
|
|
157
|
+
self.cfg,
|
|
158
|
+
session_id=callback_context.session.id,
|
|
159
|
+
user_id=callback_context.user_id,
|
|
160
|
+
)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
append_audit(
|
|
163
|
+
self.cfg.project_root,
|
|
164
|
+
{"phase": "post_turn_hook", "ok": False, "error": str(e)},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return None
|
|
168
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude-like recovery-loop for GemCode tool failures.
|
|
3
|
+
|
|
4
|
+
We complement ADK's `ReflectAndRetryToolPlugin` by treating our tool result
|
|
5
|
+
dicts like `{"error": "...", "error_kind": "..."}` as retryable tool failures
|
|
6
|
+
so the model gets reflection guidance and can try a corrected approach.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from google.adk.plugins.reflect_retry_tool_plugin import (
|
|
15
|
+
ReflectAndRetryToolPlugin,
|
|
16
|
+
TrackingScope,
|
|
17
|
+
)
|
|
18
|
+
from google.adk.tools.base_tool import BaseTool
|
|
19
|
+
from google.adk.tools.tool_context import ToolContext
|
|
20
|
+
|
|
21
|
+
from gemcode.audit import append_audit
|
|
22
|
+
from gemcode.config import GemCodeConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_STATE_FAILURE_KEY = "gemcode:consecutive_tool_failures"
|
|
26
|
+
_TERMINAL_REASON_KEY = "gemcode:terminal_reason"
|
|
27
|
+
|
|
28
|
+
_ERROR_KIND_PERMISSION_DENIED = "permission_denied"
|
|
29
|
+
_ERROR_KIND_PERMISSION_BLOCK = "permission_block"
|
|
30
|
+
_ERROR_KIND_CIRCUIT_BREAKER = "circuit_breaker"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GemCodeReflectAndRetryToolPlugin(ReflectAndRetryToolPlugin):
|
|
34
|
+
"""Retry tool failures with reflection guidance (Claude recovery-loop)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, cfg: GemCodeConfig):
|
|
37
|
+
self.cfg = cfg
|
|
38
|
+
|
|
39
|
+
enabled = os.environ.get("GEMCODE_ENABLE_TOOL_RECOVERY_RETRY", "1").lower()
|
|
40
|
+
if enabled not in ("1", "true", "yes", "on"):
|
|
41
|
+
# Still construct; we just set max_retries=0 so it becomes inert.
|
|
42
|
+
max_retries = 0
|
|
43
|
+
else:
|
|
44
|
+
max_retries = int(os.environ.get("GEMCODE_TOOL_REFLECT_MAX_RETRIES", "1"))
|
|
45
|
+
|
|
46
|
+
super().__init__(
|
|
47
|
+
max_retries=max_retries,
|
|
48
|
+
throw_exception_if_retry_exceeded=False,
|
|
49
|
+
tracking_scope=TrackingScope.INVOCATION,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def extract_error_from_result(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
tool: BaseTool,
|
|
56
|
+
tool_args: dict[str, Any],
|
|
57
|
+
tool_context: ToolContext,
|
|
58
|
+
result: Any,
|
|
59
|
+
) -> Optional[Exception]:
|
|
60
|
+
"""
|
|
61
|
+
Treat `{ "error": ... }` tool results as retryable failures.
|
|
62
|
+
|
|
63
|
+
Important: skip policy rejections (permission denials / circuit breaker)
|
|
64
|
+
so we don't waste retries on user-actionable gating.
|
|
65
|
+
"""
|
|
66
|
+
if not isinstance(result, dict):
|
|
67
|
+
return None
|
|
68
|
+
if "error" not in result:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
err_kind = result.get("error_kind")
|
|
72
|
+
if err_kind in (
|
|
73
|
+
_ERROR_KIND_PERMISSION_DENIED,
|
|
74
|
+
_ERROR_KIND_PERMISSION_BLOCK,
|
|
75
|
+
_ERROR_KIND_CIRCUIT_BREAKER,
|
|
76
|
+
):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Update GemCode streak/terminal state since canonical agent callbacks
|
|
80
|
+
# are likely short-circuited when this plugin returns a reflection.
|
|
81
|
+
try:
|
|
82
|
+
st = tool_context.state
|
|
83
|
+
st[_STATE_FAILURE_KEY] = st.get(_STATE_FAILURE_KEY, 0) + 1
|
|
84
|
+
if not st.get(_TERMINAL_REASON_KEY):
|
|
85
|
+
st[_TERMINAL_REASON_KEY] = "tool_retryable_error"
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
err = result.get("error")
|
|
90
|
+
err_text = err if isinstance(err, str) else str(err)
|
|
91
|
+
append_audit(
|
|
92
|
+
self.cfg.project_root,
|
|
93
|
+
{
|
|
94
|
+
"phase": "tool_recovery_retry",
|
|
95
|
+
"tool": tool.name,
|
|
96
|
+
"error_kind": err_kind,
|
|
97
|
+
"error": err_text,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return Exception(err_text)
|
|
102
|
+
|
|
103
|
+
async def on_tool_error_callback(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
tool: BaseTool,
|
|
107
|
+
tool_args: dict[str, Any],
|
|
108
|
+
tool_context: ToolContext,
|
|
109
|
+
error: Exception,
|
|
110
|
+
) -> Optional[dict[str, Any]]:
|
|
111
|
+
"""Ensure GemCode streak/terminal state is updated on exceptions."""
|
|
112
|
+
try:
|
|
113
|
+
st = tool_context.state
|
|
114
|
+
st[_STATE_FAILURE_KEY] = st.get(_STATE_FAILURE_KEY, 0) + 1
|
|
115
|
+
if not st.get(_TERMINAL_REASON_KEY):
|
|
116
|
+
st[_TERMINAL_REASON_KEY] = "tool_exception"
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
append_audit(
|
|
121
|
+
self.cfg.project_root,
|
|
122
|
+
{
|
|
123
|
+
"phase": "tool_recovery_exception",
|
|
124
|
+
"tool": tool.name,
|
|
125
|
+
"error": f"{type(error).__name__}: {error}",
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return await super().on_tool_error_callback(
|
|
130
|
+
tool=tool,
|
|
131
|
+
tool_args=tool_args,
|
|
132
|
+
tool_context=tool_context,
|
|
133
|
+
error=error,
|
|
134
|
+
)
|
|
135
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heuristic next-step guidance (Claude stopHooks-style).
|
|
3
|
+
|
|
4
|
+
We can't perfectly replicate Claude's UI-level "next suggestion job" without
|
|
5
|
+
extra model calls, but we can produce reliable, deterministic guidance from
|
|
6
|
+
GemCode's recorded `terminal_reason`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from gemcode.config import GemCodeConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _truthy_env(name: str, *, default: bool = False) -> bool:
|
|
17
|
+
v = os.environ.get(name)
|
|
18
|
+
if v is None:
|
|
19
|
+
return default
|
|
20
|
+
return v.lower() in ("1", "true", "yes", "on")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_prompt_suggestion(
|
|
24
|
+
cfg: GemCodeConfig, *, terminal_reason: str
|
|
25
|
+
) -> str | None:
|
|
26
|
+
if not _truthy_env("GEMCODE_ENABLE_PROMPT_SUGGESTIONS", default=True):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
r = terminal_reason
|
|
30
|
+
if r in ("completed", ""):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
if r == "permission_denied":
|
|
34
|
+
return (
|
|
35
|
+
"Some actions were blocked by policy. Re-run with `--yes` (or add the "
|
|
36
|
+
"needed command to `GEMCODE_ALLOW_COMMANDS` when in strict mode), then "
|
|
37
|
+
"try again with the same request."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if r == "tool_circuit_breaker":
|
|
41
|
+
return (
|
|
42
|
+
"Tool execution is being halted by the circuit breaker. Start a new "
|
|
43
|
+
"session (`--session <new_id>`), then either fix the failing tool "
|
|
44
|
+
"inputs or increase `GEMCODE_MAX_CONSECUTIVE_TOOL_FAILURES`."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if r == "session_token_limit":
|
|
48
|
+
return (
|
|
49
|
+
"This session exceeded the token ceiling. Start a new session (new "
|
|
50
|
+
"`--session` id) or raise `GEMCODE_MAX_SESSION_TOKENS`, then re-run "
|
|
51
|
+
"the request."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if r == "token_budget_stop":
|
|
55
|
+
return (
|
|
56
|
+
"Per-turn token budget was exhausted. Re-run the request (or split it "
|
|
57
|
+
"into smaller steps). If you want more room, increase "
|
|
58
|
+
"`GEMCODE_TOKEN_BUDGET` or reduce the prompt/context."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if r in ("tool_exception", "tool_retryable_error"):
|
|
62
|
+
return (
|
|
63
|
+
"A tool raised an exception. Check `.gemcode/audit.log` for the tool "
|
|
64
|
+
"error details, then re-run with corrected inputs or fewer/shorter "
|
|
65
|
+
"arguments."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if r == "model_error":
|
|
69
|
+
return (
|
|
70
|
+
"The model call failed. Try again, reduce prompt size, or switch to a "
|
|
71
|
+
"different model via `--model`."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Generic fallback.
|
|
75
|
+
return (
|
|
76
|
+
f"The run ended with terminal reason `{terminal_reason}`. Check "
|
|
77
|
+
f"`.gemcode/audit.log`, then re-run with a narrower request or after "
|
|
78
|
+
f"adjusting limits/policy flags."
|
|
79
|
+
)
|
|
80
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query-layer types and helpers (clean-room analogue of Claude Code `src/query/*`).
|
|
3
|
+
|
|
4
|
+
- `transitions` — terminal vs continue reasons for the model↔tool loop.
|
|
5
|
+
- `config` — immutable gate snapshot per run (env/session).
|
|
6
|
+
- `token_budget` — continuation/stop decisions vs a per-turn token budget.
|
|
7
|
+
- `deps` — injectable dependencies for tests.
|
|
8
|
+
- `stop_hooks` — optional post-turn subprocess hooks.
|
|
9
|
+
- `engine` — `GemCodeQueryEngine` facade (outer session + submit message).
|
|
10
|
+
|
|
11
|
+
The ADK `Runner` still executes the inner loop; these modules document parity and
|
|
12
|
+
host logic that maps to `query.ts` / `QueryEngine.ts` responsibilities.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from gemcode.query.config import QueryGates, build_query_gates
|
|
16
|
+
from gemcode.query.token_budget import (
|
|
17
|
+
BudgetTracker,
|
|
18
|
+
TokenBudgetDecision,
|
|
19
|
+
check_token_budget,
|
|
20
|
+
get_budget_continuation_message,
|
|
21
|
+
)
|
|
22
|
+
from gemcode.query.transitions import Continue, Terminal
|
|
23
|
+
|
|
24
|
+
# Note: import `GemCodeQueryEngine` from `gemcode.query.engine` to avoid import cycles
|
|
25
|
+
# (engine pulls session_runtime → agent → callbacks → query.token_budget).
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BudgetTracker",
|
|
29
|
+
"Continue",
|
|
30
|
+
"QueryGates",
|
|
31
|
+
"Terminal",
|
|
32
|
+
"TokenBudgetDecision",
|
|
33
|
+
"build_query_gates",
|
|
34
|
+
"check_token_budget",
|
|
35
|
+
"get_budget_continuation_message",
|
|
36
|
+
]
|
gemcode/query/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Immutable gate snapshot at query entry (cf. `query/config.ts`).
|
|
3
|
+
|
|
4
|
+
Feature flags in Claude use `feature()` for tree-shaking; we use env + this struct.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _truthy(name: str, default: bool = False) -> bool:
|
|
14
|
+
v = os.environ.get(name)
|
|
15
|
+
if v is None:
|
|
16
|
+
return default
|
|
17
|
+
return v.lower() in ("1", "true", "yes", "on")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class QueryGates:
|
|
22
|
+
"""Runtime gates (env), snapshotted once per invocation."""
|
|
23
|
+
|
|
24
|
+
emit_tool_use_summaries: bool
|
|
25
|
+
fast_mode_enabled: bool
|
|
26
|
+
streaming_tool_execution: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_query_gates() -> QueryGates:
|
|
30
|
+
"""Snapshot env-driven gates (no network)."""
|
|
31
|
+
return QueryGates(
|
|
32
|
+
emit_tool_use_summaries=_truthy("GEMCODE_EMIT_TOOL_USE_SUMMARIES"),
|
|
33
|
+
fast_mode_enabled=not _truthy("GEMCODE_DISABLE_FAST_MODE"),
|
|
34
|
+
streaming_tool_execution=_truthy("GEMCODE_STREAMING_TOOL_EXEC", default=True),
|
|
35
|
+
)
|
gemcode/query/deps.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Injectable dependencies (cf. claude-code `query/deps.ts`).
|
|
3
|
+
|
|
4
|
+
Kept minimal: tests can replace `uuid` without patching modules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid as uuid_mod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class QueryDeps:
|
|
16
|
+
uuid: Callable[[], str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def production_deps() -> QueryDeps:
|
|
20
|
+
return QueryDeps(uuid=lambda: str(uuid_mod.uuid4()))
|