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
@@ -0,0 +1,275 @@
1
+ """
2
+ Playwright-backed browser automation (ADK ComputerUse).
3
+
4
+ This provides an ADK `BaseComputer` implementation so the LLM can call
5
+ computer-use tools like `click_at`, `type_text_at`, and `navigate`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Literal
12
+ from typing import Optional
13
+ from typing import Sequence
14
+ from typing import Tuple
15
+
16
+ from google.adk.tools.computer_use.base_computer import BaseComputer
17
+ from google.adk.tools.computer_use.base_computer import ComputerEnvironment
18
+ from google.adk.tools.computer_use.base_computer import ComputerState
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BrowserComputerConfig:
23
+ headless: bool = True
24
+ viewport_width: int = 1280
25
+ viewport_height: int = 720
26
+ navigation_timeout_ms: int = 30_000
27
+
28
+
29
+ class BrowserComputer(BaseComputer):
30
+ def __init__(
31
+ self,
32
+ *,
33
+ headless: bool = True,
34
+ viewport_size: Tuple[int, int] = (1280, 720),
35
+ navigation_timeout_ms: int = 30_000,
36
+ ):
37
+ self._cfg = BrowserComputerConfig(
38
+ headless=headless,
39
+ viewport_width=viewport_size[0],
40
+ viewport_height=viewport_size[1],
41
+ navigation_timeout_ms=navigation_timeout_ms,
42
+ )
43
+ self._playwright = None
44
+ self._browser = None
45
+ self._context = None
46
+ self._page = None
47
+
48
+ async def initialize(self) -> None:
49
+ if self._page is not None:
50
+ return
51
+ try:
52
+ from playwright.async_api import async_playwright
53
+ except ImportError as e:
54
+ raise RuntimeError(
55
+ "Browser computer requires Playwright. Install `playwright` and run `playwright install`."
56
+ ) from e
57
+
58
+ self._playwright = await async_playwright().start()
59
+ self._browser = await self._playwright.chromium.launch(headless=self._cfg.headless)
60
+ self._context = await self._browser.new_context(
61
+ viewport={"width": self._cfg.viewport_width, "height": self._cfg.viewport_height}
62
+ )
63
+ self._page = await self._context.new_page()
64
+ self._page.set_default_navigation_timeout(self._cfg.navigation_timeout_ms)
65
+
66
+ async def close(self) -> None:
67
+ if self._page is not None:
68
+ try:
69
+ await self._context.close()
70
+ except Exception:
71
+ pass
72
+ if self._browser is not None:
73
+ try:
74
+ await self._browser.close()
75
+ except Exception:
76
+ pass
77
+ if self._playwright is not None:
78
+ try:
79
+ await self._playwright.stop()
80
+ except Exception:
81
+ pass
82
+ self._playwright = None
83
+ self._browser = None
84
+ self._context = None
85
+ self._page = None
86
+
87
+ async def environment(self) -> ComputerEnvironment:
88
+ return ComputerEnvironment.ENVIRONMENT_BROWSER
89
+
90
+ async def screen_size(self) -> tuple[int, int]:
91
+ await self.initialize()
92
+ return (self._cfg.viewport_width, self._cfg.viewport_height)
93
+
94
+ async def current_state(self) -> ComputerState:
95
+ await self.initialize()
96
+ assert self._page is not None
97
+ screenshot = await self._page.screenshot(type="png", full_page=False)
98
+ url = self._page.url
99
+ return ComputerState(screenshot=screenshot, url=url)
100
+
101
+ async def open_web_browser(self) -> ComputerState:
102
+ await self.initialize()
103
+ assert self._page is not None
104
+ # Use about:blank to establish a page; LLM can navigate afterwards.
105
+ await self._page.goto("about:blank")
106
+ return await self.current_state()
107
+
108
+ async def click_at(self, x: int, y: int) -> ComputerState:
109
+ await self.initialize()
110
+ assert self._page is not None
111
+ await self._page.mouse.click(x, y)
112
+ return await self.current_state()
113
+
114
+ async def hover_at(self, x: int, y: int) -> ComputerState:
115
+ await self.initialize()
116
+ assert self._page is not None
117
+ await self._page.mouse.move(x, y)
118
+ await self._page.wait_for_timeout(150)
119
+ return await self.current_state()
120
+
121
+ async def type_text_at(
122
+ self,
123
+ x: int,
124
+ y: int,
125
+ text: str,
126
+ press_enter: bool = True,
127
+ clear_before_typing: bool = True,
128
+ ) -> ComputerState:
129
+ await self.initialize()
130
+ assert self._page is not None
131
+ await self._page.mouse.click(x, y)
132
+ if clear_before_typing:
133
+ # MVP clear behavior: select all + backspace.
134
+ await self._page.keyboard.press("Control+A")
135
+ await self._page.keyboard.press("Backspace")
136
+ await self._page.keyboard.type(text)
137
+ if press_enter:
138
+ await self._page.keyboard.press("Enter")
139
+ return await self.current_state()
140
+
141
+ async def scroll_document(
142
+ self, direction: Literal["up", "down", "left", "right"]
143
+ ) -> ComputerState:
144
+ await self.initialize()
145
+ assert self._page is not None
146
+ magnitude = 600
147
+ if direction == "up":
148
+ dx, dy = 0, -magnitude
149
+ elif direction == "down":
150
+ dx, dy = 0, magnitude
151
+ elif direction == "left":
152
+ dx, dy = -magnitude, 0
153
+ else:
154
+ dx, dy = magnitude, 0
155
+ await self._page.mouse.wheel(dx=dx, dy=dy)
156
+ await self._page.wait_for_timeout(250)
157
+ return await self.current_state()
158
+
159
+ async def scroll_at(
160
+ self,
161
+ x: int,
162
+ y: int,
163
+ direction: Literal["up", "down", "left", "right"],
164
+ magnitude: int,
165
+ ) -> ComputerState:
166
+ await self.initialize()
167
+ assert self._page is not None
168
+ await self._page.mouse.move(x, y)
169
+ if direction == "up":
170
+ dx, dy = 0, -abs(magnitude)
171
+ elif direction == "down":
172
+ dx, dy = 0, abs(magnitude)
173
+ elif direction == "left":
174
+ dx, dy = -abs(magnitude), 0
175
+ else:
176
+ dx, dy = abs(magnitude), 0
177
+ await self._page.mouse.wheel(dx=dx, dy=dy)
178
+ await self._page.wait_for_timeout(250)
179
+ return await self.current_state()
180
+
181
+ async def wait(self, seconds: int) -> ComputerState:
182
+ await self.initialize()
183
+ assert self._page is not None
184
+ await self._page.wait_for_timeout(max(1, int(seconds)) * 1000)
185
+ return await self.current_state()
186
+
187
+ async def go_back(self) -> ComputerState:
188
+ await self.initialize()
189
+ assert self._page is not None
190
+ await self._page.go_back()
191
+ await self._page.wait_for_timeout(250)
192
+ return await self.current_state()
193
+
194
+ async def go_forward(self) -> ComputerState:
195
+ await self.initialize()
196
+ assert self._page is not None
197
+ await self._page.go_forward()
198
+ await self._page.wait_for_timeout(250)
199
+ return await self.current_state()
200
+
201
+ async def search(self) -> ComputerState:
202
+ # Home page for web search.
203
+ return await self.navigate("https://www.google.com")
204
+
205
+ async def navigate(self, url: str) -> ComputerState:
206
+ await self.initialize()
207
+ assert self._page is not None
208
+ await self._page.goto(url, wait_until="load")
209
+ await self._page.wait_for_timeout(250)
210
+ return await self.current_state()
211
+
212
+ async def key_combination(self, keys: list[str]) -> ComputerState:
213
+ """
214
+ Press a key combination.
215
+
216
+ ADK can pass values like `["control+c"]`. We parse `control+c` into a
217
+ Playwright modifier sequence.
218
+ """
219
+ await self.initialize()
220
+ assert self._page is not None
221
+
222
+ mod_map = {
223
+ "control": "Control",
224
+ "ctrl": "Control",
225
+ "shift": "Shift",
226
+ "alt": "Alt",
227
+ "option": "Alt",
228
+ "meta": "Meta",
229
+ "command": "Meta",
230
+ "cmd": "Meta",
231
+ "super": "Meta",
232
+ }
233
+
234
+ def _press_key_combo(combo: str) -> None:
235
+ parts = combo.lower().replace(" ", "").split("+")
236
+ if len(parts) == 1:
237
+ return self._page.keyboard.press(parts[0])
238
+ mods = parts[:-1]
239
+ key = parts[-1]
240
+ # Playwright: down(mod) + press(key) + up(mod)
241
+ async def _run():
242
+ for m in mods:
243
+ pw = mod_map.get(m, None)
244
+ if pw:
245
+ await self._page.keyboard.down(pw)
246
+ await self._page.keyboard.press(key)
247
+ for m in reversed(mods):
248
+ pw = mod_map.get(m, None)
249
+ if pw:
250
+ await self._page.keyboard.up(pw)
251
+
252
+ return _run()
253
+
254
+ for k in keys:
255
+ res = _press_key_combo(k)
256
+ if hasattr(res, "__await__"):
257
+ await res
258
+ else:
259
+ # `press` already awaited by Playwright; noop.
260
+ pass
261
+
262
+ return await self.current_state()
263
+
264
+ async def drag_and_drop(
265
+ self, x: int, y: int, destination_x: int, destination_y: int
266
+ ) -> ComputerState:
267
+ await self.initialize()
268
+ assert self._page is not None
269
+ await self._page.mouse.move(x, y)
270
+ await self._page.mouse.down()
271
+ await self._page.mouse.move(destination_x, destination_y)
272
+ await self._page.mouse.up()
273
+ await self._page.wait_for_timeout(250)
274
+ return await self.current_state()
275
+
gemcode/config.py ADDED
@@ -0,0 +1,247 @@
1
+ """Environment and CLI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ def _split_csv(s: str | None) -> list[str]:
11
+ if not s:
12
+ return []
13
+ return [x.strip() for x in s.split(",") if x.strip()]
14
+
15
+
16
+ def _opt_positive_int(name: str) -> int | None:
17
+ v = os.environ.get(name)
18
+ if v is None or not str(v).strip():
19
+ return None
20
+ n = int(str(v).strip())
21
+ return n if n > 0 else None
22
+
23
+
24
+ def _opt_int(name: str) -> int | None:
25
+ """Optional int from env, allowing 0 / negative values."""
26
+ v = os.environ.get(name)
27
+ if v is None or not str(v).strip():
28
+ return None
29
+ return int(str(v).strip())
30
+
31
+
32
+ def _truthy_env(name: str, *, default: bool = False) -> bool:
33
+ v = os.environ.get(name)
34
+ if v is None:
35
+ return default
36
+ return v.lower() in ("1", "true", "yes", "on")
37
+
38
+
39
+ def token_budget_invocation_reset() -> dict:
40
+ """Reset per-user-message token budget tracker (matches new `query()` in Claude)."""
41
+ import time
42
+
43
+ t = int(time.time() * 1000)
44
+ return {
45
+ "gemcode:bt_cc": 0,
46
+ "gemcode:bt_ld": 0,
47
+ "gemcode:bt_lg": 0,
48
+ "gemcode:bt_t0": t,
49
+ "gemcode:bt_base_total_tokens": -1,
50
+ "gemcode:bt_token_budget_stop": False,
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class GemCodeConfig:
56
+ """Runtime options (CLI + env)."""
57
+
58
+ project_root: Path
59
+ model: str = field(default_factory=lambda: os.environ.get("GEMCODE_MODEL", "gemini-2.5-flash"))
60
+ # Model mode: fast|balanced|quality|auto
61
+ model_mode: str = field(
62
+ default_factory=lambda: os.environ.get("GEMCODE_MODEL_MODE", "fast")
63
+ )
64
+ model_quality: str | None = field(
65
+ default_factory=lambda: os.environ.get("GEMCODE_MODEL_QUALITY")
66
+ )
67
+ model_balanced: str | None = field(
68
+ default_factory=lambda: os.environ.get("GEMCODE_MODEL_BALANCED")
69
+ )
70
+ # Model family routing: choose between the "primary" model ids (GEMCODE_MODEL*
71
+ # fields) and 2.5 alternatives (GEMCODE_MODEL_ALT* fields).
72
+ #
73
+ # - `auto`: heuristic chooses primary for complex prompts, alt for simple
74
+ # - `primary`: always use primary ids
75
+ # - `alt`: always use 2.5 ids (GEMCODE_MODEL_ALT*)
76
+ model_family_mode: str = field(
77
+ default_factory=lambda: os.environ.get("GEMCODE_MODEL_FAMILY_MODE", "auto")
78
+ )
79
+ model_alt: str | None = field(
80
+ default_factory=lambda: os.environ.get("GEMCODE_MODEL_ALT")
81
+ )
82
+ model_alt_quality: str | None = field(
83
+ default_factory=lambda: os.environ.get("GEMCODE_MODEL_ALT_QUALITY")
84
+ )
85
+ model_alt_balanced: str | None = field(
86
+ default_factory=lambda: os.environ.get("GEMCODE_MODEL_ALT_BALANCED")
87
+ )
88
+ permission_mode: str = field(
89
+ default_factory=lambda: os.environ.get("GEMCODE_PERMISSION_MODE", "default")
90
+ )
91
+ allow_commands: frozenset[str] | None = None
92
+ yes_to_all: bool = False
93
+ # When enabled, GemCode will ask for user confirmation in the *same run*
94
+ # (HITL) before mutating tools / computer-use tools execute.
95
+ #
96
+ # Default behavior is controlled in the CLI:
97
+ # - If `GEMCODE_INTERACTIVE_PERMISSION_ASK` is set, we honor it.
98
+ # - Otherwise we enable when stdin is a TTY and `--yes` is not provided.
99
+ interactive_permission_ask: bool = field(
100
+ default_factory=lambda: _truthy_env(
101
+ "GEMCODE_INTERACTIVE_PERMISSION_ASK", default=False
102
+ )
103
+ )
104
+ max_content_items: int = field(
105
+ default_factory=lambda: int(os.environ.get("GEMCODE_MAX_CONTENT_ITEMS", "40"))
106
+ )
107
+ # ADK RunConfig.max_llm_calls (model↔tool iterations per user message); None = SDK default (500).
108
+ max_llm_calls: int | None = field(default_factory=lambda: _opt_positive_int("GEMCODE_MAX_LLM_CALLS"))
109
+ # Hard stop before next LLM call when cumulative usage_metadata totals exceed this.
110
+ max_session_tokens: int | None = field(
111
+ default_factory=lambda: _opt_positive_int("GEMCODE_MAX_SESSION_TOKENS")
112
+ )
113
+ # Optional per-turn style budget for continuation logging (see query/token_budget.py).
114
+ token_budget: int | None = field(default_factory=lambda: _opt_positive_int("GEMCODE_TOKEN_BUDGET"))
115
+ # Enables persistent memory via ADK context integration (file-backed).
116
+ enable_memory: bool = field(
117
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_MEMORY", default=False)
118
+ )
119
+
120
+ # Modality toggles (tool injection + routing).
121
+ enable_deep_research: bool = field(
122
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_DEEP_RESEARCH", default=False)
123
+ )
124
+ enable_embeddings: bool = field(
125
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_EMBEDDINGS", default=False)
126
+ )
127
+
128
+ # Deep research model id used when routing selects deep research.
129
+ model_deep_research: str = field(
130
+ default_factory=lambda: os.environ.get(
131
+ "GEMCODE_MODEL_DEEP_RESEARCH", "travel_explore"
132
+ )
133
+ )
134
+
135
+ # Embeddings model id used by embeddings-powered tools/memory (if enabled).
136
+ embeddings_model: str = field(
137
+ default_factory=lambda: os.environ.get(
138
+ "GEMCODE_EMBEDDINGS_MODEL", "models/gemini-embedding-2-preview"
139
+ )
140
+ )
141
+
142
+ # Deep research: Google Maps grounding is optional because it can be
143
+ # incompatible with other built-in tools (e.g., google_search) in the same
144
+ # request depending on the model/tooling layer.
145
+ enable_maps_grounding: bool = field(
146
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_MAPS_GROUNDING", default=False)
147
+ )
148
+
149
+ # Computer use (ADK ComputerUseToolset) enable/disable; default is off for safety.
150
+ enable_computer_use: bool = field(
151
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_COMPUTER_USE", default=False)
152
+ )
153
+
154
+ # Audio mode (Gemini Live models). Only fully supported via `gemcode live-audio`
155
+ # in this MVP.
156
+ enable_audio: bool = field(
157
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_AUDIO", default=False)
158
+ )
159
+ model_audio_live: str = field(
160
+ default_factory=lambda: os.environ.get(
161
+ "GEMCODE_MODEL_AUDIO_LIVE", "gemini-3.1-flash-live-preview"
162
+ )
163
+ )
164
+ model_computer_use: str = field(
165
+ default_factory=lambda: os.environ.get(
166
+ "GEMCODE_MODEL_COMPUTER_USE",
167
+ "gemini-2.5-computer-use-preview-10-2025",
168
+ )
169
+ )
170
+
171
+ # Capability routing: auto|research|embeddings|computer|audio|all
172
+ capability_mode: str = field(
173
+ default_factory=lambda: os.environ.get("GEMCODE_CAPABILITY_MODE", "auto")
174
+ )
175
+
176
+ # Gemini 3 "tool context circulation" (built-in tools + function tools
177
+ # combination). Controls when we set ToolConfig(include_server_side_tool_invocations=True).
178
+ #
179
+ # - deep_research: only when enable_deep_research is enabled
180
+ # - always: enable for Gemini 3.x regardless of deep-research toggle
181
+ # - never: disable always
182
+ # - auto: alias for deep_research
183
+ tool_combination_mode: str = field(
184
+ default_factory=lambda: os.environ.get(
185
+ "GEMCODE_TOOL_COMBINATION_MODE", "deep_research"
186
+ )
187
+ )
188
+
189
+ # Set by CLI when the user explicitly provides --model. Used to prevent
190
+ # role-based routing from overriding their selection.
191
+ model_overridden: bool = False
192
+
193
+ # Gemini thinking controls (Claude-like intent, Gemini-specific knobs).
194
+ #
195
+ # Claude Code enables thinking by default and only forces disable/budgets
196
+ # when explicitly configured. We match that by returning "None" unless the
197
+ # user asks for explicit overrides below.
198
+ #
199
+ # - Gemini 3.x: supports `thinkingLevel` (can't fully disable).
200
+ # - Gemini 2.5: supports `thinkingBudget` (0 disables for models that allow it).
201
+ disable_thinking: bool = field(
202
+ default_factory=lambda: _truthy_env("GEMCODE_DISABLE_THINKING", default=False)
203
+ )
204
+ include_thought_summaries: bool = field(
205
+ default_factory=lambda: _truthy_env(
206
+ "GEMCODE_INCLUDE_THOUGHT_SUMMARIES", default=False
207
+ )
208
+ )
209
+ thinking_level: str | None = field(
210
+ default_factory=lambda: os.environ.get("GEMCODE_THINKING_LEVEL")
211
+ )
212
+ thinking_budget: int | None = field(
213
+ default_factory=lambda: _opt_int("GEMCODE_THINKING_BUDGET")
214
+ )
215
+
216
+ def __post_init__(self) -> None:
217
+ self.project_root = self.project_root.resolve()
218
+ if self.allow_commands is None:
219
+ env = os.environ.get("GEMCODE_ALLOW_COMMANDS")
220
+ if env:
221
+ self.allow_commands = frozenset(_split_csv(env))
222
+ else:
223
+ self.allow_commands = frozenset(
224
+ (
225
+ "pytest",
226
+ "python3",
227
+ "python",
228
+ "pip",
229
+ "pip3",
230
+ "npm",
231
+ "npx",
232
+ "git",
233
+ "ruff",
234
+ "uv",
235
+ "cargo",
236
+ "go",
237
+ )
238
+ )
239
+
240
+
241
+ def load_dotenv_optional() -> None:
242
+ try:
243
+ from dotenv import load_dotenv
244
+
245
+ load_dotenv()
246
+ except ImportError:
247
+ pass
@@ -0,0 +1,15 @@
1
+ """
2
+ Gemini Interactions API (optional durable / server-side history).
3
+
4
+ The ADK `LlmRequest` includes `previous_interaction_id` for chaining interactions.
5
+ Wiring GemCode to Interactions API can reduce client-side history size for long
6
+ sessions. Implementation is deferred: enable when your `google-adk` version and
7
+ deployment target require it, following:
8
+ https://ai.google.dev/gemini-api/docs/interactions
9
+
10
+ Planned integration points:
11
+ - Store `previous_interaction_id` in `.gemcode/session_meta.json` per session.
12
+ - Pass through Runner/App configuration when ADK exposes a stable hook for your stack.
13
+ """
14
+
15
+ from __future__ import annotations
gemcode/invoke.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ Single user turn (Claude Code: inner path ≈ `query()` invocation per message).
3
+
4
+ CLI and tests call `run_turn` with a Runner already bound to app + session service.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from threading import Lock
11
+
12
+ from google.adk.agents.run_config import RunConfig
13
+ from google.adk.runners import Runner
14
+ from google.genai import types
15
+
16
+
17
+ _HITL_PROMPT_LOCK = Lock()
18
+
19
+
20
+ async def run_turn(
21
+ runner: Runner,
22
+ *,
23
+ user_id: str,
24
+ session_id: str,
25
+ prompt: str,
26
+ max_llm_calls: int | None = None,
27
+ cfg: "GemCodeConfig | None" = None,
28
+ ) -> list:
29
+ """Execute one user message; collect all Events (caller aggregates text)."""
30
+
31
+ collected: list = []
32
+
33
+ run_config = (
34
+ RunConfig(max_llm_calls=max_llm_calls) if max_llm_calls is not None else None
35
+ )
36
+
37
+ # Apply token-budget reset only once per user turn, even if we must resume
38
+ # across multiple ADK tool-confirmation handoffs.
39
+ state_delta = None
40
+ if cfg is not None and cfg.token_budget:
41
+ from gemcode.config import token_budget_invocation_reset
42
+
43
+ state_delta = token_budget_invocation_reset()
44
+
45
+ # The first message is plain user text.
46
+ current_message = types.Content(role="user", parts=[types.Part(text=prompt)])
47
+
48
+ async def _await_runner_events(*, next_message: types.Content, do_reset: bool):
49
+ kwargs = dict(
50
+ user_id=user_id,
51
+ session_id=session_id,
52
+ new_message=next_message,
53
+ )
54
+ if run_config is not None:
55
+ kwargs["run_config"] = run_config
56
+ if do_reset and state_delta is not None:
57
+ kwargs["state_delta"] = state_delta
58
+ events: list = []
59
+ async for event in runner.run_async(**kwargs):
60
+ events.append(event)
61
+ return events
62
+
63
+ REQUEST_CONFIRMATION_FC = "adk_request_confirmation"
64
+
65
+ def _get_confirmation_requests(events: list) -> list[types.FunctionCall]:
66
+ out: list[types.FunctionCall] = []
67
+ for ev in events:
68
+ try:
69
+ for fc in ev.get_function_calls() or []:
70
+ if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC:
71
+ out.append(fc)
72
+ except Exception:
73
+ continue
74
+ return out
75
+
76
+ def _extract_hint_and_tool(fc: types.FunctionCall) -> tuple[str, str]:
77
+ # generate_request_confirmation_event() builds:
78
+ # - args.originalFunctionCall.{name,args,...}
79
+ # - args.toolConfirmation.{hint, ...}
80
+ tool_name = "unknown_tool"
81
+ hint = ""
82
+ try:
83
+ args = getattr(fc, "args", None) or {}
84
+ orig = args.get("originalFunctionCall") or {}
85
+ tool_name = orig.get("name") or tool_name
86
+ tc = args.get("toolConfirmation") or {}
87
+ hint = tc.get("hint") or ""
88
+ except Exception:
89
+ pass
90
+ return tool_name, hint
91
+
92
+ def _prompt_yes_no(prompt_text: str) -> bool:
93
+ with _HITL_PROMPT_LOCK:
94
+ while True:
95
+ ans = input(prompt_text).strip().lower()
96
+ if ans in ("y", "yes"):
97
+ return True
98
+ if ans in ("", "n", "no"):
99
+ return False
100
+ print("Please answer 'y' or 'n'.")
101
+
102
+ # Runner handoff loop: if tools request confirmations, we pause here to ask
103
+ # HITL, then send back function responses so ADK can re-execute the tools.
104
+ do_reset = True
105
+ while True:
106
+ events = await _await_runner_events(
107
+ next_message=current_message, do_reset=do_reset
108
+ )
109
+ collected.extend(events)
110
+
111
+ confirmation_fcs = _get_confirmation_requests(events)
112
+ if not confirmation_fcs:
113
+ break
114
+
115
+ # If interactive ask is disabled, auto-reject to avoid hanging on stdin.
116
+ interactive_enabled = bool(
117
+ getattr(cfg, "interactive_permission_ask", False)
118
+ and hasattr(sys.stdin, "isatty")
119
+ and sys.stdin.isatty()
120
+ )
121
+
122
+ parts: list[types.Part] = []
123
+ for fc in confirmation_fcs:
124
+ tool_name, hint = _extract_hint_and_tool(fc)
125
+ if interactive_enabled:
126
+ suffix = f"\n Hint: {hint}" if hint else ""
127
+ ok = _prompt_yes_no(
128
+ f"\n[gemcode HITL] Approve tool call '{tool_name}'? [y/N]{suffix}\n> "
129
+ )
130
+ else:
131
+ ok = False
132
+ print(
133
+ f"[gemcode HITL] Tool confirmation requested for '{tool_name}', but interactive-ask is disabled; auto-rejecting.",
134
+ file=sys.stderr,
135
+ )
136
+
137
+ parts.append(
138
+ types.Part(
139
+ function_response=types.FunctionResponse(
140
+ name=REQUEST_CONFIRMATION_FC,
141
+ id=getattr(fc, "id", None),
142
+ response={"confirmed": ok},
143
+ )
144
+ )
145
+ )
146
+
147
+ current_message = types.Content(role="user", parts=parts)
148
+ # Subsequent resumes must not re-reset token budgets.
149
+ do_reset = False
150
+
151
+ return collected