gemcode 0.3.77__py3-none-any.whl → 0.3.80__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
@@ -27,11 +27,49 @@ from gemcode.limits import make_before_model_limits_callback, make_before_model_
27
27
  from gemcode.thinking import build_thinking_config
28
28
  from gemcode.tools import build_function_tools
29
29
  from gemcode.tool_prompt_manifest import build_tool_manifest
30
- from gemcode.skills import build_skill_manifest_text
30
+ from gemcode.skills import (
31
+ build_skill_manifest_text,
32
+ expand_skill_text,
33
+ list_supporting_files,
34
+ load_skill,
35
+ )
31
36
  from gemcode.output_styles import build_output_style_section
32
37
  from gemcode.rules import build_rules_section
33
38
 
34
39
 
40
+ def _build_session_loaded_skills_section(cfg: GemCodeConfig) -> str:
41
+ """Full bodies for GemSkills the user loaded with /gemskill (session-scoped)."""
42
+ names = list(getattr(cfg, "session_loaded_skill_names", None) or [])
43
+ if not names:
44
+ return ""
45
+ sid = getattr(cfg, "session_skill_expand_session_id", None) or ""
46
+ chunks: list[str] = []
47
+ seen: set[str] = set()
48
+ for raw in names:
49
+ sk_name = (raw or "").strip().lower()
50
+ if not sk_name or sk_name in seen:
51
+ continue
52
+ seen.add(sk_name)
53
+ s = load_skill(cfg.project_root, sk_name)
54
+ if s is None:
55
+ continue
56
+ expanded = expand_skill_text(s, arguments="", session_id=sid)
57
+ files = list_supporting_files(s)
58
+ head = f"### GemSkill: `/{s.meta.name}` (loaded for this session)\n\n"
59
+ chunk = head + expanded
60
+ if files:
61
+ chunk += f"\n\nSupporting files: {', '.join(files)}"
62
+ chunks.append(chunk)
63
+ if not chunks:
64
+ return ""
65
+ return (
66
+ "## Loaded GemSkills (this session)\n"
67
+ "The user explicitly loaded these skills with `/gemskill`. Follow their workflows "
68
+ "when the task matches their purpose; do not force them on unrelated requests.\n\n"
69
+ + "\n\n---\n\n".join(chunks)
70
+ )
71
+
72
+
35
73
  def build_global_instruction() -> str:
36
74
  """Global instruction applied to the entire agent tree (via ADK plugin)."""
37
75
  return (
@@ -888,6 +926,9 @@ You have two tools to persist project insights across sessions (auto-memory styl
888
926
  skill_manifest = build_skill_manifest_text(cfg.project_root)
889
927
  if skill_manifest:
890
928
  base = f"{base}\n\n{skill_manifest}"
929
+ loaded_skills = _build_session_loaded_skills_section(cfg)
930
+ if loaded_skills:
931
+ base = f"{base}\n\n{loaded_skills}"
891
932
  extra = _load_gemini_md(cfg.project_root)
892
933
  if extra.strip():
893
934
  return f"{base}\n\n## Project instructions (GEMINI.md)\n{extra}"
gemcode/autotune.py CHANGED
@@ -3,8 +3,9 @@ from __future__ import annotations
3
3
  import subprocess
4
4
  import time
5
5
  from pathlib import Path
6
- from typing import Any
6
+ from typing import Any, Iterable
7
7
 
8
+ from gemcode.config import GemCodeConfig
8
9
  from gemcode.evals.harness import run_eval_suite, write_eval_record
9
10
 
10
11
 
@@ -47,11 +48,24 @@ def init_autotune(*, project_root: Path, tag: str) -> dict[str, Any]:
47
48
  return {"status": "created", "branch": branch}
48
49
 
49
50
 
50
- def run_autotune_eval(*, project_root: Path, include_llm: bool, model: str | None = None) -> dict[str, Any]:
51
+ def run_autotune_eval(
52
+ *,
53
+ project_root: Path,
54
+ include_llm: bool,
55
+ model: str | None = None,
56
+ session_cfg: GemCodeConfig | None = None,
57
+ extra_tools: Iterable[Any] | None = None,
58
+ ) -> dict[str, Any]:
51
59
  """
52
60
  Run eval suite and persist last result to .gemcode/evals/last_eval.json.
53
61
  """
54
- res = run_eval_suite(project_root=project_root, include_llm=include_llm, model=model)
62
+ res = run_eval_suite(
63
+ project_root=project_root,
64
+ include_llm=include_llm,
65
+ model=model,
66
+ session_cfg=session_cfg,
67
+ extra_tools=extra_tools,
68
+ )
55
69
  meta = {
56
70
  "ts": time.time(),
57
71
  "git_sha": _git_head_sha(project_root),
gemcode/cli.py CHANGED
@@ -147,7 +147,12 @@ def _initialize_gemcode_project(cfg: GemCodeConfig) -> None:
147
147
 
148
148
 
149
149
  async def _run_prompt(
150
- cfg: GemCodeConfig, prompt: str, session_id: str, *, use_mcp: bool
150
+ cfg: GemCodeConfig,
151
+ prompt: str,
152
+ session_id: str,
153
+ *,
154
+ use_mcp: bool,
155
+ attachment_paths: list[Path] | None = None,
151
156
  ) -> str:
152
157
  load_cli_environment()
153
158
  _maybe_prompt_trust(cfg)
@@ -164,6 +169,7 @@ async def _run_prompt(
164
169
  prompt=prompt,
165
170
  max_llm_calls=cfg.max_llm_calls,
166
171
  cfg=cfg,
172
+ attachment_paths=attachment_paths,
167
173
  )
168
174
  return _events_to_text(collected)
169
175
  finally:
@@ -245,6 +251,13 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
245
251
  file=sys.stderr,
246
252
  )
247
253
 
254
+ try:
255
+ from gemcode.repl_commands import install_readline_slash_completion
256
+
257
+ install_readline_slash_completion()
258
+ except Exception:
259
+ pass
260
+
248
261
  print(
249
262
  "GemCode CLI is running. Type your prompt and press Enter. (Ctrl+D to exit)",
250
263
  file=sys.stderr,
@@ -261,6 +274,7 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
261
274
  if prompt_text in (":q", "quit", "exit", "/exit"):
262
275
  break
263
276
 
277
+ cfg.session_skill_expand_session_id = session_id
264
278
  slash = await process_repl_slash(
265
279
  cfg=cfg,
266
280
  runner=runner,
@@ -273,12 +287,23 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
273
287
  break
274
288
  if slash.new_session_id is not None:
275
289
  session_id = slash.new_session_id
290
+ cfg.session_skill_expand_session_id = session_id
276
291
  if slash.skip_model_turn:
292
+ if slash.force_rebuild_runner:
293
+ try:
294
+ _c = runner.close()
295
+ if asyncio.iscoroutine(_c):
296
+ await _c
297
+ except Exception:
298
+ pass
299
+ runner = create_runner(cfg, extra_tools=None)
277
300
  continue
278
301
  prompt_text = slash.model_prompt or prompt_text
279
302
 
280
303
  apply_capability_routing(cfg, prompt_text, context="prompt")
281
304
  cfg.model = pick_effective_model(cfg, prompt_text)
305
+ _repl_attach = list(cfg.pending_attachment_paths)
306
+ cfg.pending_attachment_paths.clear()
282
307
  collected = await run_turn(
283
308
  runner,
284
309
  user_id="local",
@@ -286,6 +311,7 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
286
311
  prompt=prompt_text,
287
312
  max_llm_calls=cfg.max_llm_calls,
288
313
  cfg=cfg,
314
+ attachment_paths=_repl_attach if _repl_attach else None,
289
315
  )
290
316
  out = _events_to_text(collected)
291
317
  if out:
@@ -778,6 +804,17 @@ def main() -> None:
778
804
  metavar="N",
779
805
  help="Cap model↔tool iterations for this message (maps to ADK RunConfig.max_llm_calls)",
780
806
  )
807
+ parser.add_argument(
808
+ "--attach",
809
+ "--image",
810
+ dest="attachments",
811
+ action="append",
812
+ default=[],
813
+ metavar="PATH",
814
+ help="Attach file(s) for this message (repeatable): images, PDF, audio, video, text, etc. "
815
+ "(Gemini-supported MIME). Default max ~20 MiB each (GEMCODE_MAX_ATTACHMENT_BYTES). "
816
+ "REPL: /attach or /image <path>.",
817
+ )
781
818
  args = parser.parse_args()
782
819
 
783
820
  load_cli_environment()
@@ -829,7 +866,16 @@ def main() -> None:
829
866
  prompt_text = prompt.strip()
830
867
  apply_capability_routing(cfg, prompt_text, context="prompt")
831
868
  cfg.model = pick_effective_model(cfg, prompt_text)
832
- out = asyncio.run(_run_prompt(cfg, prompt_text, session_id, use_mcp=args.mcp))
869
+ _cli_attach = list(args.attachments) if getattr(args, "attachments", None) else []
870
+ out = asyncio.run(
871
+ _run_prompt(
872
+ cfg,
873
+ prompt_text,
874
+ session_id,
875
+ use_mcp=args.mcp,
876
+ attachment_paths=_cli_attach if _cli_attach else None,
877
+ )
878
+ )
833
879
  if out:
834
880
  print(out)
835
881
  print(f"\n[gemcode] session_id={session_id}", file=sys.stderr)
gemcode/config.py CHANGED
@@ -212,6 +212,15 @@ class GemCodeConfig:
212
212
  default_factory=lambda: os.environ.get("GEMCODE_OUTPUT_STYLE") or None
213
213
  )
214
214
 
215
+ # GemSkills explicitly loaded via /gemskill — full bodies injected into the
216
+ # system instruction until cleared or the session is reset/resumed.
217
+ session_loaded_skill_names: list[str] = field(default_factory=list)
218
+ # Substitutes ${GEMCODE_SESSION_ID} when expanding loaded skills for prompts.
219
+ session_skill_expand_session_id: str | None = None
220
+
221
+ # REPL/TUI: paths queued with /attach (or /image …), sent on the next message then cleared.
222
+ pending_attachment_paths: list[Path] = field(default_factory=list)
223
+
215
224
  # Modality toggles (tool injection + routing).
216
225
  enable_deep_research: bool = field(
217
226
  default_factory=lambda: _truthy_env("GEMCODE_ENABLE_DEEP_RESEARCH", default=False)
gemcode/evals/harness.py CHANGED
@@ -3,14 +3,17 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  import os
6
+ import subprocess
7
+ import sys
6
8
  import time
7
- from dataclasses import dataclass
9
+ from dataclasses import dataclass, replace
8
10
  from pathlib import Path
9
- from typing import Any, Callable
11
+ from typing import Any, Iterable
10
12
 
11
13
  from gemcode.config import GemCodeConfig, load_cli_environment
12
14
  from gemcode.invoke import run_turn
13
15
  from gemcode.session_runtime import create_runner
16
+ from gemcode.tools_inspector import inspect_tools, smoke_tools
14
17
 
15
18
 
16
19
  @dataclass
@@ -21,11 +24,25 @@ class EvalResult:
21
24
  details: str = ""
22
25
 
23
26
 
24
- def _run_cmd(cmd: str, *, cwd: Path) -> tuple[int, str]:
25
- import subprocess
26
- p = subprocess.run(cmd, cwd=str(cwd), shell=True, capture_output=True, text=True)
27
- out = (p.stdout or "") + (p.stderr or "")
28
- return int(p.returncode), out
27
+ def _discover_pytest_cwd(project_root: Path) -> tuple[Path, dict[str, str] | None] | None:
28
+ """
29
+ Return (cwd, env) for running pytest, or None if no tests tree found.
30
+
31
+ ``env`` is ``None`` to inherit the process environment; otherwise a full env dict.
32
+
33
+ Supports:
34
+ - Monorepo layout: <root>/gemcode/tests → cwd gemcode, PYTHONPATH=src
35
+ - Single-package layout: <root>/tests → cwd root
36
+ """
37
+ if (project_root / "tests").is_dir():
38
+ return project_root, None
39
+ gc = project_root / "gemcode"
40
+ if (gc / "tests").is_dir():
41
+ env = os.environ.copy()
42
+ prev = env.get("PYTHONPATH", "")
43
+ env["PYTHONPATH"] = "src" + (os.pathsep + prev if prev else "")
44
+ return gc, env
45
+ return None
29
46
 
30
47
 
31
48
  def _events_to_text(events: list) -> str:
@@ -72,28 +89,65 @@ def run_eval_suite(
72
89
  project_root: Path,
73
90
  include_llm: bool,
74
91
  model: str | None = None,
92
+ session_cfg: GemCodeConfig | None = None,
93
+ extra_tools: Iterable[Any] | None = None,
75
94
  ) -> dict[str, Any]:
76
95
  """
77
96
  Fixed evaluation harness (AutoResearch-style): deterministic gates + optional LLM golden prompts.
97
+
98
+ When ``session_cfg`` is set (e.g. from the REPL), tool smoke uses that config so flags match the live session.
78
99
  """
79
100
  t0 = time.time()
80
101
  load_cli_environment()
81
- cfg = GemCodeConfig(project_root=project_root)
82
- if model:
83
- cfg.model = model
84
- cfg.model_overridden = True
102
+ root = session_cfg.project_root if session_cfg is not None else project_root.resolve()
103
+ if session_cfg is not None:
104
+ cfg = replace(session_cfg, model=model, model_overridden=True) if model else session_cfg
105
+ else:
106
+ cfg = GemCodeConfig(project_root=project_root.resolve())
107
+ if model:
108
+ cfg.model = model
109
+ cfg.model_overridden = True
85
110
 
86
111
  results: list[EvalResult] = []
87
112
 
88
- # Gate 1: tool schema smoke
89
- rc, out = _run_cmd("PYTHONPATH=src python3 -m gemcode tools smoke", cwd=project_root / "gemcode")
90
- results.append(EvalResult(name="tools_smoke", ok=(rc == 0), score=1.0 if rc == 0 else 0.0, details=out[-800:]))
91
-
92
- # Gate 2: pytest if present
93
- tests_dir = project_root / "gemcode" / "tests"
94
- if tests_dir.is_dir():
95
- rc2, out2 = _run_cmd("PYTHONPATH=src python3 -m pytest -q", cwd=project_root / "gemcode")
96
- results.append(EvalResult(name="pytest", ok=(rc2 == 0), score=1.0 if rc2 == 0 else 0.0, details=out2[-1200:]))
113
+ # Gate 1: tool declaration smoke (in-process; matches REPL config when session_cfg is passed)
114
+ inspections = inspect_tools(cfg, extra_tools=extra_tools)
115
+ failures = smoke_tools(inspections)
116
+ ok_smoke = len(failures) == 0
117
+ smoke_details = ""
118
+ if failures:
119
+ smoke_details = "\n".join(
120
+ f"{f.name}: {f.declaration_error}" for f in failures[:40]
121
+ )
122
+ results.append(
123
+ EvalResult(
124
+ name="tools_smoke",
125
+ ok=ok_smoke,
126
+ score=1.0 if ok_smoke else 0.0,
127
+ details=smoke_details[-1200:],
128
+ )
129
+ )
130
+
131
+ # Gate 2: pytest if a tests/ tree exists under root or root/gemcode
132
+ pytest_target = _discover_pytest_cwd(root)
133
+ if pytest_target is not None:
134
+ cwd, env = pytest_target
135
+ p = subprocess.run(
136
+ [sys.executable, "-m", "pytest", "-q"],
137
+ cwd=str(cwd),
138
+ env=env,
139
+ capture_output=True,
140
+ text=True,
141
+ )
142
+ out2 = (p.stdout or "") + (p.stderr or "")
143
+ results.append(
144
+ EvalResult(
145
+ name="pytest",
146
+ ok=(p.returncode == 0),
147
+ score=1.0 if p.returncode == 0 else 0.0,
148
+ details=out2[-1200:],
149
+ )
150
+ )
97
151
 
98
152
  if include_llm:
99
153
  goldens = [
gemcode/ide_protocol.py CHANGED
@@ -8,6 +8,14 @@ Design goals:
8
8
  - Human-readable JSONL (easy to debug)
9
9
  - Streaming (token deltas + progress)
10
10
  - Safe editing (engine proposes; IDE applies via WorkspaceEdit)
11
+
12
+ Attachments on ``action: turn`` may include:
13
+
14
+ - Textual: ``type: selection`` / ``file`` (appended into the prompt as fenced blocks).
15
+ - Binary / multimodal: ``type: inline`` | ``binary`` | ``blob`` with ``data`` or ``base64``
16
+ (standard base64), plus optional ``filename`` / ``name`` and ``mimeType`` / ``mime_type``.
17
+ The engine writes bytes to a temp file and passes them to Gemini as inline parts
18
+ (same limits as CLI ``--attach``).
11
19
  """
12
20
 
13
21
  from __future__ import annotations
gemcode/ide_stdio.py CHANGED
@@ -16,8 +16,13 @@ GemCode is responsible for:
16
16
  from __future__ import annotations
17
17
 
18
18
  import asyncio
19
+ import base64
20
+ import binascii
21
+ import mimetypes
19
22
  import os
20
23
  import sys
24
+ import tempfile
25
+ from pathlib import Path
21
26
  from typing import Any
22
27
 
23
28
  from gemcode.config import GemCodeConfig, load_cli_environment
@@ -38,6 +43,107 @@ def _truthy(v: Any, default: bool = False) -> bool:
38
43
  return default
39
44
 
40
45
 
46
+ def _max_ide_inline_bytes() -> int:
47
+ raw = os.environ.get("GEMCODE_MAX_ATTACHMENT_BYTES")
48
+ if raw:
49
+ try:
50
+ v = int(raw, 10)
51
+ if v > 0:
52
+ return v
53
+ except ValueError:
54
+ pass
55
+ return 20 * 1024 * 1024
56
+
57
+
58
+ def _suffix_for_inline_attachment(name: str, mime: str) -> str:
59
+ p = Path(name or "")
60
+ if p.suffix and len(p.suffix) <= 12:
61
+ return p.suffix
62
+ m = (mime or "").strip().lower().split(";")[0].strip()
63
+ if m:
64
+ ext = mimetypes.guess_extension(m, strict=False)
65
+ if ext == ".jpe":
66
+ ext = ".jpeg"
67
+ if ext:
68
+ return ext
69
+ return ".bin"
70
+
71
+
72
+ def prepare_inline_attachment_paths(
73
+ attachments: list[Any] | None,
74
+ *,
75
+ max_bytes: int | None = None,
76
+ max_count: int = 16,
77
+ ) -> tuple[list[Path], list[str]]:
78
+ """
79
+ Materialize IDE ``inline`` / ``binary`` / ``blob`` attachment dicts as temp files.
80
+
81
+ Expected keys (camelCase accepted): ``data`` or ``base64``, optional ``name`` /
82
+ ``filename``, ``mime_type`` / ``mimeType``.
83
+
84
+ Returns ``(paths, errors)``. Caller must unlink paths when done.
85
+ """
86
+ cap = max_bytes if max_bytes is not None else _max_ide_inline_bytes()
87
+ max_b64 = int(cap * 4 / 3) + 32
88
+ paths: list[Path] = []
89
+ errors: list[str] = []
90
+ if not attachments:
91
+ return paths, errors
92
+
93
+ for a in attachments:
94
+ if len(paths) >= max_count:
95
+ errors.append(f"inline attachments: max {max_count} files")
96
+ break
97
+ if not isinstance(a, dict):
98
+ continue
99
+ at = str(a.get("type") or "").strip().lower()
100
+ if at not in ("inline", "binary", "blob"):
101
+ continue
102
+ b64 = a.get("data") if isinstance(a.get("data"), str) else None
103
+ if b64 is None:
104
+ b64 = a.get("base64") if isinstance(a.get("base64"), str) else None
105
+ if not b64:
106
+ errors.append("inline attachment missing base64 data")
107
+ continue
108
+ if len(b64) > max_b64:
109
+ errors.append("inline attachment too large (base64)")
110
+ continue
111
+ try:
112
+ raw = base64.b64decode(b64, validate=True)
113
+ except (binascii.Error, ValueError) as e:
114
+ errors.append(f"inline attachment: invalid base64 ({e})")
115
+ continue
116
+ if len(raw) > cap:
117
+ errors.append(f"inline attachment exceeds max {cap} bytes")
118
+ continue
119
+ name = str(a.get("name") or a.get("filename") or "attachment")
120
+ mime = str(a.get("mime_type") or a.get("mimeType") or "")
121
+ suffix = _suffix_for_inline_attachment(name, mime)
122
+ try:
123
+ fd, fspath = tempfile.mkstemp(prefix="gemcode_ide_", suffix=suffix)
124
+ with os.fdopen(fd, "wb") as f:
125
+ f.write(raw)
126
+ paths.append(Path(fspath))
127
+ except OSError as e:
128
+ errors.append(f"inline attachment temp file failed: {e}")
129
+
130
+ return paths, errors
131
+
132
+
133
+ def _textual_attachments_only(attachments: list[dict] | None) -> list[dict]:
134
+ if not attachments:
135
+ return []
136
+ out: list[dict] = []
137
+ for a in attachments:
138
+ if not isinstance(a, dict):
139
+ continue
140
+ at = str(a.get("type") or "").strip().lower()
141
+ if at in ("inline", "binary", "blob"):
142
+ continue
143
+ out.append(a)
144
+ return out
145
+
146
+
41
147
  def _build_prompt(prompt: str, attachments: list[dict] | None) -> str:
42
148
  # Keep it simple: attachments are appended as fenced blocks.
43
149
  if not attachments:
@@ -47,6 +153,8 @@ def _build_prompt(prompt: str, attachments: list[dict] | None) -> str:
47
153
  if not isinstance(a, dict):
48
154
  continue
49
155
  at = (a.get("type") or "").strip().lower()
156
+ if at in ("inline", "binary", "blob"):
157
+ continue
50
158
  if at == "selection":
51
159
  txt = a.get("text") or ""
52
160
  path = a.get("path") or ""
@@ -139,7 +247,6 @@ async def run_stdio_loop() -> int:
139
247
  if cfg is None:
140
248
  root = msg.get("project_root") or os.getcwd()
141
249
  model = msg.get("model") or os.environ.get("GEMCODE_MODEL") or ""
142
- from pathlib import Path
143
250
  cfg = GemCodeConfig(project_root=Path(str(root)), model=str(model))
144
251
  # Attach emitter + proposal mode flags (used by tool wrappers).
145
252
  object.__setattr__(cfg, "_ide_emitter", emitter)
@@ -151,7 +258,25 @@ async def run_stdio_loop() -> int:
151
258
 
152
259
  prompt = str(msg.get("prompt") or "")
153
260
  attachments = msg.get("attachments") if isinstance(msg.get("attachments"), list) else None
154
- full_prompt = _build_prompt(prompt, attachments)
261
+ att_dicts = [a for a in (attachments or []) if isinstance(a, dict)]
262
+ inline_paths, inline_err = prepare_inline_attachment_paths(attachments)
263
+ if inline_err:
264
+ for p in inline_paths:
265
+ try:
266
+ p.unlink()
267
+ except OSError:
268
+ pass
269
+ emitter.send(
270
+ make_response(
271
+ id=req_id,
272
+ ok=False,
273
+ error="; ".join(inline_err),
274
+ session=session_id,
275
+ )
276
+ )
277
+ continue
278
+
279
+ full_prompt = _build_prompt(prompt, _textual_attachments_only(att_dicts))
155
280
 
156
281
  # Per-turn allow flags (the engine still only proposes in IDE mode; the IDE applies).
157
282
  allow_write = _truthy(msg.get("allowWrite"), default=False)
@@ -161,14 +286,22 @@ async def run_stdio_loop() -> int:
161
286
 
162
287
  emitter.send(make_event(event="turn_start", id=req_id, session=session_id))
163
288
  try:
164
- events = await run_turn(
165
- runner,
166
- user_id="local",
167
- session_id=session_id,
168
- prompt=full_prompt,
169
- max_llm_calls=cfg.max_llm_calls,
170
- cfg=cfg,
171
- )
289
+ try:
290
+ events = await run_turn(
291
+ runner,
292
+ user_id="local",
293
+ session_id=session_id,
294
+ prompt=full_prompt,
295
+ max_llm_calls=cfg.max_llm_calls,
296
+ cfg=cfg,
297
+ attachment_paths=inline_paths if inline_paths else None,
298
+ )
299
+ finally:
300
+ for p in inline_paths:
301
+ try:
302
+ p.unlink()
303
+ except OSError:
304
+ pass
172
305
  except Exception as e:
173
306
  emitter.send(make_response(id=req_id, ok=False, error=f"{type(e).__name__}: {e}", session=session_id))
174
307
  continue
gemcode/invoke.py CHANGED
@@ -9,8 +9,9 @@ from __future__ import annotations
9
9
  import asyncio
10
10
  import os
11
11
  import sys
12
- from typing import Any
12
+ from pathlib import Path
13
13
  from threading import Lock
14
+ from typing import Any, Sequence
14
15
 
15
16
  from google.adk.agents.run_config import RunConfig
16
17
  from google.adk.runners import Runner
@@ -66,6 +67,7 @@ async def run_turn(
66
67
  prompt: str,
67
68
  max_llm_calls: int | None = None,
68
69
  cfg: "GemCodeConfig | None" = None,
70
+ attachment_paths: Sequence[Path | str] | None = None,
69
71
  ) -> list:
70
72
  """Execute one user message; collect all Events (caller aggregates text)."""
71
73
  # Dynamic risk score: updated each user message; later refined by tool outcomes.
@@ -86,6 +88,8 @@ async def run_turn(
86
88
  risk += 0.2
87
89
  if re.search(r"\\b(test|pytest|ci|build|deploy|release)\\b", p, re.I):
88
90
  risk += 0.1
91
+ if attachment_paths:
92
+ risk = min(1.0, risk + 0.12)
89
93
  # Multi-file hints
90
94
  if p.count("/") >= 6 or p.count(".py") + p.count(".ts") + p.count(".tsx") >= 3:
91
95
  risk += 0.1
@@ -167,10 +171,22 @@ async def run_turn(
167
171
 
168
172
  state_delta = token_budget_invocation_reset()
169
173
 
170
- # The first message is plain user text.
171
- current_message = types.Content(
172
- role="user", parts=[types.Part(text=prompt)]
173
- )
174
+ # First message: optional inline files + text (Gemini multimodal).
175
+ if attachment_paths:
176
+ from gemcode.multimodal_input import build_user_content
177
+
178
+ root = cfg.project_root if cfg is not None else Path.cwd()
179
+ current_message, attach_warn = build_user_content(
180
+ prompt,
181
+ attachment_paths,
182
+ project_root=root,
183
+ )
184
+ for w in attach_warn:
185
+ print(f"[gemcode] {w}", file=sys.stderr)
186
+ else:
187
+ current_message = types.Content(
188
+ role="user", parts=[types.Part(text=prompt)]
189
+ )
174
190
 
175
191
  async def _await_runner_events(
176
192
  *, next_message: types.Content, do_reset: bool