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
|
@@ -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
|
gemcode/interactions.py
ADDED
|
@@ -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
|