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 +42 -1
- gemcode/autotune.py +17 -3
- gemcode/cli.py +48 -2
- gemcode/config.py +9 -0
- gemcode/evals/harness.py +74 -20
- gemcode/ide_protocol.py +8 -0
- gemcode/ide_stdio.py +143 -10
- gemcode/invoke.py +21 -5
- gemcode/multimodal_input.py +144 -0
- gemcode/repl_commands.py +137 -1
- gemcode/repl_slash.py +376 -3
- gemcode/trust.py +5 -0
- gemcode/tui/input_handler.py +8 -42
- gemcode/tui/scrollback.py +15 -1
- {gemcode-0.3.77.dist-info → gemcode-0.3.80.dist-info}/METADATA +133 -30
- {gemcode-0.3.77.dist-info → gemcode-0.3.80.dist-info}/RECORD +20 -19
- {gemcode-0.3.77.dist-info → gemcode-0.3.80.dist-info}/WHEEL +0 -0
- {gemcode-0.3.77.dist-info → gemcode-0.3.80.dist-info}/entry_points.txt +0 -0
- {gemcode-0.3.77.dist-info → gemcode-0.3.80.dist-info}/licenses/LICENSE +0 -0
- {gemcode-0.3.77.dist-info → gemcode-0.3.80.dist-info}/top_level.txt +0 -0
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
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
82
|
-
if
|
|
83
|
-
cfg
|
|
84
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
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
|