gemcode 0.3.28__py3-none-any.whl → 0.3.30__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/agent.py CHANGED
@@ -66,6 +66,8 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
66
66
 
67
67
  # ── Active capabilities ──────────────────────────────────────────────────
68
68
  caps: list[str] = []
69
+ if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
70
+ caps.append("web_search ON (tool: google_search — standalone search without full deep_research)")
69
71
  if getattr(cfg, "enable_deep_research", False):
70
72
  dr_extras = " + google_maps_grounding" if getattr(cfg, "enable_maps_grounding", False) else ""
71
73
  caps.append(f"deep_research ON (tools: google_search, url_context{dr_extras})")
@@ -77,8 +79,12 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
77
79
  caps.append(f"memory ON ({mem_kind}, stored at {mem_path}; ADK preload_memory injects relevant memories before each turn)")
78
80
  if getattr(cfg, "enable_computer_use", False):
79
81
  caps.append("computer_use ON (tools: navigate, click_at, type_text_at, browser_screenshot, browser_find_element, etc.)")
82
+ if getattr(cfg, "enable_code_executor", False):
83
+ caps.append("code_executor ON — you can write Python code blocks and they will be executed safely via Gemini's built-in sandboxed executor; results appear as code_execution_result events. Use this for math, data processing, quick tests, and anything that would otherwise require a shell command.")
84
+ if getattr(cfg, "enable_artifacts", True):
85
+ caps.append("artifacts ON — use save_artifact(filename, bytes, mime_type) / load_artifact(filename) to store large/binary outputs (screenshots, PDFs, generated files) outside session history. Artifacts are keyed by filename; prefix 'user:' for cross-session persistence.")
80
86
  if not caps:
81
- caps.append("none enabled (use /research on, /embeddings on, /memory on, /computer on to enable)")
87
+ caps.append("none enabled (use /research on, /embeddings on, /memory on, /computer on, /code on to enable)")
82
88
  caps_text = "\n".join(f" - {c}" for c in caps)
83
89
 
84
90
  # ── Limits ───────────────────────────────────────────────────────────────
@@ -147,6 +153,40 @@ Memory is **ON** ({kind}). Stored at: `{mem_path}`
147
153
  """
148
154
 
149
155
 
156
+ def _build_plan_mode_section() -> str:
157
+ """Injected when plan_mode=True — instructs agent to write explicit plans first."""
158
+ return """
159
+ ## PLAN MODE IS ACTIVE
160
+
161
+ You are currently in **Plan Mode**. Before executing ANY tools that modify files or run shell commands, you MUST:
162
+
163
+ 1. **Write a numbered plan** in your response text — list every step you intend to take.
164
+ Example:
165
+ ```
166
+ Plan:
167
+ 1. Read src/auth/login.ts to understand the current flow
168
+ 2. Read src/types/user.ts for the User interface
169
+ 3. Add `lastLogin: Date` field to User interface
170
+ 4. Update login handler to set lastLogin on successful auth
171
+ 5. Run `npm run build` to verify no TypeScript errors
172
+ ```
173
+
174
+ 2. **Pause after the plan** — do not immediately execute tools. Present the plan and wait for the user to confirm ("go", "proceed", "looks good") before starting.
175
+
176
+ 3. **Stick to the plan** — if you discover the plan needs changing mid-execution, note the update before proceeding.
177
+
178
+ 4. **Report completion** against the plan — when done, confirm each step as completed.
179
+
180
+ **Why plan mode?**
181
+ - Prevents unintended side effects from premature tool execution
182
+ - Gives you and the user visibility into the full scope before any changes
183
+ - Makes complex multi-file tasks reviewable and reversible
184
+ - Catches scope creep early
185
+
186
+ **To turn off plan mode**, type `/plan off` at the prompt.
187
+ """
188
+
189
+
150
190
  def _build_computer_use_section(cfg: GemCodeConfig) -> str:
151
191
  """Rich computer use guidance, only injected when enable_computer_use=True."""
152
192
  w = getattr(cfg, "_cfg", None)
@@ -426,6 +466,41 @@ Use `run_subtask` when the work is better done in an isolated context:
426
466
 
427
467
  The sub-agent inherits your permission settings and returns its final text as `result`. Treat it as a trusted colleague returning a written summary.
428
468
 
469
+ ## ADK Special Tools (always available when ADK supports them)
470
+
471
+ ### `get_user_choice`
472
+ Present the user with a structured multi-option prompt rather than open-ended questions.
473
+ Use when you need the user to pick from 2–6 specific options (e.g. "Which framework would you like?", "Choose migration strategy: A, B, or C").
474
+ This provides a better UX than asking them to type a free-form answer.
475
+
476
+ ### `load_artifacts`
477
+ Load binary/large artifacts that were saved in a previous turn or by a sub-agent.
478
+ Artifacts are keyed by filename (e.g. "report.pdf", "screenshot.png", "output.json").
479
+ Use `user:filename` prefix for user-scoped artifacts that persist across sessions.
480
+ After loading, the artifact bytes are available for further processing (display, analysis, transformation).
481
+
482
+ ### `exit_loop`
483
+ Signal the surrounding LoopAgent to stop iterating and return the final result.
484
+ Only meaningful when this agent is running inside an ADK LoopAgent pipeline.
485
+ Call this when the task is complete and no further iterations are needed.
486
+
487
+ ## Artifacts — storing large outputs
488
+ When `artifacts ON` (see Runtime facts above):
489
+ - **Save** large generated content as artifacts instead of printing them inline:
490
+ - Screenshots from computer_use: save as "screenshot.png" artifact
491
+ - Generated reports/PDFs: save as "report.pdf" artifact
492
+ - Large JSON data: save as "data.json" artifact
493
+ - **Reference** artifacts in instructions via `{artifact.filename?}` template syntax
494
+ - Artifacts are keyed by filename; `user:` prefix = cross-session persistence
495
+
496
+ ## Code Executor (sandboxed Python)
497
+ When `code_executor ON` (see Runtime facts above):
498
+ - You can write Python code blocks in your response and the Gemini API executes them safely
499
+ - The result appears as a `code_execution_result` event with stdout and the outcome
500
+ - Best for: math calculations, data transformation, unit testing logic, quick experiments
501
+ - The sandbox does NOT have internet access or filesystem access — use for pure computation
502
+ - For file I/O or shell commands, use the standard tools (`bash`, `write_file`, etc.)
503
+
429
504
  ## Evaluator-optimizer loop
430
505
  For tasks where quality matters:
431
506
  1. Complete the task (execute tools, write code, run commands)
@@ -461,6 +536,9 @@ All file tools use paths **relative to the project root** (where GemCode was sta
461
536
  if getattr(cfg, "enable_memory", False):
462
537
  base = f"{base}\n\n{_build_memory_section(cfg)}"
463
538
 
539
+ if getattr(cfg, "plan_mode", False):
540
+ base = f"{base}\n\n{_build_plan_mode_section()}"
541
+
464
542
  tool_manifest = build_tool_manifest(cfg)
465
543
  if tool_manifest:
466
544
  base = f"{base}\n\n{tool_manifest}"
@@ -470,6 +548,17 @@ All file tools use paths **relative to the project root** (where GemCode was sta
470
548
  return base
471
549
 
472
550
 
551
+ def _build_code_executor(cfg: GemCodeConfig):
552
+ """Return an ADK BuiltInCodeExecutor when enable_code_executor=True, else None."""
553
+ if not getattr(cfg, "enable_code_executor", False):
554
+ return None
555
+ try:
556
+ from google.adk.code_executors import BuiltInCodeExecutor
557
+ return BuiltInCodeExecutor()
558
+ except Exception:
559
+ return None
560
+
561
+
473
562
  def build_root_agent(
474
563
  cfg: GemCodeConfig,
475
564
  extra_tools: list | None = None,
@@ -488,12 +577,19 @@ def build_root_agent(
488
577
  if _tools is not None:
489
578
  tools = list(_tools)
490
579
  else:
491
- tools = build_function_tools(cfg)
580
+ tools = build_function_tools(cfg)
492
581
  if getattr(cfg, "enable_memory", False):
493
582
  # ADK preload_memory injects retrieved memories into the next llm_request.
494
583
  from google.adk.tools import preload_memory
495
-
496
584
  tools = [preload_memory, *tools]
585
+
586
+ # ADK built-in interactive + artifact tools — always available when ADK supports them.
587
+ try:
588
+ from google.adk.tools import get_user_choice, load_artifacts, exit_loop
589
+ tools = [*tools, get_user_choice, load_artifacts, exit_loop]
590
+ except Exception:
591
+ pass
592
+
497
593
  if extra_tools:
498
594
  tools = [*tools, *extra_tools]
499
595
 
@@ -548,15 +644,31 @@ def build_root_agent(
548
644
  tool_config=tool_cfg,
549
645
  )
550
646
 
551
- return LlmAgent(
647
+ # global_instruction applies to the entire agent tree (including sub-agents
648
+ # spawned via run_subtask or multi-agent delegation). Keep it short — it's
649
+ # prepended to every agent's effective instruction.
650
+ global_instr = (
651
+ "You are GemCode, an expert software engineering agent powered by Google Gemini. "
652
+ "Act, don't advise. Complete tasks fully and autonomously. "
653
+ "Think before destructive actions. Use read-only tools before shell/write tools."
654
+ )
655
+
656
+ agent_kwargs: dict = dict(
552
657
  model=cfg.model,
553
658
  name="gemcode",
554
659
  instruction=build_instruction(cfg),
660
+ global_instruction=global_instr,
555
661
  tools=tools,
556
662
  generate_content_config=gen_cfg,
557
663
  **cb_kwargs,
558
664
  )
559
665
 
666
+ code_executor = _build_code_executor(cfg)
667
+ if code_executor is not None:
668
+ agent_kwargs["code_executor"] = code_executor
669
+
670
+ return LlmAgent(**agent_kwargs)
671
+
560
672
 
561
673
  def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None):
562
674
  """Backward-compatible: prefer `gemcode.session_runtime.create_runner`."""
gemcode/callbacks.py CHANGED
@@ -141,6 +141,22 @@ def make_before_tool_callback(cfg: GemCodeConfig):
141
141
  record = {"tool": name, "args": _redact_args(name, args)}
142
142
  append_audit(cfg.project_root, record)
143
143
 
144
+ # ── Shell hooks: pre_tool_use ─────────────────────────────────────────
145
+ # If the project has a .gemcode/hooks/pre_tool_use.sh, run it now.
146
+ # Non-zero exit or {"decision":"deny"} stdout will block the tool call.
147
+ try:
148
+ from gemcode.hooks import run_pre_tool_use_hook
149
+ hook_result = run_pre_tool_use_hook(
150
+ cfg.project_root,
151
+ model=getattr(cfg, "model", "") or "",
152
+ tool_name=name,
153
+ args=args or {},
154
+ )
155
+ if hook_result is not None:
156
+ return hook_result
157
+ except Exception:
158
+ pass
159
+
144
160
  streak = 0
145
161
  if tool_context is not None:
146
162
  try:
@@ -316,6 +332,19 @@ def make_after_tool_callback(cfg: GemCodeConfig):
316
332
  st[_STATE_FAILURE_KEY] = 0
317
333
  else:
318
334
  st[_STATE_FAILURE_KEY] = 0
335
+ # ── Shell hooks: post_tool_use ────────────────────────────────────────
336
+ try:
337
+ from gemcode.hooks import run_post_tool_use_hook
338
+ run_post_tool_use_hook(
339
+ cfg.project_root,
340
+ model=getattr(cfg, "model", "") or "",
341
+ tool_name=name,
342
+ args=args or {},
343
+ result=tool_response if isinstance(tool_response, dict) else {},
344
+ )
345
+ except Exception:
346
+ pass
347
+
319
348
  if _maybe_tool_summary_enabled():
320
349
  summary: dict[str, Any] = {
321
350
  "phase": "tool_result",
@@ -399,6 +428,7 @@ def make_after_model_callback(cfg: GemCodeConfig):
399
428
  "candidates_token_count",
400
429
  "cached_content_token_count",
401
430
  "total_token_count",
431
+ "thoughts_token_count",
402
432
  ):
403
433
  if hasattr(um, attr):
404
434
  v = getattr(um, attr)
@@ -407,6 +437,44 @@ def make_after_model_callback(cfg: GemCodeConfig):
407
437
  if d:
408
438
  append_audit(cfg.project_root, {"phase": "model_usage", **d})
409
439
 
440
+ # ── Expose live token stats to the TUI ───────────────────────────────────
441
+ # The TUI reads cfg._last_turn_stats after each turn to display token counts
442
+ # and estimated cost in the footer (like OpenClaude's spinner token display).
443
+ try:
444
+ in_tok = d.get("prompt_token_count", 0) or 0
445
+ out_tok = d.get("candidates_token_count", 0) or 0
446
+ think_tok = d.get("thoughts_token_count", 0) or 0
447
+ cache_tok = d.get("cached_content_token_count", 0) or 0
448
+ total_tok = d.get("total_token_count", 0) or 0
449
+
450
+ prev_session_tokens = int(st.get(SESSION_TOTAL_TOKENS_KEY, 0) or 0)
451
+ session_total = prev_session_tokens + total_tok
452
+
453
+ from gemcode.pricing import estimate_cost
454
+ turn_cost = estimate_cost(
455
+ getattr(cfg, "model", "") or "",
456
+ input_tokens=in_tok,
457
+ output_tokens=out_tok,
458
+ )
459
+ # Accumulate session cost
460
+ prev_cost = getattr(cfg, "_session_cost_usd", 0.0) or 0.0
461
+ session_cost = prev_cost + (turn_cost or 0.0)
462
+ object.__setattr__(cfg, "_session_cost_usd", session_cost)
463
+
464
+ stats: dict[str, Any] = {
465
+ "in": in_tok,
466
+ "out": out_tok,
467
+ "think": think_tok,
468
+ "cache": cache_tok,
469
+ "total": total_tok,
470
+ "session_total": session_total,
471
+ "turn_cost": turn_cost,
472
+ "session_cost": session_cost,
473
+ }
474
+ object.__setattr__(cfg, "_last_turn_stats", stats)
475
+ except Exception:
476
+ pass
477
+
410
478
  pt = d.get("prompt_token_count")
411
479
  if isinstance(pt, int) and pt >= 0:
412
480
  try:
gemcode/config.py CHANGED
@@ -251,6 +251,37 @@ class GemCodeConfig:
251
251
  default_factory=lambda: _truthy_env("GEMCODE_SHOW_FULL_THINKING", default=False)
252
252
  )
253
253
 
254
+ # Enable ADK BuiltInCodeExecutor for safe sandboxed Python execution via
255
+ # Gemini's code execution API. When on, the agent can write and run Python
256
+ # snippets inline (math, data processing, quick tests) without requiring
257
+ # bash/shell permissions. Requires a Gemini model that supports code execution
258
+ # (gemini-2.5-flash, gemini-2.5-pro, gemini-3.x).
259
+ enable_code_executor: bool = field(
260
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_CODE_EXECUTOR", default=False)
261
+ )
262
+
263
+ # Enable ADK artifact service for storing large/binary outputs (screenshots,
264
+ # generated files, reports) outside of session history.
265
+ # When on, the agent can save_artifact / load_artifact keyed by filename.
266
+ enable_artifacts: bool = field(
267
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_ARTIFACTS", default=True)
268
+ )
269
+
270
+ # Plan mode: when ON, the agent explicitly writes out a numbered plan
271
+ # BEFORE executing any tools, then checks the plan before reporting done.
272
+ # Like OpenClaude's EnterPlanMode — great for complex, multi-file tasks.
273
+ # Toggle at runtime with /plan on|off.
274
+ plan_mode: bool = field(
275
+ default_factory=lambda: _truthy_env("GEMCODE_PLAN_MODE", default=False)
276
+ )
277
+
278
+ # Always-on web search (independent of deep_research / research mode).
279
+ # When True, google_search is available as a basic tool without enabling
280
+ # the full deep_research capability suite (url_context, maps, etc.).
281
+ enable_web_search: bool = field(
282
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_WEB_SEARCH", default=False)
283
+ )
284
+
254
285
  def __post_init__(self) -> None:
255
286
  self.project_root = self.project_root.resolve()
256
287
  # Default agentic depth when env omits GEMCODE_MAX_LLM_CALLS (was: None → SDK default).
gemcode/hooks.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ GemCode shell hooks — scriptable lifecycle events inspired by OpenClaude.
3
+
4
+ Hooks are shell scripts stored under ``<project_root>/.gemcode/hooks/``.
5
+ They receive a JSON payload on stdin and can:
6
+ - pre_tool_use.sh: return non-zero exit to DENY the tool call, or
7
+ print JSON {"decision":"deny","reason":"..."} to stderr.
8
+ - post_tool_use.sh: informational; return value ignored.
9
+ - session_start.sh: runs when a new GemCode session starts.
10
+ - session_stop.sh: runs when the session ends.
11
+
12
+ HOOK ENVIRONMENT
13
+ All hooks receive these env vars:
14
+ GEMCODE_HOOK_TYPE — "pre_tool_use" | "post_tool_use" | "session_start" | "session_stop"
15
+ GEMCODE_PROJECT_ROOT — absolute path to the project root
16
+ GEMCODE_MODEL — active model id
17
+
18
+ HOOK STDIN (pre_tool_use, post_tool_use)
19
+ JSON object with:
20
+ { "tool": "<tool_name>",
21
+ "args": { ...tool_args... },
22
+ "type": "pre_tool_use" | "post_tool_use",
23
+ "result": { ...tool_result... } // post only
24
+ }
25
+
26
+ PRE_TOOL_USE DECISION
27
+ If exit code is non-zero → the tool is DENIED.
28
+ If exit code is 0 → the tool proceeds normally.
29
+ If stdout starts with '{' and contains "decision": "deny" → the tool is denied.
30
+
31
+ EXAMPLE HOOKS
32
+ # .gemcode/hooks/pre_tool_use.sh
33
+ #!/bin/bash
34
+ # Deny all delete_file calls
35
+ HOOK=$(cat)
36
+ TOOL=$(echo "$HOOK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tool'])")
37
+ if [ "$TOOL" = "delete_file" ]; then
38
+ echo "delete_file blocked by hook" >&2
39
+ exit 1
40
+ fi
41
+
42
+ # .gemcode/hooks/session_start.sh
43
+ #!/bin/bash
44
+ echo "[gemcode hook] session started in $GEMCODE_PROJECT_ROOT" >> /tmp/gemcode-audit.log
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import json
50
+ import logging
51
+ import os
52
+ import subprocess
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ log = logging.getLogger(__name__)
57
+
58
+ _HOOKS_DIR = ".gemcode/hooks"
59
+ _TIMEOUT_PRE = 5.0 # seconds — pre_tool_use must respond quickly
60
+ _TIMEOUT_POST = 10.0 # seconds — post_tool_use has more budget
61
+
62
+
63
+ def _find_hook(project_root: Path, name: str) -> Path | None:
64
+ """Return the hook file path if it exists and is executable, else None."""
65
+ hooks_dir = project_root / _HOOKS_DIR
66
+ for ext in ("", ".sh", ".py", ".bash"):
67
+ candidate = hooks_dir / f"{name}{ext}"
68
+ if candidate.is_file() and os.access(candidate, os.X_OK):
69
+ return candidate
70
+ return None
71
+
72
+
73
+ def _run_hook(
74
+ hook_path: Path,
75
+ project_root: Path,
76
+ model: str,
77
+ hook_type: str,
78
+ stdin_data: dict | None = None,
79
+ timeout: float = 5.0,
80
+ ) -> tuple[int, str, str]:
81
+ """
82
+ Run a hook script.
83
+
84
+ Returns (exit_code, stdout, stderr).
85
+ Never raises — all exceptions are caught and logged.
86
+ """
87
+ try:
88
+ env = {
89
+ **os.environ,
90
+ "GEMCODE_HOOK_TYPE": hook_type,
91
+ "GEMCODE_PROJECT_ROOT": str(project_root),
92
+ "GEMCODE_MODEL": model or "",
93
+ }
94
+ stdin_bytes = (
95
+ json.dumps(stdin_data, ensure_ascii=False).encode() if stdin_data else b""
96
+ )
97
+ result = subprocess.run(
98
+ [str(hook_path)],
99
+ input=stdin_bytes,
100
+ capture_output=True,
101
+ timeout=timeout,
102
+ env=env,
103
+ cwd=str(project_root),
104
+ )
105
+ return result.returncode, result.stdout.decode(errors="replace"), result.stderr.decode(errors="replace")
106
+ except subprocess.TimeoutExpired:
107
+ log.warning("[hooks] %s timed out after %.1fs", hook_path.name, timeout)
108
+ return 1, "", f"hook timed out after {timeout}s"
109
+ except Exception as exc:
110
+ log.debug("[hooks] failed to run %s: %s", hook_path.name, exc)
111
+ return 1, "", str(exc)
112
+
113
+
114
+ def run_pre_tool_use_hook(
115
+ project_root: Path,
116
+ model: str,
117
+ tool_name: str,
118
+ args: dict[str, Any],
119
+ ) -> dict | None:
120
+ """
121
+ Run the pre_tool_use hook (if it exists).
122
+
123
+ Returns:
124
+ None → tool is allowed (default)
125
+ dict → {"error": "...", "error_kind": "hook_denied"} to deny the tool
126
+ """
127
+ hook = _find_hook(project_root, "pre_tool_use")
128
+ if hook is None:
129
+ return None
130
+
131
+ payload = {"tool": tool_name, "args": args, "type": "pre_tool_use"}
132
+ rc, stdout, stderr = _run_hook(
133
+ hook, project_root, model, "pre_tool_use", payload, timeout=_TIMEOUT_PRE
134
+ )
135
+
136
+ if rc != 0:
137
+ reason = stderr.strip() or stdout.strip() or f"hook exited {rc}"
138
+ log.info("[hooks] pre_tool_use denied %s: %s", tool_name, reason[:200])
139
+ return {
140
+ "error": f"Tool denied by pre_tool_use hook: {reason[:400]}",
141
+ "error_kind": "hook_denied",
142
+ }
143
+
144
+ # Check if stdout contains an explicit deny decision.
145
+ if stdout.strip().startswith("{"):
146
+ try:
147
+ decision = json.loads(stdout.strip())
148
+ if str(decision.get("decision", "")).lower() == "deny":
149
+ reason = decision.get("reason", "hook denied")
150
+ log.info("[hooks] pre_tool_use JSON-denied %s: %s", tool_name, reason)
151
+ return {
152
+ "error": f"Tool denied by hook: {reason}",
153
+ "error_kind": "hook_denied",
154
+ }
155
+ except Exception:
156
+ pass
157
+
158
+ return None # allowed
159
+
160
+
161
+ def run_post_tool_use_hook(
162
+ project_root: Path,
163
+ model: str,
164
+ tool_name: str,
165
+ args: dict[str, Any],
166
+ result: dict[str, Any],
167
+ ) -> None:
168
+ """Run the post_tool_use hook if it exists. Return value is ignored."""
169
+ hook = _find_hook(project_root, "post_tool_use")
170
+ if hook is None:
171
+ return
172
+ payload = {
173
+ "tool": tool_name,
174
+ "args": args,
175
+ "result": result,
176
+ "type": "post_tool_use",
177
+ }
178
+ _run_hook(hook, project_root, model, "post_tool_use", payload, timeout=_TIMEOUT_POST)
179
+
180
+
181
+ def run_session_start_hook(project_root: Path, model: str) -> None:
182
+ """Run the session_start hook if it exists."""
183
+ hook = _find_hook(project_root, "session_start")
184
+ if hook is None:
185
+ return
186
+ _run_hook(hook, project_root, model, "session_start", timeout=10.0)
187
+
188
+
189
+ def run_session_stop_hook(project_root: Path, model: str) -> None:
190
+ """Run the session_stop hook if it exists."""
191
+ hook = _find_hook(project_root, "session_stop")
192
+ if hook is None:
193
+ return
194
+ _run_hook(hook, project_root, model, "session_stop", timeout=10.0)
gemcode/modality_tools.py CHANGED
@@ -172,9 +172,23 @@ def build_extra_tools(cfg: GemCodeConfig) -> list[Any]:
172
172
  """Return ADK tool unions to expose for enabled modalities."""
173
173
  extra: list[Any] = []
174
174
 
175
+ # ── Web search (standalone, no full deep_research needed) ────────────────
176
+ # enable_web_search=True adds google_search alone.
177
+ # enable_deep_research=True adds google_search + url_context + optional maps.
178
+ # If both are on, avoid adding google_search twice.
179
+ web_search_added = False
180
+ if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
181
+ try:
182
+ from google.adk.tools import google_search
183
+ extra.append(google_search)
184
+ web_search_added = True
185
+ except Exception:
186
+ pass
187
+
175
188
  if getattr(cfg, "enable_deep_research", False):
176
189
  from google.adk.tools import google_search, url_context
177
- extra.append(google_search)
190
+ if not web_search_added:
191
+ extra.append(google_search)
178
192
  extra.append(url_context)
179
193
  # Google Maps grounding can be incompatible with other built-in tools
180
194
  # (e.g., google_search) depending on the request/model tooling layer.
gemcode/pricing.py ADDED
@@ -0,0 +1,76 @@
1
+ """
2
+ Per-model token pricing for cost estimation.
3
+
4
+ All prices are USD per 1 million tokens (input / output separately).
5
+ Sources: Google AI Studio / Vertex AI pricing pages as of 2026.
6
+
7
+ Usage:
8
+ from gemcode.pricing import estimate_cost
9
+ usd = estimate_cost("gemini-2.5-flash", input_tokens=5000, output_tokens=1200)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ # Format: model_prefix → (input_$/M, output_$/M)
15
+ # Matches are done by checking if the model id STARTS WITH any key (longest first).
16
+ _PRICING_TABLE: dict[str, tuple[float, float]] = {
17
+ # ── Gemini 2.5 ──────────────────────────────────────────────────────────
18
+ "gemini-2.5-pro": (3.50, 10.50), # standard context ≤200k
19
+ "gemini-2.5-flash": (0.15, 0.60), # standard context
20
+ "gemini-2.5-flash-8b": (0.037, 0.15), # 8B lite variant
21
+ # ── Gemini 2.0 ──────────────────────────────────────────────────────────
22
+ "gemini-2.0-flash": (0.10, 0.40),
23
+ "gemini-2.0-flash-lite": (0.075, 0.30),
24
+ "gemini-2.0-pro": (3.50, 10.50),
25
+ # ── Gemini 1.5 ──────────────────────────────────────────────────────────
26
+ "gemini-1.5-flash": (0.075, 0.30),
27
+ "gemini-1.5-pro": (1.25, 5.00),
28
+ # ── Gemini experimental / preview ───────────────────────────────────────
29
+ "gemini-exp": (0.00, 0.00), # free during preview
30
+ "gemini-3": (0.30, 1.20), # approximate future pricing
31
+ }
32
+
33
+
34
+ def estimate_cost(
35
+ model: str,
36
+ *,
37
+ input_tokens: int = 0,
38
+ output_tokens: int = 0,
39
+ ) -> float | None:
40
+ """
41
+ Return estimated USD cost for a model turn, or None if pricing is unknown.
42
+
43
+ Args:
44
+ model: Model id string (e.g. "gemini-2.5-flash").
45
+ input_tokens: Prompt/input token count.
46
+ output_tokens: Candidates/output token count.
47
+ """
48
+ if not model:
49
+ return None
50
+ model_lower = model.lower().strip()
51
+ # Match longest prefix first for specificity.
52
+ for prefix in sorted(_PRICING_TABLE, key=len, reverse=True):
53
+ if model_lower.startswith(prefix):
54
+ in_rate, out_rate = _PRICING_TABLE[prefix]
55
+ return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
56
+ return None
57
+
58
+
59
+ def format_cost(usd: float | None) -> str:
60
+ """Human-readable cost string, e.g. '$0.0012' or '$1.23'."""
61
+ if usd is None:
62
+ return ""
63
+ if usd < 0.0001:
64
+ return f"<$0.0001"
65
+ if usd < 0.01:
66
+ return f"${usd:.4f}"
67
+ return f"${usd:.3f}"
68
+
69
+
70
+ def format_tokens(n: int) -> str:
71
+ """Format a token count compactly: 1234 → '1.2k', 12345678 → '12.3M'."""
72
+ if n >= 1_000_000:
73
+ return f"{n / 1_000_000:.1f}M"
74
+ if n >= 1_000:
75
+ return f"{n / 1_000:.1f}k"
76
+ return str(n)
gemcode/refine.py ADDED
@@ -0,0 +1,204 @@
1
+ """
2
+ Iterative refinement pipelines using ADK LoopAgent + SequentialAgent.
3
+
4
+ This module exposes factory functions for building multi-agent orchestration
5
+ patterns that GemCode can invoke for complex, multi-step tasks:
6
+
7
+ - build_refine_loop(): LoopAgent that loops write→test→fix until tests pass
8
+ or max_iterations is reached (escalates via exit_loop tool).
9
+
10
+ - build_sequential_pipeline(): SequentialAgent that runs N specialist agents
11
+ in order, passing results through session state via output_key.
12
+
13
+ - build_parallel_research(): ParallelAgent that fans out N research sub-agents
14
+ then collects their outputs.
15
+
16
+ These are "heavy" patterns; they are NOT wired by default — callers instantiate
17
+ them on demand (e.g. via /refine slash command or run_subtask).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Guard: check what's available in the installed ADK version
29
+ # ---------------------------------------------------------------------------
30
+ try:
31
+ from google.adk.agents import LoopAgent, SequentialAgent, ParallelAgent, LlmAgent # type: ignore
32
+ _ADK_WORKFLOW_AGENTS_OK = True
33
+ except ImportError:
34
+ _ADK_WORKFLOW_AGENTS_OK = False
35
+ log.warning("google.adk workflow agents (LoopAgent/SequentialAgent/ParallelAgent) "
36
+ "not available — refine pipelines disabled.")
37
+
38
+
39
+ def _require_workflow() -> None:
40
+ if not _ADK_WORKFLOW_AGENTS_OK:
41
+ raise RuntimeError(
42
+ "ADK workflow agents (LoopAgent / SequentialAgent / ParallelAgent) are not "
43
+ "available in the installed google-adk version. "
44
+ "Try: pip install -U google-adk"
45
+ )
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Write → Test → Fix Loop
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def build_refine_loop(
53
+ *,
54
+ model: str,
55
+ task_description: str,
56
+ test_command: str,
57
+ max_iterations: int = 8,
58
+ extra_tools: list | None = None,
59
+ ) -> Any:
60
+ """
61
+ Build a LoopAgent that iteratively writes code, runs tests, and fixes
62
+ failures until all tests pass or ``max_iterations`` is exhausted.
63
+
64
+ Architecture:
65
+ LoopAgent(max_iterations=N)
66
+ ├─ writer_agent — writes / edits code based on current task + test output
67
+ ├─ tester_agent — runs the test command via bash, stores result in state
68
+ └─ checker_agent — inspects test result; calls exit_loop if tests pass
69
+
70
+ State keys used:
71
+ "task" — original task description (seeded by caller)
72
+ "test_command" — shell command to run tests
73
+ "test_output" — stdout/stderr from last test run
74
+ "test_passed" — "yes" if tests passed
75
+ "iteration" — current loop count (auto-incremented by writer)
76
+
77
+ Returns the LoopAgent instance, ready to run via a Runner.
78
+
79
+ Example usage:
80
+ loop = build_refine_loop(model="gemini-2.5-flash",
81
+ task_description="add unit tests for parser.py",
82
+ test_command="pytest tests/test_parser.py -x")
83
+ runner = Runner(agent=loop, app_name="refine", session_service=...)
84
+ async for event in runner.run_async(...):
85
+ ...
86
+ """
87
+ _require_workflow()
88
+ from google.adk.tools import exit_loop # type: ignore
89
+
90
+ from gemcode.tools import build_function_tools # type: ignore
91
+
92
+ # Shared tools that the sub-agents may use (no run_subtask to avoid recursion)
93
+ tools = extra_tools or []
94
+
95
+ writer = LlmAgent(
96
+ model=model,
97
+ name="writer_agent",
98
+ description="Writes or edits source code to fulfil the task, using test failure output as feedback.",
99
+ instruction=(
100
+ "You are a coding agent. Your goal: {task}.\n\n"
101
+ "Test command: {test_command}\n"
102
+ "Last test output:\n{test_output?}\n\n"
103
+ "Write or fix code to make the tests pass. "
104
+ "After making your edits, update state key 'iteration' by incrementing it."
105
+ ),
106
+ tools=tools,
107
+ output_key="writer_notes",
108
+ )
109
+
110
+ tester = LlmAgent(
111
+ model=model,
112
+ name="tester_agent",
113
+ description="Runs the test suite and stores the output in session state.",
114
+ instruction=(
115
+ "Run the test command stored in state: {test_command}.\n"
116
+ "Use the bash tool to execute it. Store ALL stdout+stderr verbatim in "
117
+ "state key 'test_output'. "
118
+ "If the command exits with code 0 (success), set state key 'test_passed' to 'yes'. "
119
+ "If it fails, set 'test_passed' to 'no'."
120
+ ),
121
+ tools=tools,
122
+ output_key="test_output",
123
+ )
124
+
125
+ checker = LlmAgent(
126
+ model=model,
127
+ name="checker_agent",
128
+ description=(
129
+ "Checks whether tests passed. If yes, calls exit_loop to terminate the "
130
+ "refinement cycle. If no, does nothing (loop continues)."
131
+ ),
132
+ instruction=(
133
+ "Check the value of state key 'test_passed'.\n"
134
+ "If 'test_passed' is 'yes': call the exit_loop tool to stop the loop — "
135
+ "the task is complete.\n"
136
+ "If 'test_passed' is 'no': output a brief summary of the failures so the "
137
+ "writer knows what to fix next. Do NOT call exit_loop."
138
+ ),
139
+ tools=[exit_loop, *tools],
140
+ )
141
+
142
+ return LoopAgent(
143
+ name="refine_loop",
144
+ description=(
145
+ f"Iterative write→test→fix loop for: {task_description[:120]}"
146
+ ),
147
+ sub_agents=[writer, tester, checker],
148
+ max_iterations=max_iterations,
149
+ )
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Sequential Pipeline
154
+ # ---------------------------------------------------------------------------
155
+
156
+ def build_sequential_pipeline(
157
+ agents: list[Any],
158
+ *,
159
+ name: str = "sequential_pipeline",
160
+ description: str = "Runs specialist agents sequentially, passing results via state.",
161
+ ) -> Any:
162
+ """
163
+ Wrap a list of LlmAgents in a SequentialAgent.
164
+
165
+ Each agent should set ``output_key`` so subsequent agents can read its output
166
+ via {key} template substitution in their instructions.
167
+
168
+ Example:
169
+ pipeline = build_sequential_pipeline([
170
+ planner_agent, # output_key="plan"
171
+ coder_agent, # reads {plan}, output_key="code"
172
+ reviewer_agent, # reads {code}, output_key="review"
173
+ ])
174
+ """
175
+ _require_workflow()
176
+ return SequentialAgent(name=name, description=description, sub_agents=agents)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Parallel Fan-Out
181
+ # ---------------------------------------------------------------------------
182
+
183
+ def build_parallel_research(
184
+ agents: list[Any],
185
+ *,
186
+ name: str = "parallel_research",
187
+ description: str = "Runs research agents in parallel, collecting results via state.",
188
+ ) -> Any:
189
+ """
190
+ Wrap a list of LlmAgents in a ParallelAgent.
191
+
192
+ Each agent MUST use a distinct ``output_key`` to avoid state key collisions.
193
+
194
+ Example:
195
+ research = build_parallel_research([
196
+ web_researcher, # output_key="web_results"
197
+ code_searcher, # output_key="code_results"
198
+ doc_reader, # output_key="doc_results"
199
+ ])
200
+ # Wrap in a SequentialAgent to collect after:
201
+ pipeline = build_sequential_pipeline([research, synthesis_agent])
202
+ """
203
+ _require_workflow()
204
+ return ParallelAgent(name=name, description=description, sub_agents=agents)
gemcode/repl_commands.py CHANGED
@@ -101,7 +101,8 @@ def format_memory_lines(cfg: GemCodeConfig) -> list[str]:
101
101
 
102
102
  def format_hooks_lines(cfg: GemCodeConfig) -> list[str]:
103
103
  env_hook = os.environ.get("GEMCODE_POST_TURN_HOOK")
104
- default_hook = cfg.project_root / ".gemcode" / "hooks" / "post_turn"
104
+ hooks_dir = cfg.project_root / ".gemcode" / "hooks"
105
+ default_hook = hooks_dir / "post_turn"
105
106
  active: str | None = env_hook
106
107
  if not active and _is_executable(default_hook):
107
108
  active = str(default_hook)
@@ -115,6 +116,31 @@ def format_hooks_lines(cfg: GemCodeConfig) -> list[str]:
115
116
  lines.append(f" active_hook: {active}")
116
117
  else:
117
118
  lines.append(" active_hook: (none — set env or chmod +x .gemcode/hooks/post_turn)")
119
+
120
+ # ── Shell lifecycle hooks (like OpenClaude PreToolUse / PostToolUse) ──────
121
+ lines.append("")
122
+ lines.append("Shell lifecycle hooks (.gemcode/hooks/):")
123
+ lines.append(" Scripts run at tool/session lifecycle points.")
124
+ lifecycle_hooks = [
125
+ ("pre_tool_use", "Run BEFORE each tool call. Non-zero exit → tool denied."),
126
+ ("post_tool_use", "Run AFTER each tool call. Informational; return ignored."),
127
+ ("session_start", "Run when a GemCode session starts."),
128
+ ("session_stop", "Run when a GemCode session ends."),
129
+ ]
130
+ for hook_name, description in lifecycle_hooks:
131
+ found = None
132
+ for ext in ("", ".sh", ".py", ".bash"):
133
+ p = hooks_dir / f"{hook_name}{ext}"
134
+ if p.is_file() and _is_executable(p):
135
+ found = p
136
+ break
137
+ status = f"✓ {found}" if found else "✗ not found (create and chmod +x to enable)"
138
+ lines.append(f" {hook_name:20s} {status}")
139
+ lines.append(f" {'':20s} {description}")
140
+ lines.append("")
141
+ lines.append(f" Hook directory: {hooks_dir}")
142
+ lines.append(" Hooks receive JSON on stdin with tool name, args, and result.")
143
+ lines.append(" Set GEMCODE_HOOK_TYPE env var is set for each hook.")
118
144
  return lines
119
145
 
120
146
 
@@ -216,4 +242,6 @@ def slash_help_lines() -> list[str]:
216
242
  " /permissions Show permission / HITL settings",
217
243
  " /hooks Show post-turn hook configuration",
218
244
  " /kairos How to launch the background parallel job scheduler",
245
+ " /code [on|off] Toggle sandboxed Python executor (ADK BuiltInCodeExecutor)",
246
+ " /plan [on|off] Toggle plan mode — agent plans before executing tools",
219
247
  ]
gemcode/repl_slash.py CHANGED
@@ -357,6 +357,75 @@ async def process_repl_slash(
357
357
  out()
358
358
  return ReplSlashResult(skip_model_turn=True)
359
359
 
360
+ # ── /plan ─────────────────────────────────────────────────────────────────
361
+ if name == "plan":
362
+ args_s = (sc.args or "").strip().lower()
363
+ if args_s in ("on", "enable", "1", "true"):
364
+ cfg.plan_mode = True
365
+ out("Plan mode: ON")
366
+ out("The agent will now write an explicit numbered plan BEFORE executing")
367
+ out("any tools. It will pause for your confirmation before proceeding.")
368
+ out()
369
+ out("Type /plan off to disable.")
370
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
371
+ elif args_s in ("off", "disable", "0", "false"):
372
+ cfg.plan_mode = False
373
+ out("Plan mode: OFF")
374
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
375
+ else:
376
+ status = "ON" if getattr(cfg, "plan_mode", False) else "OFF"
377
+ out(f"Plan mode: {status}")
378
+ out()
379
+ out("When ON, the agent writes a numbered plan and waits for your")
380
+ out("confirmation before executing any file or shell operations.")
381
+ out()
382
+ out("Best for: complex multi-file refactors, migrations, risky changes.")
383
+ out("Toggle: /plan on /plan off")
384
+ return ReplSlashResult(skip_model_turn=True)
385
+
386
+ # ── /code ─────────────────────────────────────────────────────────────────
387
+ if name == "code":
388
+ args_s = (sc.args or "").strip().lower()
389
+ if args_s in ("on", "enable", "1", "true"):
390
+ cfg.enable_code_executor = True
391
+ out("Code executor: ON (sandboxed Python via Gemini built-in executor)")
392
+ out("The agent can now write Python code blocks and execute them safely,")
393
+ out("without requiring bash/shell permission. Best for: math, data, quick tests.")
394
+ out()
395
+ out("Note: the sandbox has no internet/filesystem access — use bash for I/O.")
396
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
397
+ elif args_s in ("off", "disable", "0", "false"):
398
+ cfg.enable_code_executor = False
399
+ out("Code executor: OFF")
400
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
401
+ else:
402
+ # Status
403
+ status = "ON" if getattr(cfg, "enable_code_executor", False) else "OFF"
404
+ out(f"Code executor: {status}")
405
+ out()
406
+ out("ADK BuiltInCodeExecutor — safe sandboxed Python execution via Gemini API.")
407
+ out()
408
+ out("What it does:")
409
+ out(" When ON, the agent can write Python code blocks directly in its response")
410
+ out(" and the Gemini API executes them in a sandboxed environment. The output")
411
+ out(" (stdout, result) is sent back so the agent can use it for further reasoning.")
412
+ out()
413
+ out("Best for:")
414
+ out(" - Math and numerical calculations")
415
+ out(" - Data processing and transformations")
416
+ out(" - Quick tests of logic or algorithms")
417
+ out(" - Anything that doesn't need filesystem or network access")
418
+ out()
419
+ out("Not for:")
420
+ out(" - Shell commands (use bash)")
421
+ out(" - Reading/writing files (use read_file / write_file)")
422
+ out(" - Internet requests (use web_fetch)")
423
+ out()
424
+ out("Toggle: /code on /code off")
425
+ out()
426
+ out("Supported models: gemini-2.5-flash, gemini-2.5-pro, gemini-3.x and newer.")
427
+ return ReplSlashResult(skip_model_turn=True)
428
+
360
429
  # ── /computer ────────────────────────────────────────────────────────────
361
430
  if name in ("computer", "browser"):
362
431
  args_s = (sc.args or "").strip().lower()
@@ -560,19 +629,23 @@ async def process_repl_slash(
560
629
  # ── /caps ─────────────────────────────────────────────────────────────────
561
630
  if name in ("caps", "capabilities", "capability"):
562
631
  args_s = (sc.args or "").strip().lower()
563
- valid_caps = ("auto", "research", "embeddings", "computer", "all", "none", "reset")
632
+ valid_caps = ("auto", "research", "embeddings", "computer", "search", "all", "none", "reset")
564
633
  out("Active capabilities:")
634
+ out(f" web_search: {'on' if getattr(cfg, 'enable_web_search', False) else 'off'}")
565
635
  out(f" deep_research: {'on' if cfg.enable_deep_research else 'off'}")
566
636
  out(f" embeddings: {'on' if cfg.enable_embeddings else 'off'}")
567
637
  out(f" memory: {'on' if cfg.enable_memory else 'off'}")
568
638
  out(f" computer_use: {'on' if cfg.enable_computer_use else 'off'}")
569
639
  out(f" maps_grounding: {'on' if cfg.enable_maps_grounding else 'off'}")
640
+ out(f" code_executor: {'on' if getattr(cfg, 'enable_code_executor', False) else 'off'}")
641
+ out(f" plan_mode: {'on' if getattr(cfg, 'plan_mode', False) else 'off'}")
570
642
  out(f" capability_mode (auto-routing): {cfg.capability_mode}")
571
643
  out()
572
644
  if not args_s:
573
645
  out("Commands:")
574
646
  out(" /caps none — turn all off, capability_mode=auto")
575
- out(" /caps research enable_deep_research on")
647
+ out(" /caps search enable_web_search on (standalone google_search)")
648
+ out(" /caps research — enable_deep_research on (search + url_context)")
576
649
  out(" /caps embeddings — enable_embeddings on")
577
650
  out(" /caps all — all modalities on")
578
651
  out(" /caps reset — reset to startup defaults (all off, auto mode)")
@@ -583,10 +656,18 @@ async def process_repl_slash(
583
656
  cfg.enable_embeddings = False
584
657
  cfg.enable_computer_use = False
585
658
  cfg.enable_maps_grounding = False
659
+ if hasattr(cfg, "enable_web_search"):
660
+ cfg.enable_web_search = False
586
661
  cfg.capability_mode = "auto"
587
662
  out("capabilities: reset to defaults (all off, auto mode)")
588
663
  out()
589
664
  return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
665
+ if args_s == "search":
666
+ if hasattr(cfg, "enable_web_search"):
667
+ cfg.enable_web_search = True
668
+ out("enable_web_search: on (google_search available without full deep_research)")
669
+ out()
670
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
590
671
  if args_s == "research":
591
672
  cfg.enable_deep_research = True
592
673
  out("enable_deep_research: on (runner rebuilding…)")
@@ -607,6 +688,8 @@ async def process_repl_slash(
607
688
  cfg.enable_deep_research = True
608
689
  cfg.enable_embeddings = True
609
690
  cfg.enable_computer_use = True
691
+ if hasattr(cfg, "enable_web_search"):
692
+ cfg.enable_web_search = True
610
693
  out("capabilities: all on (runner rebuilding…)")
611
694
  out()
612
695
  return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
@@ -30,6 +30,23 @@ def session_db_path(cfg: GemCodeConfig) -> Path:
30
30
  return cfg.project_root / ".gemcode" / "sessions.sqlite"
31
31
 
32
32
 
33
+ def _build_artifact_service(cfg: GemCodeConfig):
34
+ """
35
+ Return an ADK ArtifactService for this session, or None if disabled.
36
+
37
+ Uses InMemoryArtifactService so artifacts are available within the session
38
+ without requiring GCS credentials. The agent can save screenshots, generated
39
+ files, large reports, etc. as artifacts to avoid bloating session history.
40
+ """
41
+ if not getattr(cfg, "enable_artifacts", True):
42
+ return None
43
+ try:
44
+ from google.adk.artifacts import InMemoryArtifactService
45
+ return InMemoryArtifactService()
46
+ except Exception:
47
+ return None
48
+
49
+
33
50
  def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None) -> Runner:
34
51
  """Construct Runner + SQLite session service + root LlmAgent."""
35
52
  modality_tools = build_modality_extra_tools(cfg)
@@ -86,7 +103,9 @@ def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None) -> Runner
86
103
  else:
87
104
  memory_service = FileMemoryService(mem_path)
88
105
 
89
- return Runner(
106
+ artifact_service = _build_artifact_service(cfg)
107
+
108
+ runner_kwargs: dict = dict(
90
109
  app_name="gemcode",
91
110
  agent=agent,
92
111
  session_service=session_service,
@@ -94,3 +113,7 @@ def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None) -> Runner
94
113
  memory_service=memory_service,
95
114
  auto_create_session=True,
96
115
  )
116
+ if artifact_service is not None:
117
+ runner_kwargs["artifact_service"] = artifact_service
118
+
119
+ return Runner(**runner_kwargs)
@@ -126,6 +126,9 @@ def build_tool_manifest(cfg: GemCodeConfig) -> str | None:
126
126
  - Returns the sub-agent's final text as `result`.
127
127
  - Use for: context-heavy exploration (reading 50+ files), parallel investigation of independent subsystems, verification passes after you finish work.
128
128
  - Always give the sub-agent enough context to work independently; end the task with "Summarise your findings clearly."
129
+ - **`get_user_choice(question, choices)`** — present the user with a structured list of options (2–6 items). Returns the user's selected option. Use instead of open-ended questions when the set of valid responses is known and bounded (e.g. framework choice, migration strategy, naming convention).
130
+ - **`load_artifacts(filenames)`** — load one or more named artifacts (binary/large files) saved in this or a previous session. Returns the content of each. Use `user:filename` prefix for cross-session artifacts.
131
+ - **`exit_loop()`** — signal the surrounding LoopAgent to stop iterating and emit the final result. Only relevant when running inside a LoopAgent pipeline (e.g. the `/refine` write→test→fix loop).
129
132
 
130
133
  ### Read-only tools (no permission needed): {_fmt_list(read_only)}
131
134
  Use proactively. Never use bash/run_command just to list or read files — these are instant and require zero approval.
@@ -56,6 +56,8 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
56
56
  ("budget", "Set per-turn token budget · /budget <N> · /budget off"),
57
57
  ("limits", "Show/set execution limits (max_llm_calls, context, etc.)"),
58
58
  ("kairos", "Background parallel job scheduler — how to run gemcode kairos"),
59
+ ("code", "Toggle sandboxed Python code executor (ADK BuiltInCodeExecutor)"),
60
+ ("plan", "Toggle plan mode — agent writes explicit plan before executing tools"),
59
61
  ("tools", "List all tools and their permission categories"),
60
62
  ("config", "Show full active configuration (all fields)"),
61
63
  ("permissions", "Show current permission mode (default / strict / yes)"),
@@ -113,10 +115,12 @@ class GemCodeInputHandler:
113
115
  ansi_enabled: bool = True,
114
116
  get_model: Callable[[], str] | None = None,
115
117
  get_session_id: Callable[[], str] | None = None,
118
+ get_cfg: Callable[[], object | None] | None = None,
116
119
  ) -> None:
117
120
  self._ansi = ansi_enabled
118
121
  self._get_model = get_model or (lambda: "gemini")
119
122
  self._get_session_id = get_session_id or (lambda: "")
123
+ self._get_cfg = get_cfg or (lambda: None)
120
124
  self._session: "PromptSession | None" = None
121
125
 
122
126
  if _PT_AVAILABLE and sys.stdin.isatty() and sys.stdout.isatty():
@@ -141,15 +145,30 @@ class GemCodeInputHandler:
141
145
 
142
146
  get_model = self._get_model
143
147
  get_session_id = self._get_session_id
148
+ get_cfg = self._get_cfg
144
149
 
145
150
  def bottom_toolbar() -> HTML:
146
151
  model = get_model() or "gemini"
147
152
  sid = get_session_id()
148
153
  sid_short = sid[:8] if len(sid) >= 8 else sid
154
+ cfg = get_cfg()
155
+ extras = ""
156
+ if cfg is not None:
157
+ flags = []
158
+ if getattr(cfg, "plan_mode", False):
159
+ flags.append("PLAN")
160
+ if getattr(cfg, "enable_code_executor", False):
161
+ flags.append("CODE")
162
+ if getattr(cfg, "enable_deep_research", False):
163
+ flags.append("RESEARCH")
164
+ if getattr(cfg, "enable_computer_use", False):
165
+ flags.append("BROWSER")
166
+ if flags:
167
+ extras = " · " + " ".join(f"[{f}]" for f in flags)
149
168
  return HTML(
150
169
  f'<style bg="#0d1f2d" fg="#5fafd7"><b> ◆ {model}</b></style>'
151
170
  f'<style bg="#0d1f2d" fg="#3d6080"> · session {sid_short}'
152
- f' · / for commands</style>'
171
+ f'{extras} · / for commands</style>'
153
172
  )
154
173
 
155
174
  kb = KeyBindings()
gemcode/tui/scrollback.py CHANGED
@@ -204,6 +204,13 @@ async def run_gemcode_scrollback_tui(
204
204
  load_cli_environment()
205
205
  os.environ["GEMCODE_TUI_ACTIVE"] = "1"
206
206
 
207
+ # ── Session-start hook ──────────────────────────────────────────────────
208
+ try:
209
+ from gemcode.hooks import run_session_start_hook
210
+ run_session_start_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
211
+ except Exception:
212
+ pass
213
+
207
214
  ansi = _Ansi(
208
215
  enabled=(
209
216
  sys.stdout.isatty()
@@ -234,6 +241,7 @@ async def run_gemcode_scrollback_tui(
234
241
  ansi_enabled=ansi.enabled,
235
242
  get_model=lambda: getattr(cfg, "model", "gemini") or "gemini",
236
243
  get_session_id=lambda: _current_session_id_holder[0],
244
+ get_cfg=lambda: cfg,
237
245
  )
238
246
 
239
247
  async def typewrite(text: str) -> None:
@@ -437,10 +445,20 @@ async def run_gemcode_scrollback_tui(
437
445
  prompt = await input_handler.prompt_async()
438
446
  except EOFError:
439
447
  print("")
448
+ try:
449
+ from gemcode.hooks import run_session_stop_hook
450
+ run_session_stop_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
451
+ except Exception:
452
+ pass
440
453
  return
441
454
  if not prompt:
442
455
  continue
443
456
  if prompt in (":q", "quit", "exit", "/exit"):
457
+ try:
458
+ from gemcode.hooks import run_session_stop_hook
459
+ run_session_stop_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
460
+ except Exception:
461
+ pass
444
462
  return
445
463
 
446
464
  old_model = getattr(cfg, "model", "")
@@ -707,7 +725,32 @@ async def run_gemcode_scrollback_tui(
707
725
  else current_session_id
708
726
  )
709
727
  model = getattr(cfg, "model", "") or ""
710
- print(f"{ansi.dim} · {model} · session {sid}{ansi.reset}")
728
+ # ── Token / cost stats from last turn ──────────────────────────────────
729
+ stats = getattr(cfg, "_last_turn_stats", None)
730
+ token_part = ""
731
+ if stats:
732
+ try:
733
+ from gemcode.pricing import format_cost, format_tokens
734
+ in_tok = stats.get("in", 0) or 0
735
+ out_tok = stats.get("out", 0) or 0
736
+ think_tok = stats.get("think", 0) or 0
737
+ session_total = stats.get("session_total", 0) or 0
738
+ turn_cost = stats.get("turn_cost")
739
+ session_cost = stats.get("session_cost")
740
+ parts_t: list[str] = []
741
+ parts_t.append(f"↑{format_tokens(in_tok)} ↓{format_tokens(out_tok)}")
742
+ if think_tok:
743
+ parts_t.append(f"✦{format_tokens(think_tok)}")
744
+ if turn_cost is not None:
745
+ parts_t.append(format_cost(turn_cost))
746
+ if session_total:
747
+ parts_t.append(f"session {format_tokens(session_total)}")
748
+ if session_cost and session_cost > 0.0001:
749
+ parts_t.append(f"total {format_cost(session_cost)}")
750
+ token_part = " · " + " · ".join(parts_t)
751
+ except Exception:
752
+ pass
753
+ print(f"{ansi.dim} · {model} · session {sid}{token_part}{ansi.reset}")
711
754
  if os.environ.get("GEMCODE_TUI_TURN_RULE", "1").lower() in (
712
755
  "1",
713
756
  "true",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.28
3
+ Version: 0.3.30
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -1,17 +1,18 @@
1
1
  gemcode/__init__.py,sha256=l0DCRYqK7KM7Fb7u49fqh-5_SlpeIL7r3LjMeJWMgSg,112
2
2
  gemcode/__main__.py,sha256=EX2s1hxq2Yvli_-tnBN3w5Qv4bOjsBBbjyISF0pDIQw,37
3
- gemcode/agent.py,sha256=etP0evf7_QSzxY9RqTvc3peVJXh6tcwBmcGqqfb2VDQ,29990
3
+ gemcode/agent.py,sha256=DED-H1lYEYb3NpIiwVbcefmzfkl06XM1edM1qhXyUw8,35713
4
4
  gemcode/audit.py,sha256=bh9uhXaeh8wqxqoZtz3ZAowd8Ndk1ss-mw9993Vlrgo,469
5
5
  gemcode/autocompact.py,sha256=77h5tgFzJ2rjrhlCL2oIc28IHwLbP4Pqlo7cSNgDwiA,6727
6
- gemcode/callbacks.py,sha256=dxZhHsJDE-16nJ0OGe4udPkWRTOCVfSi2gBSR7MUWOM,20465
6
+ gemcode/callbacks.py,sha256=g_1HgHzD7NRB1DN7eVc4MN5sT9g7ZB2dnu3nreXAz7I,23182
7
7
  gemcode/capability_routing.py,sha256=D8tvawmf_MSL94GVXgG9QhDvNaQVGqzA8tUFQ8XlftY,2894
8
8
  gemcode/cli.py,sha256=0b9hO86EILJ5xRg8fb1Sfmwh6vYi9Bp6tkOvsxOSIEU,25447
9
9
  gemcode/compaction.py,sha256=9YtA_qa23_8dHWVHx7AJwUduuI7jJQtq-m6sT8jgPWI,1186
10
- gemcode/config.py,sha256=xvvhJbMEBPjIotwnxoggTdXpfYaDPjVi5PCIy489610,12540
10
+ gemcode/config.py,sha256=GKbze1nDf1U5O-i6AmKd9FYr92TklJ2Vhi3qdKrhqZw,14081
11
11
  gemcode/context_budget.py,sha256=Nhox9vFBtLbb7jtO7cyGW1MxtN7SVjlIeQ7d-cgGyKM,10544
12
12
  gemcode/context_warning.py,sha256=Q8mg5Vojj7EglPhsGAVL7vb8ROLuHVPgdzw25yw-Q2c,4263
13
13
  gemcode/credentials.py,sha256=04v-rLD8_Ams69FQdof2FwcL3ZgsroGUnMcHNQFuBZo,1296
14
14
  gemcode/hitl_session.py,sha256=oNiI7odFJGUcmqPavjKLJOEumZKrgklLvwjjrIG9GPg,281
15
+ gemcode/hooks.py,sha256=FHz175d_18j-4ByZZVdEIagmdOvLHcjDjo7HD2Cikf4,6339
15
16
  gemcode/interactions.py,sha256=B0b3QNE_I2i5_HtiebX4ehhjlc4Nbqjf_XbvcTLyJT0,641
16
17
  gemcode/invoke.py,sha256=TakGUR8RweW0nKFCQeYFKwge24rVYHhwkO1erMuoMf8,7514
17
18
  gemcode/kairos_daemon.py,sha256=giINipslAIhBtdbqA0o4RYwt4fBsZjtkiKDjp4zSEvw,6980
@@ -19,18 +20,20 @@ gemcode/limits.py,sha256=1u7w3X13Uqw34ZOVYKa62pxfvlyLTY2Dne1kN8SKJlA,2524
19
20
  gemcode/live_audio_engine.py,sha256=Re1zS-9lcaK2qfU7ydPVQlI6tXxv384O6Bs1dhZ8Puo,3423
20
21
  gemcode/logging_config.py,sha256=nw_FyJbXM3StzaAx6b6BLLNvzuxsGg-sdQ3QRw7BWgs,1080
21
22
  gemcode/mcp_loader.py,sha256=0QRgYvI71YNAyehO0N9SgK-9jhiocXHVo0J5HQ8MZHo,1344
22
- gemcode/modality_tools.py,sha256=196Plk1lUgegPu-VeVoONkt3Zlxk5kXQnM8vlRFapn0,6261
23
+ gemcode/modality_tools.py,sha256=Gx-hDXr5Y364jl2oiUvBNWOZj7Z6bUXxmwVHUpc1L44,6875
23
24
  gemcode/model_errors.py,sha256=6I2Gl2HbxINMmJOjSdM5rH6rCUDLA4ellGZsQrzy47Q,2948
24
25
  gemcode/model_routing.py,sha256=Q42HZtXQa6rao2O2vYMHxohrTgD-wq4t7qGxU4_38Jg,4881
25
26
  gemcode/paths.py,sha256=U6cEH9jfIcSc4NO8Ke0jniZSiJTfCIJPvSMue3hR0ZU,768
26
27
  gemcode/permissions.py,sha256=9FmwTP_LLflahxS9KXvY3VPSra4e-h3OariOqnWIKDI,177
28
+ gemcode/pricing.py,sha256=pedyJg4i18NeZ9gvw3JObhmuEBaHJMKwQ5a17Q6Y-6s,3176
27
29
  gemcode/prompt_suggestions.py,sha256=h-W_9LlfagS91PyoMEjEjsCqoG4XmIh3QBypA59HyGw,2553
28
- gemcode/repl_commands.py,sha256=yYd1aXaY9wZzoiWNMlVhtFPK1KO2_tX-FUTTXrfHuhE,8270
29
- gemcode/repl_slash.py,sha256=epkMgUcjfY6u4XOpr_oGoN3UDIFTyhsGSmHvl46s590,31498
30
- gemcode/session_runtime.py,sha256=iasFwXJmrqbutNnZmSCbWmDerWiBf32_eloi3SKKu6c,4081
30
+ gemcode/refine.py,sha256=BijEZ4Z32wGa9aK_WottyAhZF-j0xEqRg5UpjedNv2A,7653
31
+ gemcode/repl_commands.py,sha256=VMqJVf9L-H3Qschia_VkqS-Sq7s4HNNnKIg7nfRSzC4,9676
32
+ gemcode/repl_slash.py,sha256=2XT01vCx9rUaSI0_zJy-djZodlLBodEzqUh091pqXuc,35853
33
+ gemcode/session_runtime.py,sha256=gXuBU-3kJlZ-_aBTCaCVnEVxz6RD-J7CRTMC1Z7JVSg,4845
31
34
  gemcode/slash_commands.py,sha256=Qylzsj1notk0xN_hvd3CR4HD8g-l99UENDMcg1pKeBA,794
32
35
  gemcode/thinking.py,sha256=RanBf_x9fKv1o4DNyNXPLfOdn2xT0KybJb65nYgmMEE,4885
33
- gemcode/tool_prompt_manifest.py,sha256=6Fy5vrzgMX-0SIOeenXe8I41RmKPwIoz0qMp9KcLDRM,7976
36
+ gemcode/tool_prompt_manifest.py,sha256=MS_eSJg2BTp6yv1Ih2p93okPmnK3B2dYAMjnG6yaEVY,8695
34
37
  gemcode/tool_registry.py,sha256=ifqxtr2uLwEUwnJLKYLza_tIz-paaZediJa75y9MiyA,1795
35
38
  gemcode/tools_inspector.py,sha256=okmu4PDYAQQ7nthDvuzSHmy2zArFTG4ftIPRadzLnxA,4100
36
39
  gemcode/trust.py,sha256=fxe57Xg6aL_KU24bQDUtD-rXjsNpaq7g-eQTInZnudE,1336
@@ -64,17 +67,17 @@ gemcode/tools/subtask.py,sha256=ur_6jnwnNzHSBtW96mB2N2A6zmALpydSV24Nkx0T6UQ,5683
64
67
  gemcode/tools/think.py,sha256=WrNATR-bi97aLkbSsOFOYYAGxbzihe9AnPDZfw3z5-Q,1704
65
68
  gemcode/tools/todo.py,sha256=d9aXiyT04r1RFZIk6qdVif17-_Oc3oi4ymDnsPBRg68,3143
66
69
  gemcode/tools/web.py,sha256=ULg1e3inG4FjPSUCYI8dVBzTrcCHINNRo76SIU9qw-A,4489
67
- gemcode/tui/input_handler.py,sha256=nQEltM2U0Ks2GW_8KYyoww-S7x7Xjspig59r6yi_Pyo,8235
68
- gemcode/tui/scrollback.py,sha256=zc91bA1jPQdluwy5eUFX3-lElYhaaSBIyOKQ0bj-zbA,24521
70
+ gemcode/tui/input_handler.py,sha256=M3TNo7EjcKVHvzIw_WbBPr6VNYVxbsGwwX_5vXWtXOs,9195
71
+ gemcode/tui/scrollback.py,sha256=LrYjmu9g9oatFB22ZzN5CcVEzn2NQfTF3sV3_AAHpiw,26517
69
72
  gemcode/tui/spinner.py,sha256=AJrApG5od-Sh40-5uWcNM9RHb5ax7gr-NbgAZmTbIYY,4848
70
73
  gemcode/tui/welcome_banner.py,sha256=aocl1lnoyLIM6RN4f65g3i0wRA71RqUlgPrGsXeVLW4,4387
71
74
  gemcode/tui/welcome_rich.py,sha256=8FEZzLXrzqly5JWiDgV9ooRV1LNXDk-CXV1a7K6ua-U,4048
72
75
  gemcode/web/__init__.py,sha256=EysmUAWs6g-lmMk4VFljKfaHVrEgb_FiIzwQmBdORJc,40
73
76
  gemcode/web/claude_sse_adapter.py,sha256=HcNp0Lh4DdBZBLOpstsqa-VzfqAUrRngZ6FSuJ-mIMg,8609
74
77
  gemcode/web/terminal_repl.py,sha256=k2irvFGbCY8gDm_pbirR7b_cakaeafcctoTIvnJkVXk,3902
75
- gemcode-0.3.28.dist-info/licenses/LICENSE,sha256=TD4524qn-W8Z07GTDnag-9jJPFutFZNB0a1WbMHPC54,8388
76
- gemcode-0.3.28.dist-info/METADATA,sha256=iwoh3k_b5G6pb68A58SBvkcfcobd9xok_br31HvZlT0,23695
77
- gemcode-0.3.28.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
78
- gemcode-0.3.28.dist-info/entry_points.txt,sha256=cZdLTLDiHbks7OSUCuxCh66dCWeQdpLR8BozoqfEjV4,45
79
- gemcode-0.3.28.dist-info/top_level.txt,sha256=UYrjULLBY2bcgK6KI6flomJWmsbDXu7n0rvW2SWFrbo,8
80
- gemcode-0.3.28.dist-info/RECORD,,
78
+ gemcode-0.3.30.dist-info/licenses/LICENSE,sha256=TD4524qn-W8Z07GTDnag-9jJPFutFZNB0a1WbMHPC54,8388
79
+ gemcode-0.3.30.dist-info/METADATA,sha256=rzb9o3zpt1S4CNHZF3YjcbG-1EazIWiPL7Xi5A5T-wI,23695
80
+ gemcode-0.3.30.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
81
+ gemcode-0.3.30.dist-info/entry_points.txt,sha256=cZdLTLDiHbks7OSUCuxCh66dCWeQdpLR8BozoqfEjV4,45
82
+ gemcode-0.3.30.dist-info/top_level.txt,sha256=UYrjULLBY2bcgK6KI6flomJWmsbDXu7n0rvW2SWFrbo,8
83
+ gemcode-0.3.30.dist-info/RECORD,,