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,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from google.adk.agents.run_config import RunConfig
|
|
9
|
+
from google.genai import types
|
|
10
|
+
|
|
11
|
+
from gemcode.capability_routing import apply_capability_routing
|
|
12
|
+
from gemcode.model_routing import pick_effective_model
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class _Ansi:
|
|
17
|
+
enabled: bool
|
|
18
|
+
|
|
19
|
+
def esc(self, code: str) -> str:
|
|
20
|
+
if not self.enabled:
|
|
21
|
+
return ""
|
|
22
|
+
return f"\x1b[{code}m"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def reset(self) -> str: # noqa: D401
|
|
26
|
+
return self.esc("0")
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def dim(self) -> str:
|
|
30
|
+
return self.esc("2")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def bold(self) -> str:
|
|
34
|
+
return self.esc("1")
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def blue(self) -> str:
|
|
38
|
+
# ANSI 256-color bright-ish blue.
|
|
39
|
+
return self.esc("38;5;75")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def blue2(self) -> str:
|
|
43
|
+
# Slightly deeper blue for secondary accents.
|
|
44
|
+
return self.esc("38;5;33")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def blue_ok(self) -> str:
|
|
48
|
+
return self.esc("38;5;81")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def blue_warn(self) -> str:
|
|
52
|
+
return self.esc("38;5;39")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def blue_tool(self) -> str:
|
|
56
|
+
return self.esc("38;5;69")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _term_width(default: int = 100) -> int:
|
|
60
|
+
try:
|
|
61
|
+
import shutil
|
|
62
|
+
|
|
63
|
+
return max(60, shutil.get_terminal_size((default, 24)).columns)
|
|
64
|
+
except Exception:
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _hr(ch: str = "─") -> str:
|
|
69
|
+
return ch * _term_width()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _dashboard(cfg) -> str:
|
|
73
|
+
w = _term_width()
|
|
74
|
+
title = f" GemCode v{os.environ.get('GEMCODE_VERSION', '0.1.0')} "
|
|
75
|
+
left_w = (w - 4) * 2 // 3
|
|
76
|
+
right_w = (w - 4) - left_w
|
|
77
|
+
|
|
78
|
+
def pad(s: str, ww: int) -> str:
|
|
79
|
+
s = s.replace("\n", " ")
|
|
80
|
+
if len(s) > ww:
|
|
81
|
+
return s[: ww - 1] + "…"
|
|
82
|
+
return s + (" " * (ww - len(s)))
|
|
83
|
+
|
|
84
|
+
user = (os.environ.get("USER") or os.environ.get("LOGNAME") or "there").strip()
|
|
85
|
+
model = getattr(cfg, "model", "") or ""
|
|
86
|
+
root = str(getattr(cfg, "project_root", "") or "")
|
|
87
|
+
|
|
88
|
+
box_top = "╭" + ("─" * (w - 2)) + "╮"
|
|
89
|
+
box_bot = "╰" + ("─" * (w - 2)) + "╯"
|
|
90
|
+
lines: list[str] = [box_top]
|
|
91
|
+
lines.append("│" + pad(title, w - 2) + "│")
|
|
92
|
+
lines.append("│" + (" " * (w - 2)) + "│")
|
|
93
|
+
left = [
|
|
94
|
+
"",
|
|
95
|
+
f"Welcome back {user}!",
|
|
96
|
+
"",
|
|
97
|
+
" ▐▛███▜▌",
|
|
98
|
+
" ▝▜█████▛▘",
|
|
99
|
+
" ▘▘ ▝▝",
|
|
100
|
+
"",
|
|
101
|
+
f"{model or 'GemCode'} · Local session",
|
|
102
|
+
root,
|
|
103
|
+
]
|
|
104
|
+
right = [
|
|
105
|
+
"Tips for getting started",
|
|
106
|
+
"Run /init to create a .gemcode file",
|
|
107
|
+
"",
|
|
108
|
+
"Recent activity",
|
|
109
|
+
"No recent activity",
|
|
110
|
+
]
|
|
111
|
+
h = max(len(left), len(right))
|
|
112
|
+
left += [""] * (h - len(left))
|
|
113
|
+
right += [""] * (h - len(right))
|
|
114
|
+
for i in range(h):
|
|
115
|
+
lines.append(
|
|
116
|
+
"│ " + pad(left[i], left_w) + " │ " + pad(right[i], right_w) + " │"
|
|
117
|
+
)
|
|
118
|
+
lines.append(box_bot)
|
|
119
|
+
lines.append("")
|
|
120
|
+
lines.append(" ↑ GemCode Pro now supports larger contexts · faster streaming")
|
|
121
|
+
lines.append("")
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def run_gemcode_scrollback_tui(*, cfg, runner, session_id: str) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Claude-like terminal UI: NO internal scrolling, just terminal scrollback.
|
|
128
|
+
|
|
129
|
+
- User prompt line starts with: ❯
|
|
130
|
+
- Assistant/tool blocks start with: ⎿ (indented)
|
|
131
|
+
- Tool calls are shown as a short "internal state" block.
|
|
132
|
+
- Permission prompts are inline: type y/n at the prompt.
|
|
133
|
+
"""
|
|
134
|
+
os.environ["GEMCODE_TUI_ACTIVE"] = "1"
|
|
135
|
+
|
|
136
|
+
ansi = _Ansi(
|
|
137
|
+
enabled=(
|
|
138
|
+
sys.stdout.isatty()
|
|
139
|
+
and os.environ.get("NO_COLOR") is None
|
|
140
|
+
and os.environ.get("GEMCODE_TUI_NO_COLOR") is None
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if os.environ.get("GEMCODE_TUI_SHOW_DASHBOARD", "1").lower() in ("1", "true", "yes", "on"):
|
|
145
|
+
dash = _dashboard(cfg)
|
|
146
|
+
if ansi.enabled:
|
|
147
|
+
# Color title + the ASCII mark.
|
|
148
|
+
lines = dash.splitlines()
|
|
149
|
+
if len(lines) >= 2:
|
|
150
|
+
lines[1] = (
|
|
151
|
+
lines[1]
|
|
152
|
+
.replace("GemCode", f"{ansi.blue}{ansi.bold}GemCode{ansi.reset}")
|
|
153
|
+
.replace("v", f"{ansi.dim}v{ansi.reset}")
|
|
154
|
+
)
|
|
155
|
+
for i, ln in enumerate(lines):
|
|
156
|
+
if "▐▛███▜▌" in ln or "▝▜█████▛▘" in ln or "▘▘ ▝▝" in ln:
|
|
157
|
+
lines[i] = f"{ansi.blue2}{ln}{ansi.reset}"
|
|
158
|
+
dash = "\n".join(lines)
|
|
159
|
+
print(dash)
|
|
160
|
+
|
|
161
|
+
print(f"{ansi.dim} ? for shortcuts{ansi.reset}")
|
|
162
|
+
print("")
|
|
163
|
+
|
|
164
|
+
char_delay_ms = int(os.environ.get("GEMCODE_TUI_CHAR_DELAY_MS", "0") or "0")
|
|
165
|
+
|
|
166
|
+
async def typewrite(text: str) -> None:
|
|
167
|
+
if not text:
|
|
168
|
+
return
|
|
169
|
+
if char_delay_ms <= 0:
|
|
170
|
+
sys.stdout.write(text)
|
|
171
|
+
sys.stdout.flush()
|
|
172
|
+
await asyncio.sleep(0)
|
|
173
|
+
return
|
|
174
|
+
for ch in text:
|
|
175
|
+
sys.stdout.write(ch)
|
|
176
|
+
sys.stdout.flush()
|
|
177
|
+
await asyncio.sleep(char_delay_ms / 1000.0)
|
|
178
|
+
|
|
179
|
+
REQUEST_CONFIRMATION_FC = "adk_request_confirmation"
|
|
180
|
+
|
|
181
|
+
def _get_confirmation_fcs(events: list) -> list[types.FunctionCall]:
|
|
182
|
+
out: list[types.FunctionCall] = []
|
|
183
|
+
for ev in events:
|
|
184
|
+
try:
|
|
185
|
+
for fc in ev.get_function_calls() or []:
|
|
186
|
+
if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC:
|
|
187
|
+
out.append(fc)
|
|
188
|
+
except Exception:
|
|
189
|
+
continue
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
def _extract_tool_and_hint(fc: types.FunctionCall) -> tuple[str, str]:
|
|
193
|
+
tool_name = "unknown_tool"
|
|
194
|
+
hint = ""
|
|
195
|
+
try:
|
|
196
|
+
args = getattr(fc, "args", None) or {}
|
|
197
|
+
orig = args.get("originalFunctionCall") or {}
|
|
198
|
+
tool_name = orig.get("name") or tool_name
|
|
199
|
+
tc = args.get("toolConfirmation") or {}
|
|
200
|
+
hint = tc.get("hint") or ""
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
return tool_name, hint
|
|
204
|
+
|
|
205
|
+
def _render_tool_calls(ev) -> None:
|
|
206
|
+
try:
|
|
207
|
+
fcs = ev.get_function_calls() or []
|
|
208
|
+
except Exception:
|
|
209
|
+
fcs = []
|
|
210
|
+
for fc in fcs:
|
|
211
|
+
name = getattr(fc, "name", "") or ""
|
|
212
|
+
if name == REQUEST_CONFIRMATION_FC:
|
|
213
|
+
continue
|
|
214
|
+
print(f" ⎿ {ansi.blue_tool}[tool]{ansi.reset} {ansi.bold}{name}{ansi.reset}")
|
|
215
|
+
|
|
216
|
+
run_config = (
|
|
217
|
+
RunConfig(max_llm_calls=cfg.max_llm_calls)
|
|
218
|
+
if getattr(cfg, "max_llm_calls", None) is not None
|
|
219
|
+
else None
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
while True:
|
|
223
|
+
try:
|
|
224
|
+
prompt = input(f"{ansi.bold}❯{ansi.reset} ").strip()
|
|
225
|
+
except EOFError:
|
|
226
|
+
print("")
|
|
227
|
+
return
|
|
228
|
+
if not prompt:
|
|
229
|
+
continue
|
|
230
|
+
if prompt in (":q", "quit", "exit", "/exit"):
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
apply_capability_routing(cfg, prompt, context="prompt")
|
|
234
|
+
cfg.model = pick_effective_model(cfg, prompt)
|
|
235
|
+
|
|
236
|
+
# Start streaming assistant output.
|
|
237
|
+
sys.stdout.write(f" ⎿ {ansi.bold}GemCode{ansi.reset}: ")
|
|
238
|
+
sys.stdout.flush()
|
|
239
|
+
|
|
240
|
+
current_message = types.Content(role="user", parts=[types.Part(text=prompt)])
|
|
241
|
+
do_reset = True
|
|
242
|
+
|
|
243
|
+
while True:
|
|
244
|
+
events: list = []
|
|
245
|
+
kwargs = dict(user_id="local", session_id=session_id, new_message=current_message)
|
|
246
|
+
if run_config is not None:
|
|
247
|
+
kwargs["run_config"] = run_config
|
|
248
|
+
# (We don't handle token budget reset here; full-screen TUI does.)
|
|
249
|
+
|
|
250
|
+
async for ev in runner.run_async(**kwargs):
|
|
251
|
+
events.append(ev)
|
|
252
|
+
_render_tool_calls(ev)
|
|
253
|
+
try:
|
|
254
|
+
if not ev.content or not ev.content.parts:
|
|
255
|
+
continue
|
|
256
|
+
if not getattr(ev, "author", None) or ev.author == "user":
|
|
257
|
+
continue
|
|
258
|
+
for part in ev.content.parts:
|
|
259
|
+
delta = getattr(part, "text", None)
|
|
260
|
+
if delta:
|
|
261
|
+
await typewrite(delta)
|
|
262
|
+
except Exception:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
confirmation_fcs = _get_confirmation_fcs(events)
|
|
266
|
+
if not confirmation_fcs:
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
interactive_enabled = bool(getattr(cfg, "interactive_permission_ask", False))
|
|
270
|
+
parts: list[types.Part] = []
|
|
271
|
+
for fc in confirmation_fcs:
|
|
272
|
+
tool_name, hint = _extract_tool_and_hint(fc)
|
|
273
|
+
if not interactive_enabled:
|
|
274
|
+
print("")
|
|
275
|
+
print(
|
|
276
|
+
f" ⎿ {ansi.blue_warn}{ansi.bold}Permission needed{ansi.reset} for {ansi.bold}{tool_name}{ansi.reset} "
|
|
277
|
+
f"but perm mode is not ask. Denying."
|
|
278
|
+
)
|
|
279
|
+
ok = False
|
|
280
|
+
else:
|
|
281
|
+
print("")
|
|
282
|
+
if hint:
|
|
283
|
+
print(
|
|
284
|
+
f" ⎿ {ansi.blue}{ansi.bold}Permission needed{ansi.reset} for {ansi.bold}{tool_name}{ansi.reset}: {hint}"
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
print(
|
|
288
|
+
f" ⎿ {ansi.blue}{ansi.bold}Permission needed{ansi.reset} for {ansi.bold}{tool_name}{ansi.reset}."
|
|
289
|
+
)
|
|
290
|
+
ans = input(
|
|
291
|
+
f" ⎿ Allow? ({ansi.blue_ok}y{ansi.reset}/{ansi.dim}N{ansi.reset}) "
|
|
292
|
+
).strip().lower()
|
|
293
|
+
ok = ans in ("y", "yes")
|
|
294
|
+
# Resume the assistant indent after permission prompt.
|
|
295
|
+
sys.stdout.write(f" ⎿ {ansi.bold}GemCode{ansi.reset}: ")
|
|
296
|
+
sys.stdout.flush()
|
|
297
|
+
|
|
298
|
+
parts.append(
|
|
299
|
+
types.Part(
|
|
300
|
+
function_response=types.FunctionResponse(
|
|
301
|
+
name=REQUEST_CONFIRMATION_FC,
|
|
302
|
+
id=getattr(fc, "id", None),
|
|
303
|
+
response={"confirmed": bool(ok)},
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
current_message = types.Content(role="user", parts=parts)
|
|
308
|
+
do_reset = False
|
|
309
|
+
|
|
310
|
+
print("")
|
|
311
|
+
print("")
|
|
312
|
+
|
gemcode/vertex.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vertex AI (optional production path).
|
|
3
|
+
|
|
4
|
+
Set for Google GenAI client routing (see google-genai docs):
|
|
5
|
+
- GOOGLE_GENAI_USE_VERTEXAI=true
|
|
6
|
+
- GOOGLE_CLOUD_PROJECT=your-project-id
|
|
7
|
+
- GOOGLE_CLOUD_LOCATION=us-central1
|
|
8
|
+
|
|
9
|
+
Application Default Credentials (gcloud auth application-default login) or
|
|
10
|
+
service account for CI.
|
|
11
|
+
|
|
12
|
+
GemCode currently uses the default google-genai behavior from the environment;
|
|
13
|
+
no extra code is required for basic Vertex text generation once env is set.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def vertex_env_active() -> bool:
|
|
22
|
+
return os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("1", "true", "yes")
|
gemcode/web/__init__.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from gemcode.config import GemCodeConfig
|
|
13
|
+
from gemcode.session_runtime import create_runner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _extract_text_from_event(event: Any) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Best-effort extraction of assistant-visible text from ADK events.
|
|
19
|
+
|
|
20
|
+
GemCode's CLI uses `event.content.parts` and skips events whose author is
|
|
21
|
+
"user". We reuse the same heuristic so the web UI can render incremental
|
|
22
|
+
text deltas.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
content = getattr(event, "content", None)
|
|
26
|
+
author = getattr(event, "author", None)
|
|
27
|
+
if author == "user":
|
|
28
|
+
return ""
|
|
29
|
+
if not content or not getattr(content, "parts", None):
|
|
30
|
+
return ""
|
|
31
|
+
parts = content.parts
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
for p in parts:
|
|
34
|
+
t = getattr(p, "text", None)
|
|
35
|
+
if isinstance(t, str) and t:
|
|
36
|
+
out.append(t)
|
|
37
|
+
return "".join(out)
|
|
38
|
+
except Exception:
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extract_text_from_message_content(content: Any) -> str:
|
|
43
|
+
if isinstance(content, str):
|
|
44
|
+
return content
|
|
45
|
+
try:
|
|
46
|
+
return json.dumps(content, ensure_ascii=False)
|
|
47
|
+
except Exception:
|
|
48
|
+
return str(content)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_prompt(messages: list[dict[str, Any]]) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Claude's web UI sends the full conversation history in `messages`.
|
|
54
|
+
|
|
55
|
+
GemCode's current invocation is "single user message" only, so we embed the
|
|
56
|
+
conversation into the prompt text.
|
|
57
|
+
"""
|
|
58
|
+
lines: list[str] = []
|
|
59
|
+
for m in messages:
|
|
60
|
+
role = m.get("role")
|
|
61
|
+
content = _extract_text_from_message_content(m.get("content"))
|
|
62
|
+
if role == "user":
|
|
63
|
+
lines.append(f"User: {content}")
|
|
64
|
+
elif role == "assistant":
|
|
65
|
+
lines.append(f"Assistant: {content}")
|
|
66
|
+
if not lines:
|
|
67
|
+
return ""
|
|
68
|
+
return "Conversation so far:\n" + "\n".join(lines) + "\n\nNow respond as the assistant."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sse_emit(obj: dict[str, Any]) -> None:
|
|
72
|
+
sys.stdout.write(f"data: {json.dumps(obj)}\n\n")
|
|
73
|
+
sys.stdout.flush()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _iter_chunks(text: str, chunk_size: int) -> Iterable[str]:
|
|
77
|
+
if chunk_size <= 0:
|
|
78
|
+
yield text
|
|
79
|
+
return
|
|
80
|
+
for i in range(0, len(text), chunk_size):
|
|
81
|
+
yield text[i : i + chunk_size]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _emit_text_delta(index: int, delta: str) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Emit a text delta in smaller chunks to create smoother streaming in the UI.
|
|
87
|
+
|
|
88
|
+
The upstream ADK events can arrive in sentence-sized deltas; splitting reduces
|
|
89
|
+
"chunky" updates without requiring true token events.
|
|
90
|
+
"""
|
|
91
|
+
if not delta:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# Default small chunk for smoother streaming; override via env.
|
|
95
|
+
chunk_size = int(os.environ.get("GEMCODE_WEB_STREAM_CHUNK", "8"))
|
|
96
|
+
for piece in _iter_chunks(delta, max(1, chunk_size)):
|
|
97
|
+
_sse_emit(
|
|
98
|
+
{
|
|
99
|
+
"type": "content_block_delta",
|
|
100
|
+
"index": index,
|
|
101
|
+
"delta": {"type": "text_delta", "text": piece},
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
_sse_emit({"type": "text", "content": piece})
|
|
105
|
+
# Yield to the event loop so chunks flush promptly under load.
|
|
106
|
+
await asyncio.sleep(0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def run_adapter(req: dict[str, Any]) -> None:
|
|
110
|
+
# ---- Request parsing ----
|
|
111
|
+
messages = req.get("messages")
|
|
112
|
+
requested_model = req.get("model")
|
|
113
|
+
model = requested_model or os.environ.get("GEMCODE_MODEL") or "gemini-2.5-flash"
|
|
114
|
+
|
|
115
|
+
if not isinstance(messages, list):
|
|
116
|
+
raise ValueError("messages must be a list")
|
|
117
|
+
|
|
118
|
+
prompt = _build_prompt(messages)
|
|
119
|
+
|
|
120
|
+
# ---- Config ----
|
|
121
|
+
project_root = os.environ.get("GEMCODE_WEB_PROJECT_ROOT") or os.getcwd()
|
|
122
|
+
cfg = GemCodeConfig(project_root=Path(project_root))
|
|
123
|
+
|
|
124
|
+
# Permission mapping: for the web MVP we gate all mutations behind `--yes`
|
|
125
|
+
# style confirmation using an env flag.
|
|
126
|
+
cfg.permission_mode = os.environ.get("GEMCODE_PERMISSION_MODE", cfg.permission_mode)
|
|
127
|
+
cfg.yes_to_all = os.environ.get("GEMCODE_WEB_YES_TO_ALL", "false").lower() in (
|
|
128
|
+
"1",
|
|
129
|
+
"true",
|
|
130
|
+
"yes",
|
|
131
|
+
"on",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Model mapping/validation:
|
|
135
|
+
# The ported Claude UI sends Claude model ids (e.g. "claude-sonnet-4-6"),
|
|
136
|
+
# but GemCode uses Google GenAI model ids. Ignore unknown model ids and
|
|
137
|
+
# fall back to cfg defaults so web chat doesn't hard-fail.
|
|
138
|
+
MODEL_MAP: dict[str, str] = {
|
|
139
|
+
# GemCode UI model ids
|
|
140
|
+
"gemcode-pro": "gemini-2.5-pro",
|
|
141
|
+
"gemcode-balanced": "gemini-2.5-flash",
|
|
142
|
+
"gemcode-fast": "gemini-2.5-flash",
|
|
143
|
+
# Backward compatibility with older Claude-branded ids
|
|
144
|
+
"claude-opus-4-6": "gemini-2.5-pro",
|
|
145
|
+
"claude-sonnet-4-6": "gemini-2.5-flash",
|
|
146
|
+
"claude-haiku-4-5-20251001": "gemini-2.5-flash",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
resolved_model: str | None = None
|
|
150
|
+
if isinstance(requested_model, str) and requested_model.strip():
|
|
151
|
+
rm = requested_model.strip()
|
|
152
|
+
if rm in MODEL_MAP:
|
|
153
|
+
resolved_model = MODEL_MAP[rm]
|
|
154
|
+
elif rm.startswith("gemini") or rm.startswith("models/"):
|
|
155
|
+
resolved_model = rm
|
|
156
|
+
|
|
157
|
+
if resolved_model:
|
|
158
|
+
cfg.model = resolved_model
|
|
159
|
+
cfg.model_overridden = True
|
|
160
|
+
model = resolved_model
|
|
161
|
+
|
|
162
|
+
# ---- Session + runner ----
|
|
163
|
+
session_id = req.get("session_id") or str(uuid.uuid4())
|
|
164
|
+
|
|
165
|
+
# ---- Claude-like stream event mapping (text-only MVP) ----
|
|
166
|
+
# We emit the full StreamEvent shape for useChat, and a simplified StreamChunk
|
|
167
|
+
# shape for ChatInput. Tools are not mapped yet (text-only).
|
|
168
|
+
message_id = f"msg_{uuid.uuid4().hex[:12]}"
|
|
169
|
+
assistant_block_index = 0
|
|
170
|
+
|
|
171
|
+
# message_start + content_block_start before the first token
|
|
172
|
+
_sse_emit(
|
|
173
|
+
{
|
|
174
|
+
"type": "message_start",
|
|
175
|
+
"message": {
|
|
176
|
+
"id": message_id,
|
|
177
|
+
"role": "assistant",
|
|
178
|
+
"model": model,
|
|
179
|
+
"usage": {"input_tokens": 0, "output_tokens": 0},
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
_sse_emit(
|
|
184
|
+
{
|
|
185
|
+
"type": "content_block_start",
|
|
186
|
+
"index": assistant_block_index,
|
|
187
|
+
"content_block": {"type": "text", "text": ""},
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
emitted_text = ""
|
|
192
|
+
runner = None
|
|
193
|
+
try:
|
|
194
|
+
# ---- Mock mode (for web smoke tests / local dev without API keys) ----
|
|
195
|
+
mock_response = os.environ.get("GEMCODE_WEB_MOCK_RESPONSE")
|
|
196
|
+
if isinstance(mock_response, str) and mock_response.strip():
|
|
197
|
+
full = mock_response
|
|
198
|
+
# Emit small deltas so the frontend can exercise its streaming UI.
|
|
199
|
+
chunk_size = int(os.environ.get("GEMCODE_WEB_MOCK_CHUNK", "6"))
|
|
200
|
+
for i in range(0, len(full), max(1, chunk_size)):
|
|
201
|
+
delta = full[i : i + chunk_size]
|
|
202
|
+
emitted_text += delta
|
|
203
|
+
_sse_emit(
|
|
204
|
+
{
|
|
205
|
+
"type": "content_block_delta",
|
|
206
|
+
"index": assistant_block_index,
|
|
207
|
+
"delta": {"type": "text_delta", "text": delta},
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
_sse_emit({"type": "text", "content": delta})
|
|
211
|
+
await asyncio.sleep(0.01)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
# Real ADK streaming mode
|
|
216
|
+
runner = create_runner(cfg, extra_tools=None)
|
|
217
|
+
|
|
218
|
+
# Import here to avoid unused dependency import at module init.
|
|
219
|
+
from google.adk.agents.run_config import RunConfig
|
|
220
|
+
from google.genai import types
|
|
221
|
+
|
|
222
|
+
new_message = types.Content(role="user", parts=[types.Part(text=prompt)])
|
|
223
|
+
run_config = (
|
|
224
|
+
RunConfig(max_llm_calls=cfg.max_llm_calls)
|
|
225
|
+
if cfg.max_llm_calls is not None
|
|
226
|
+
else None
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async for event in runner.run_async(
|
|
230
|
+
user_id=req.get("user_id") or "web",
|
|
231
|
+
session_id=session_id,
|
|
232
|
+
new_message=new_message,
|
|
233
|
+
**({"run_config": run_config} if run_config is not None else {}),
|
|
234
|
+
):
|
|
235
|
+
text = _extract_text_from_event(event)
|
|
236
|
+
if not text:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Ensure we emit only forward deltas.
|
|
240
|
+
if text.startswith(emitted_text):
|
|
241
|
+
delta = text[len(emitted_text) :]
|
|
242
|
+
else:
|
|
243
|
+
# Fallback: compute a conservative common prefix.
|
|
244
|
+
common = 0
|
|
245
|
+
max_common = min(len(text), len(emitted_text))
|
|
246
|
+
while common < max_common and text[common] == emitted_text[common]:
|
|
247
|
+
common += 1
|
|
248
|
+
delta = text[common:]
|
|
249
|
+
|
|
250
|
+
if delta:
|
|
251
|
+
emitted_text += delta
|
|
252
|
+
await _emit_text_delta(assistant_block_index, delta)
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
_sse_emit({"type": "error", "error": {"type": "server", "message": str(e)}})
|
|
256
|
+
_sse_emit({"type": "error", "error": str(e)})
|
|
257
|
+
finally:
|
|
258
|
+
# Close the StreamEvent content block even if we produced no tokens.
|
|
259
|
+
_sse_emit({"type": "content_block_stop", "index": assistant_block_index})
|
|
260
|
+
_sse_emit({"type": "message_stop"})
|
|
261
|
+
_sse_emit({"type": "done"})
|
|
262
|
+
|
|
263
|
+
if runner is not None:
|
|
264
|
+
try:
|
|
265
|
+
await runner.close()
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main() -> None:
|
|
271
|
+
# ---- Read JSON request from stdin ----
|
|
272
|
+
raw = sys.stdin.read()
|
|
273
|
+
if not raw.strip():
|
|
274
|
+
raise RuntimeError("Empty request")
|
|
275
|
+
req = json.loads(raw)
|
|
276
|
+
|
|
277
|
+
asyncio.run(run_adapter(req))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
main()
|
|
282
|
+
|