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,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")
@@ -0,0 +1,2 @@
1
+ """Web adapter modules for GemCode."""
2
+
@@ -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
+