gemcode 0.2.2__tar.gz → 0.3.0__tar.gz

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 (102) hide show
  1. {gemcode-0.2.2/src/gemcode.egg-info → gemcode-0.3.0}/PKG-INFO +1 -1
  2. {gemcode-0.2.2 → gemcode-0.3.0}/pyproject.toml +1 -1
  3. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/agent.py +33 -2
  4. gemcode-0.3.0/src/gemcode/autocompact.py +210 -0
  5. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/callbacks.py +118 -11
  6. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/cli.py +143 -18
  7. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/compaction.py +6 -1
  8. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/config.py +51 -1
  9. gemcode-0.3.0/src/gemcode/context_budget.py +342 -0
  10. gemcode-0.3.0/src/gemcode/context_warning.py +126 -0
  11. gemcode-0.3.0/src/gemcode/credentials.py +49 -0
  12. gemcode-0.3.0/src/gemcode/hitl_session.py +5 -0
  13. gemcode-0.3.0/src/gemcode/invoke.py +237 -0
  14. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/limits.py +5 -5
  15. gemcode-0.3.0/src/gemcode/model_errors.py +87 -0
  16. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/prompt_suggestions.py +4 -2
  17. gemcode-0.3.0/src/gemcode/repl_commands.py +178 -0
  18. gemcode-0.3.0/src/gemcode/repl_slash.py +218 -0
  19. gemcode-0.3.0/src/gemcode/slash_commands.py +34 -0
  20. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tool_prompt_manifest.py +28 -7
  21. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tool_registry.py +5 -1
  22. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tools/__init__.py +5 -1
  23. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tools/filesystem.py +14 -1
  24. gemcode-0.3.0/src/gemcode/tools/shell.py +159 -0
  25. gemcode-0.3.0/src/gemcode/tools/shell_gate.py +28 -0
  26. gemcode-0.3.0/src/gemcode/tools/todo.py +96 -0
  27. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tui/app.py +46 -2
  28. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tui/scrollback.py +25 -2
  29. {gemcode-0.2.2 → gemcode-0.3.0/src/gemcode.egg-info}/PKG-INFO +1 -1
  30. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode.egg-info/SOURCES.txt +20 -0
  31. gemcode-0.3.0/tests/test_autocompact.py +71 -0
  32. gemcode-0.3.0/tests/test_context_budget.py +60 -0
  33. gemcode-0.3.0/tests/test_context_warning.py +42 -0
  34. gemcode-0.3.0/tests/test_credentials.py +41 -0
  35. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_interactive_permission_ask.py +21 -0
  36. gemcode-0.3.0/tests/test_model_error_retry.py +55 -0
  37. gemcode-0.3.0/tests/test_model_errors.py +28 -0
  38. gemcode-0.3.0/tests/test_repl_commands.py +52 -0
  39. gemcode-0.3.0/tests/test_repl_slash.py +86 -0
  40. gemcode-0.3.0/tests/test_slash_commands.py +25 -0
  41. gemcode-0.3.0/tests/test_tools.py +139 -0
  42. gemcode-0.2.2/src/gemcode/invoke.py +0 -151
  43. gemcode-0.2.2/src/gemcode/tools/shell.py +0 -73
  44. gemcode-0.2.2/tests/test_tools.py +0 -22
  45. {gemcode-0.2.2 → gemcode-0.3.0}/LICENSE +0 -0
  46. {gemcode-0.2.2 → gemcode-0.3.0}/MANIFEST.in +0 -0
  47. {gemcode-0.2.2 → gemcode-0.3.0}/README.md +0 -0
  48. {gemcode-0.2.2 → gemcode-0.3.0}/setup.cfg +0 -0
  49. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/__init__.py +0 -0
  50. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/__main__.py +0 -0
  51. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/audit.py +0 -0
  52. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/capability_routing.py +0 -0
  53. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/computer_use/__init__.py +0 -0
  54. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/computer_use/browser_computer.py +0 -0
  55. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/interactions.py +0 -0
  56. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/kairos_daemon.py +0 -0
  57. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/live_audio_engine.py +0 -0
  58. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/mcp_loader.py +0 -0
  59. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/memory/__init__.py +0 -0
  60. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/memory/embedding_memory_service.py +0 -0
  61. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/memory/file_memory_service.py +0 -0
  62. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/modality_tools.py +0 -0
  63. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/model_routing.py +0 -0
  64. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/paths.py +0 -0
  65. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/permissions.py +0 -0
  66. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/plugins/__init__.py +0 -0
  67. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  68. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  69. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/query/__init__.py +0 -0
  70. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/query/config.py +0 -0
  71. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/query/deps.py +0 -0
  72. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/query/engine.py +0 -0
  73. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/query/stop_hooks.py +0 -0
  74. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/query/token_budget.py +0 -0
  75. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/query/transitions.py +0 -0
  76. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/session_runtime.py +0 -0
  77. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/thinking.py +0 -0
  78. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tools/edit.py +0 -0
  79. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tools/search.py +0 -0
  80. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/tools_inspector.py +0 -0
  81. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/trust.py +0 -0
  82. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/vertex.py +0 -0
  83. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/web/__init__.py +0 -0
  84. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/web/claude_sse_adapter.py +0 -0
  85. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode/web/terminal_repl.py +0 -0
  86. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode.egg-info/dependency_links.txt +0 -0
  87. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode.egg-info/entry_points.txt +0 -0
  88. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode.egg-info/requires.txt +0 -0
  89. {gemcode-0.2.2 → gemcode-0.3.0}/src/gemcode.egg-info/top_level.txt +0 -0
  90. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_capability_routing.py +0 -0
  91. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_claude_web_adapter_sse.py +0 -0
  92. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_computer_use_permissions.py +0 -0
  93. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_kairos_scheduler.py +0 -0
  94. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_modality_tools.py +0 -0
  95. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_model_routing.py +0 -0
  96. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_paths.py +0 -0
  97. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_permissions.py +0 -0
  98. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_prompt_suggestions.py +0 -0
  99. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_thinking_config.py +0 -0
  100. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_token_budget.py +0 -0
  101. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_tool_context_circulation.py +0 -0
  102. {gemcode-0.2.2 → gemcode-0.3.0}/tests/test_tools_inspector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
 
13
13
  from google.adk.agents.llm_agent import LlmAgent
14
14
 
15
+ from gemcode.autocompact import make_before_model_autocompact_callback
15
16
  from gemcode.callbacks import (
16
17
  make_after_model_callback,
17
18
  make_after_tool_callback,
@@ -21,6 +22,7 @@ from gemcode.callbacks import (
21
22
  )
22
23
  from gemcode.compaction import make_before_model_callback
23
24
  from gemcode.config import GemCodeConfig
25
+ from gemcode.context_budget import make_before_model_context_shrink_callback
24
26
  from gemcode.limits import make_before_model_limits_callback, make_before_model_token_budget_callback
25
27
  from gemcode.thinking import build_thinking_config
26
28
  from gemcode.tools import build_function_tools
@@ -55,9 +57,36 @@ def _load_gemini_md(project_root: Path) -> str:
55
57
 
56
58
 
57
59
  def build_instruction(cfg: GemCodeConfig) -> str:
60
+ # Layered instructions mirror the *structure* of mature coding agents (scope,
61
+ # task interpretation, tool choice, parallelism, risk)—not proprietary text.
58
62
  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."""
63
+ You operate only inside the user's project directory (current working directory).
64
+
65
+ ## How to interpret requests
66
+ - Treat every message as a **software engineering** task in this repo unless the user clearly wants something else. If the instruction is vague ("fix it", "rename that", "the config", "see codebase"), **infer intent from the repository**: search, read, then act—do not answer with abstract advice when concrete files exist.
67
+ - If the user refers to symbols, filenames, or behaviors, **locate them in the tree** (glob/grep/list) instead of asking them to paste paths. Only ask a clarifying question when multiple plausible targets exist **and** choosing wrongly would be harmful.
68
+ - **Do not propose edits to files you have not read** (or have not inspected via grep/list with enough context). Understand what is there before you change it.
69
+ - When something fails, **diagnose** (read the error, re-check assumptions) before switching strategies; do not repeat the exact same failed tool call.
70
+
71
+ ## Using tools (decisive and efficient)
72
+ - **Multi-step work:** call `todo_write` to track tasks (merge updates by id). Mark items completed as you finish—helps you stay organized like a senior engineer.
73
+ - **Prefer dedicated tools over the shell** for this workspace: `read_file`, `list_directory`, `glob_files`, `grep_content`, `write_file`, `search_replace`, `delete_file`. Use `run_command` for builds, tests, package managers, git, and other true shell workflows.
74
+ - **`run_command` rules (critical):**
75
+ - `command` must be a **single executable basename** (e.g. `npm`, `npx`, `mkdir`) — **not** `bash`, `sh`, or `cd foo && ...`.
76
+ - Pass argv as `args` (list). To run a command **inside** a subfolder (e.g. Next app in `testing/`), set **`cwd_subdir`** to that relative path (e.g. `"testing"`) and run `npm run dev` there — **never** simulate `cd` with `bash`.
77
+ - **Scaffolding** (`create-next-app`, etc.): many CLIs require non-interactive mode — pass **`extra_env`** like `{"CI": "1"}` and/or flags supported by that tool (`--yes` where documented).
78
+ - **Dev servers** (`npm run dev`, `vite`, etc.) run until stopped: use **`background=True`** so the process detaches; otherwise the tool may time out. You cannot open a *new OS terminal window* from here—background start is the supported way to keep running.
79
+ - **Parallelize:** when you need several **independent** reads or searches (no output from one is required to form the next call), issue them together in one turn so the user gets answers faster. When step B depends on step A's result, run **sequentially**.
80
+ - **Deletion:** use `delete_file` for a single file under the project root; reserve `rm` via `run_command` for unusual cases.
81
+ - **Autonomy:** explore with `list_directory` ("."), `glob_files` (e.g. `**/*.md`, `**/*keyword*`), and `grep_content` before asking "which file?". Prefer widening your search over interrogating the user.
82
+
83
+ ## Risk and permissions
84
+ - Destructive or irreversible actions (deletes, force pushes, anything that wipes data) deserve a clear, honest description; the runtime may require explicit user approval. If the session uses **inline** approval, wait for it—do not instruct the user to "re-run with --yes" unless that is actually required by the environment.
85
+ - If a tool call is denied, **do not** immediately retry the identical call; adjust the plan or explain the blocker.
86
+
87
+ ## Communication
88
+ - Before the first tool call in a turn, give a **short** line on what you are about to do. Assume the user does not see raw tool internals—summarize outcomes in plain language.
89
+ - Prefer small, testable edits and accurate reporting over breadth."""
61
90
 
62
91
  tool_manifest = build_tool_manifest(cfg)
63
92
 
@@ -81,6 +110,8 @@ def build_root_agent(cfg: GemCodeConfig, extra_tools: list | None = None) -> Llm
81
110
  tools = [*tools, *extra_tools]
82
111
 
83
112
  before_model = _chain_before_model_callbacks(
113
+ make_before_model_autocompact_callback(cfg),
114
+ make_before_model_context_shrink_callback(cfg),
84
115
  make_before_model_callback(cfg),
85
116
  make_before_model_limits_callback(cfg),
86
117
  make_before_model_token_budget_callback(cfg),
@@ -0,0 +1,210 @@
1
+ """
2
+ Claude Code–style autocompact for ADK/Gemini.
3
+
4
+ GemCode already has:
5
+ - bounded tool output (after_tool truncation)
6
+ - soft context shrink (before_model trimming/clearing)
7
+
8
+ This module adds:
9
+ - threshold-based autocompact: when context is near the ceiling, summarize older
10
+ conversation into a compact "memory" message and keep only the tail turns.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any
17
+
18
+ from google.genai import Client
19
+ from google.genai import types
20
+
21
+ from gemcode.config import GemCodeConfig
22
+ from gemcode.context_budget import estimate_contents_text_chars
23
+
24
+ _AC_STATE_KEY = "gemcode:autocompact"
25
+ _AC_FAILURES_KEY = "gemcode:autocompact_failures"
26
+ _AC_LAST_SUMMARY_KEY = "gemcode:autocompact_last_summary"
27
+
28
+
29
+ def _truthy_env(name: str, *, default: bool = False) -> bool:
30
+ v = os.environ.get(name)
31
+ if v is None:
32
+ return default
33
+ return v.lower() in ("1", "true", "yes", "on")
34
+
35
+
36
+ def _autocompact_enabled(cfg: GemCodeConfig) -> bool:
37
+ # Default on to match "it knows what to do and when".
38
+ if os.environ.get("GEMCODE_AUTOCOMPACT") is not None:
39
+ return _truthy_env("GEMCODE_AUTOCOMPACT", default=True)
40
+ return True
41
+
42
+
43
+ def _autocompact_threshold_chars(cfg: GemCodeConfig) -> int:
44
+ # Claude Code uses token windows; we use a character proxy budget since
45
+ # Gemini tokenizers vary and ADK does not expose a cheap exact counter.
46
+ max_chars = int(getattr(cfg, "max_context_chars", 0) or 0)
47
+ if max_chars <= 0:
48
+ return 0
49
+ buffer_chars = int(os.environ.get("GEMCODE_AUTOCOMPACT_BUFFER_CHARS", "60000"))
50
+ return max(50_000, max_chars - max(10_000, buffer_chars))
51
+
52
+
53
+ def _max_failures() -> int:
54
+ return int(os.environ.get("GEMCODE_AUTOCOMPACT_MAX_FAILURES", "3"))
55
+
56
+
57
+ def _tail_keep_contents(cfg: GemCodeConfig) -> int:
58
+ return int(os.environ.get("GEMCODE_AUTOCOMPACT_KEEP_CONTENT_ITEMS", "18"))
59
+
60
+
61
+ def _summary_model(cfg: GemCodeConfig) -> str:
62
+ return os.environ.get("GEMCODE_AUTOCOMPACT_MODEL", getattr(cfg, "model", ""))
63
+
64
+
65
+ def _build_summary_prompt(contents: Any) -> str:
66
+ # Safe, bounded textualization for summarization. We do not try to serialize
67
+ # structured tool blocks fully; the pre-model context shrink already clears
68
+ # most large payloads under pressure.
69
+ lines: list[str] = []
70
+ for c in contents or []:
71
+ role = getattr(c, "role", "unknown")
72
+ parts = getattr(c, "parts", None) or []
73
+ texts: list[str] = []
74
+ for p in parts:
75
+ t = getattr(p, "text", None)
76
+ if isinstance(t, str) and t.strip():
77
+ texts.append(t.strip())
78
+ if not texts:
79
+ continue
80
+ joined = "\n".join(texts)
81
+ # Bound per content item to avoid PTL inside the compact call itself.
82
+ if len(joined) > 20_000:
83
+ joined = joined[:20_000] + "\n… [truncated for autocompact]"
84
+ lines.append(f"{role.upper()}:\n{joined}")
85
+ transcript = "\n\n".join(lines)
86
+ if len(transcript) > 180_000:
87
+ transcript = transcript[:180_000] + "\n… [older transcript truncated for autocompact]"
88
+
89
+ return (
90
+ "You are GemCode. Summarize the conversation so far into a compact, actionable memory.\n"
91
+ "Requirements:\n"
92
+ "- Preserve key decisions, constraints, and current plan.\n"
93
+ "- Preserve important file paths, commands, and errors.\n"
94
+ "- Keep it concise but information-dense.\n"
95
+ "- Do NOT include tool call JSON; paraphrase.\n\n"
96
+ "Conversation:\n"
97
+ f"{transcript}\n"
98
+ )
99
+
100
+
101
+ def _summarize_via_genai(cfg: GemCodeConfig, prompt: str) -> str:
102
+ api_key = os.environ.get("GOOGLE_API_KEY")
103
+ if not api_key:
104
+ raise RuntimeError("GOOGLE_API_KEY not set (required for autocompact summary call)")
105
+ client = Client(api_key=api_key)
106
+ model = _summary_model(cfg) or getattr(cfg, "model", "")
107
+ resp = client.models.generate_content(
108
+ model=model,
109
+ contents=[types.Content(role="user", parts=[types.Part(text=prompt)])],
110
+ config=types.GenerateContentConfig(temperature=0.2),
111
+ )
112
+ out_parts: list[str] = []
113
+ try:
114
+ if resp.candidates:
115
+ c0 = resp.candidates[0]
116
+ content = getattr(c0, "content", None)
117
+ for p in getattr(content, "parts", None) or []:
118
+ t = getattr(p, "text", None)
119
+ if isinstance(t, str) and t:
120
+ out_parts.append(t)
121
+ except Exception:
122
+ pass
123
+ text = "".join(out_parts).strip()
124
+ if not text:
125
+ raise RuntimeError("autocompact summary call returned empty text")
126
+ # Hard bound
127
+ return text[:80_000]
128
+
129
+
130
+ def make_before_model_autocompact_callback(cfg: GemCodeConfig):
131
+ if not _autocompact_enabled(cfg):
132
+ return None
133
+
134
+ async def before_model(callback_context, llm_request):
135
+ try:
136
+ contents = getattr(llm_request, "contents", None) or []
137
+ except Exception:
138
+ return None
139
+
140
+ threshold = _autocompact_threshold_chars(cfg)
141
+ if threshold <= 0:
142
+ return None
143
+
144
+ used = estimate_contents_text_chars(contents)
145
+ force = os.environ.get("GEMCODE_AUTOCOMPACT_FORCE", "").lower() in (
146
+ "1",
147
+ "true",
148
+ "yes",
149
+ "on",
150
+ )
151
+ if not force and used < threshold:
152
+ return None
153
+
154
+ st = getattr(callback_context, "state", None) or {}
155
+ failures = int(st.get(_AC_FAILURES_KEY, 0) or 0)
156
+ if failures >= _max_failures():
157
+ return None
158
+
159
+ # Build summary from the "older" prefix; keep tail untouched.
160
+ #
161
+ # Keep a reasonable tail by default, but allow compaction even in short
162
+ # conversations that become huge due to tool payloads.
163
+ requested_keep = max(4, _tail_keep_contents(cfg))
164
+ # Need at least 2 items in the summarize slice to be worth it:
165
+ # [first] + [summary] + [tail...]
166
+ max_keep_for_summarize = max(2, len(contents) - 2)
167
+ keep_n = min(requested_keep, max_keep_for_summarize)
168
+ keep_first = 1 if contents else 0
169
+ tail = contents[-keep_n:] if len(contents) > keep_n else list(contents)
170
+ prefix = []
171
+ if keep_first:
172
+ prefix = contents[:1]
173
+ summarize_slice = contents[1:-keep_n] if len(contents) > (1 + keep_n) else []
174
+ else:
175
+ summarize_slice = contents[:-keep_n] if len(contents) > keep_n else []
176
+
177
+ if not summarize_slice:
178
+ return None
179
+
180
+ try:
181
+ prompt = _build_summary_prompt(summarize_slice)
182
+ summary_text = _summarize_via_genai(cfg, prompt)
183
+ except Exception:
184
+ st[_AC_FAILURES_KEY] = failures + 1
185
+ return None
186
+
187
+ st[_AC_FAILURES_KEY] = 0
188
+ st[_AC_LAST_SUMMARY_KEY] = summary_text
189
+ st[_AC_STATE_KEY] = True
190
+
191
+ summary_msg = types.Content(
192
+ role="user",
193
+ parts=[
194
+ types.Part(
195
+ text=(
196
+ "Conversation summary (autocompacted):\n"
197
+ f"{summary_text}\n"
198
+ )
199
+ )
200
+ ],
201
+ )
202
+
203
+ llm_request.contents = [*prefix, summary_msg, *tail]
204
+ # One-shot force flag.
205
+ if force:
206
+ os.environ.pop("GEMCODE_AUTOCOMPACT_FORCE", None)
207
+ return None
208
+
209
+ return before_model
210
+
@@ -11,15 +11,21 @@ from __future__ import annotations
11
11
 
12
12
  import os
13
13
  import sys
14
+ from pathlib import Path
14
15
  from typing import Any
15
16
 
16
17
  from google.adk.tools.base_tool import BaseTool
17
18
 
18
19
  from gemcode.audit import append_audit
19
20
  from gemcode.config import GemCodeConfig
21
+ from gemcode.context_budget import truncate_tool_result_dict
22
+ from gemcode.context_warning import calculate_context_warning_state, worst_alert_level
20
23
  from gemcode.limits import SESSION_TOTAL_TOKENS_KEY
21
24
  from gemcode.query.token_budget import BudgetTracker, check_token_budget, create_budget_tracker
25
+ from gemcode.hitl_session import HITL_STICKY_SESSION_KEY
26
+ from gemcode.model_errors import format_model_error_for_user
22
27
  from gemcode.tool_registry import MUTATING_TOOLS, SHELL_TOOLS
28
+ from gemcode.tools.shell_gate import arm_confirmed_shell_basename
23
29
 
24
30
  _STATE_FAILURE_KEY = "gemcode:consecutive_tool_failures"
25
31
  TERMINAL_REASON_KEY = "gemcode:terminal_reason"
@@ -33,6 +39,10 @@ _BT_CC = "gemcode:bt_cc"
33
39
  _BT_LD = "gemcode:bt_ld"
34
40
  _BT_LG = "gemcode:bt_lg"
35
41
  _BT_T0 = "gemcode:bt_t0"
42
+ _CTX_WARN_LEVEL_NOTIFIED = "gemcode:ctx_warn_level_notified"
43
+ _LAST_PROMPT_TOKENS = "gemcode:last_prompt_tokens"
44
+ _LAST_CONTEXT_PCT = "gemcode:last_context_percent_left"
45
+ _LAST_CONTEXT_LEVEL = "gemcode:last_context_alert_level"
36
46
 
37
47
  def _truthy_env(name: str, *, default: bool = False) -> bool:
38
48
  v = os.environ.get(name)
@@ -57,6 +67,12 @@ def _max_consecutive_failures() -> int:
57
67
  return int(os.environ.get("GEMCODE_MAX_CONSECUTIVE_TOOL_FAILURES", "8"))
58
68
 
59
69
 
70
+ def _arm_shell_from_args(args: dict[str, Any]) -> None:
71
+ cmd = args.get("command")
72
+ if isinstance(cmd, str) and cmd.strip():
73
+ arm_confirmed_shell_basename(Path(cmd.strip()).name)
74
+
75
+
60
76
  def _is_computer_use_tool(tool: BaseTool) -> bool:
61
77
  """
62
78
  Detect ADK ComputerUseTool instances without enumerating every method name.
@@ -76,6 +92,25 @@ def _is_computer_use_tool(tool: BaseTool) -> bool:
76
92
  def make_before_tool_callback(cfg: GemCodeConfig):
77
93
  """Permission gate + circuit breaker (open after too many tool errors in a row)."""
78
94
 
95
+ def _hitl_sticky_enabled(tool_context) -> bool:
96
+ try:
97
+ return bool(
98
+ getattr(cfg, "interactive_hitl_sticky_session", False)
99
+ and tool_context is not None
100
+ and tool_context.state.get(HITL_STICKY_SESSION_KEY)
101
+ )
102
+ except Exception:
103
+ return False
104
+
105
+ def _hitl_mark_session_approved(tool_context) -> None:
106
+ if not getattr(cfg, "interactive_hitl_sticky_session", False):
107
+ return
108
+ try:
109
+ if tool_context is not None:
110
+ tool_context.state[HITL_STICKY_SESSION_KEY] = True
111
+ except Exception:
112
+ pass
113
+
79
114
  def _tool_confirmation_state(tool_context) -> bool | None:
80
115
  """
81
116
  Returns:
@@ -145,13 +180,17 @@ def make_before_tool_callback(cfg: GemCodeConfig):
145
180
  # In-run HITL: request ADK tool confirmation and pause execution until
146
181
  # the user approves in the current terminal session.
147
182
  if getattr(cfg, "interactive_permission_ask", False):
183
+ # After one approval this ADK session, optional skip (see GEMCODE_HITL_STICKY_SESSION).
184
+ if _hitl_sticky_enabled(tool_context):
185
+ return None
148
186
  tc_state = _tool_confirmation_state(tool_context)
149
187
  if tc_state is True:
188
+ _hitl_mark_session_approved(tool_context)
150
189
  return None
151
190
  if tc_state is False:
152
191
  return {
153
- "error": "This tool call was rejected.",
154
- "error_kind": _ERROR_KIND_PERMISSION_DENIED,
192
+ "error": "This tool call was rejected.",
193
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
155
194
  }
156
195
  if tool_context is not None and hasattr(
157
196
  tool_context, "request_confirmation"
@@ -162,7 +201,7 @@ def make_before_tool_callback(cfg: GemCodeConfig):
162
201
  )
163
202
  else:
164
203
  tool_context.request_confirmation(
165
- hint="Approve to apply the requested file mutation (write_file/search_replace)."
204
+ hint=f"Approve to apply the requested mutation ({name})."
166
205
  )
167
206
  return {
168
207
  "error": "This tool call requires confirmation.",
@@ -192,8 +231,12 @@ def make_before_tool_callback(cfg: GemCodeConfig):
192
231
  }
193
232
  if not cfg.yes_to_all:
194
233
  if getattr(cfg, "interactive_permission_ask", False):
234
+ if _hitl_sticky_enabled(tool_context):
235
+ return None
195
236
  tc_state = _tool_confirmation_state(tool_context)
196
237
  if tc_state is True:
238
+ _hitl_mark_session_approved(tool_context)
239
+ _arm_shell_from_args(args)
197
240
  return None
198
241
  if tc_state is False:
199
242
  return {
@@ -229,13 +272,21 @@ def make_after_tool_callback(cfg: GemCodeConfig):
229
272
  tool_context,
230
273
  tool_response: dict,
231
274
  ) -> dict | None:
275
+ truncated = False
276
+ if isinstance(tool_response, dict) and getattr(cfg, "tool_result_max_chars", 0) > 0:
277
+ new_d, did = truncate_tool_result_dict(
278
+ tool_response, int(cfg.tool_result_max_chars)
279
+ )
280
+ if did:
281
+ tool_response = new_d
282
+ truncated = True
232
283
  name = getattr(tool, "name", None) or ""
233
284
  if tool_context is None:
234
- return None
285
+ return tool_response if truncated else None
235
286
  try:
236
287
  st = tool_context.state
237
288
  except Exception:
238
- return None
289
+ return tool_response if truncated else None
239
290
  err = isinstance(tool_response, dict) and tool_response.get("error")
240
291
  err_kind = (
241
292
  isinstance(tool_response, dict) and tool_response.get("error_kind")
@@ -292,7 +343,7 @@ def make_after_tool_callback(cfg: GemCodeConfig):
292
343
  try:
293
344
  # Full-screen TUIs get corrupted by stray stderr prints.
294
345
  if _truthy_env("GEMCODE_TUI_ACTIVE", default=False):
295
- return None
346
+ return tool_response if truncated else None
296
347
  ok = bool(summary.get("ok"))
297
348
  prefix = "[tool ok]" if ok else "[tool err]"
298
349
  details = ""
@@ -305,6 +356,8 @@ def make_after_tool_callback(cfg: GemCodeConfig):
305
356
  print(f"{prefix} {name}{details}", file=sys.stderr)
306
357
  except Exception:
307
358
  pass
359
+ if truncated:
360
+ return tool_response
308
361
  return None
309
362
 
310
363
  return after_tool
@@ -354,6 +407,54 @@ def make_after_model_callback(cfg: GemCodeConfig):
354
407
  if d:
355
408
  append_audit(cfg.project_root, {"phase": "model_usage", **d})
356
409
 
410
+ pt = d.get("prompt_token_count")
411
+ if isinstance(pt, int) and pt >= 0:
412
+ try:
413
+ model_id = getattr(cfg, "model", "") or ""
414
+ cw = calculate_context_warning_state(
415
+ prompt_token_count=pt, model=model_id, cfg=cfg
416
+ )
417
+ level = worst_alert_level(cw)
418
+ st[_LAST_PROMPT_TOKENS] = pt
419
+ st[_LAST_CONTEXT_PCT] = cw.get("percent_left")
420
+ st[_LAST_CONTEXT_LEVEL] = level
421
+ append_audit(
422
+ cfg.project_root,
423
+ {
424
+ "phase": "context_warning",
425
+ "prompt_token_count": pt,
426
+ "percent_left": cw.get("percent_left"),
427
+ "level": level,
428
+ "is_above_warning_threshold": cw.get("is_above_warning_threshold"),
429
+ "is_above_error_threshold": cw.get("is_above_error_threshold"),
430
+ "is_above_auto_compact_threshold": cw.get(
431
+ "is_above_auto_compact_threshold"
432
+ ),
433
+ "is_at_blocking_limit": cw.get("is_at_blocking_limit"),
434
+ },
435
+ )
436
+ prev = int(st.get(_CTX_WARN_LEVEL_NOTIFIED, 0) or 0)
437
+ if level < prev:
438
+ st[_CTX_WARN_LEVEL_NOTIFIED] = level
439
+ prev = level
440
+ if (
441
+ level > prev
442
+ and not _truthy_env("GEMCODE_TUI_ACTIVE", default=False)
443
+ and os.environ.get("GEMCODE_CONTEXT_WARNINGS", "1").lower()
444
+ not in ("0", "false", "no", "off")
445
+ ):
446
+ labels = ("ok", "warning", "error", "blocking")
447
+ label = labels[min(level, 3)]
448
+ msg = (
449
+ f"[gemcode context] ~{cw.get('percent_left')}% context left "
450
+ f"(prompt_tokens≈{pt}; {label}). "
451
+ "Use /compact or start a new session if you hit limits."
452
+ )
453
+ print(msg, file=sys.stderr)
454
+ st[_CTX_WARN_LEVEL_NOTIFIED] = level
455
+ except Exception:
456
+ pass
457
+
357
458
  total_this = d.get("total_token_count")
358
459
  if isinstance(total_this, int) and total_this >= 0:
359
460
  prev_total = int(st.get(SESSION_TOTAL_TOKENS_KEY, 0) or 0)
@@ -447,11 +548,17 @@ def make_on_model_error_callback(cfg: GemCodeConfig):
447
548
  append_audit(
448
549
  cfg.project_root,
449
550
  {
450
- "phase": "model_exception",
451
- "error": f"{type(error).__name__}: {error}",
551
+ "phase": "model_exception",
552
+ "error": f"{type(error).__name__}: {error}",
452
553
  },
453
554
  )
454
- # Best-effort fallback content; do not attempt full Claude recovery loop.
555
+ if _truthy_env("GEMCODE_VERBOSE_MODEL_ERRORS", default=False):
556
+ import traceback
557
+
558
+ traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
559
+
560
+ user_text = format_model_error_for_user(error)
561
+ # Scrollback/TUI already prints "GemCode:" before assistant text — avoid "GemCode: GemCode:".
455
562
  from google.adk.models.llm_response import LlmResponse
456
563
  from google.genai import types
457
564
 
@@ -461,8 +568,8 @@ def make_on_model_error_callback(cfg: GemCodeConfig):
461
568
  parts=[
462
569
  types.Part(
463
570
  text=(
464
- "GemCode: model call failed. "
465
- "Re-run the request or reduce prompt size."
571
+ f"{user_text} "
572
+ "You can re-run, shorten the message, or start a fresh session."
466
573
  )
467
574
  )
468
575
  ],