@agentsdock/server 1.0.10 → 1.0.11-beta.2
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.
- package/NOTICE +7 -0
- package/package.json +1 -1
- package/server/NOTICE +7 -0
- package/server/VERSION +1 -1
- package/server/agent_server.py +1605 -493
- package/server/agentsdock_jobs.py +2 -2
- package/server/claude_history_repair.py +46 -61
- package/server/codex_auth.py +76 -7
- package/server/codex_history_repair.py +203 -38
- package/server/cursor_process_guard.py +67 -4
- package/server/cursor_provider_mcp.py +2 -0
- package/server/execution_durability.py +87 -11
- package/server/execution_install.py +5 -1
- package/server/gemini_acp_client.py +432 -0
- package/server/gemini_api.py +128 -0
- package/server/gemini_content.py +102 -0
- package/server/gemini_history.py +293 -0
- package/server/gemini_import.py +126 -0
- package/server/gemini_permissions.py +47 -0
- package/server/gemini_runner.py +493 -0
- package/server/gemini_runtime.py +322 -0
- package/server/install.sh +11 -2
- package/server/interactive_chat_controls.py +9 -3
- package/server/interactive_chat_native.py +1 -1
- package/server/interactive_chat_share_routes.py +1 -1
- package/server/interactive_chat_share_web.py +2 -2
- package/server/local_session_ownership.py +1 -0
- package/server/provider_commands.py +65 -0
- package/server/provider_connections.py +39 -10
- package/server/server_instances.py +217 -15
- package/server/title_generation.py +60 -1
- package/server/transcript_reader.py +334 -0
- package/server/update_runner.py +4 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Bounded Gemini Developer API discovery; never OpenAI protocol guessing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
MODEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,199}\Z")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def supports_auto(selected: dict) -> bool:
|
|
16
|
+
"""Only Google's API is known to support the models chosen by CLI Auto.
|
|
17
|
+
|
|
18
|
+
Auto is a CLI routing alias, not a model returned by the REST inventory.
|
|
19
|
+
Do not assume a Gemini-compatible gateway implements that model family.
|
|
20
|
+
"""
|
|
21
|
+
url = urlsplit(selected.get("base_url") or "")
|
|
22
|
+
return (url.scheme == "https" and url.netloc.lower() in {
|
|
23
|
+
"generativelanguage.googleapis.com", "generativelanguage.googleapis.com:443"
|
|
24
|
+
} and url.path.rstrip("/") in {"", "/v1", "/v1beta"}
|
|
25
|
+
and not url.query and not url.fragment)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_model(selected: dict) -> str | None:
|
|
29
|
+
# Preserve an explicit chat model or its immutable connection default.
|
|
30
|
+
return selected.get("model") or ("auto" if supports_auto(selected) else None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def model_options(selected: dict, models: list[dict]) -> list[dict]:
|
|
34
|
+
if supports_auto(selected):
|
|
35
|
+
return [{"value": "auto", "label": "Auto (Gemini CLI)"},
|
|
36
|
+
*[item for item in models if item["value"] != "auto"]]
|
|
37
|
+
return models
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def api_base(selected):
|
|
41
|
+
base = selected["base_url"].rstrip("/")
|
|
42
|
+
return base if base.endswith(("/v1", "/v1beta")) else base + "/v1beta"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_models(value):
|
|
46
|
+
if not isinstance(value, dict) or not isinstance(value.get("models"), list):
|
|
47
|
+
raise ValueError("Invalid Gemini model catalog")
|
|
48
|
+
result = []
|
|
49
|
+
for item in value["models"]:
|
|
50
|
+
if not isinstance(item, dict):
|
|
51
|
+
raise ValueError("Invalid Gemini model entry")
|
|
52
|
+
model = str(item.get("name") or "").removeprefix("models/")
|
|
53
|
+
if not MODEL.fullmatch(model) or "generateContent" not in item.get("supportedGenerationMethods", []):
|
|
54
|
+
continue
|
|
55
|
+
label = item.get("displayName") or model
|
|
56
|
+
if not isinstance(label, str) or not label or len(label) > 256 or any(ord(c) < 32 or ord(c) == 127 for c in label):
|
|
57
|
+
label = model
|
|
58
|
+
result.append({"value": model, "label": label})
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def catalog(selected):
|
|
63
|
+
models, seen = [], set()
|
|
64
|
+
deadline = time.monotonic() + 10
|
|
65
|
+
status = "unavailable"
|
|
66
|
+
try:
|
|
67
|
+
with httpx.Client(follow_redirects=False, trust_env=False) as client:
|
|
68
|
+
page = None
|
|
69
|
+
for _ in range(5):
|
|
70
|
+
remaining = deadline - time.monotonic()
|
|
71
|
+
if remaining <= 0:
|
|
72
|
+
break
|
|
73
|
+
params = {"pageSize": "100", **({"pageToken": page} if page else {})}
|
|
74
|
+
with client.stream("GET", api_base(selected) + "/models", params=params,
|
|
75
|
+
headers={"x-goog-api-key": selected["api_key"]}, timeout=remaining) as response:
|
|
76
|
+
if response.status_code in {401, 403}: return {"models": [], "discovery_status": "authentication_failed"}
|
|
77
|
+
if response.status_code != 200: break
|
|
78
|
+
raw = bytearray()
|
|
79
|
+
for chunk in response.iter_bytes():
|
|
80
|
+
raw.extend(chunk)
|
|
81
|
+
if len(raw) > 2 * 1024 * 1024 or time.monotonic() > deadline: raise ValueError()
|
|
82
|
+
value = json.loads(raw)
|
|
83
|
+
for item in parse_models(value):
|
|
84
|
+
if item["value"] not in seen:
|
|
85
|
+
seen.add(item["value"]); models.append(item)
|
|
86
|
+
page = value.get("nextPageToken")
|
|
87
|
+
if not page:
|
|
88
|
+
return {"models": models[:512], "discovery_status": "ready" if models else "empty"}
|
|
89
|
+
if not isinstance(page, str) or len(page) > 4096: raise ValueError()
|
|
90
|
+
except (ValueError, TypeError, httpx.HTTPError):
|
|
91
|
+
pass
|
|
92
|
+
return {"models": models[:512], "discovery_status": "partial" if models else status}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def probe(selected):
|
|
96
|
+
async def send():
|
|
97
|
+
headers = {"x-goog-api-key": selected["api_key"]}
|
|
98
|
+
model = selected.get("model")
|
|
99
|
+
async with httpx.AsyncClient(timeout=10, follow_redirects=False, trust_env=False) as client:
|
|
100
|
+
if model:
|
|
101
|
+
if not MODEL.fullmatch(model): return "unsupported"
|
|
102
|
+
method, target = "POST", api_base(selected) + "/models/" + quote(model, safe="") + ":generateContent"
|
|
103
|
+
kwargs = {"json": {"contents": [{"role": "user", "parts": [{"text": "Reply OK only."}]}],
|
|
104
|
+
"generationConfig": {"maxOutputTokens": 256}}}
|
|
105
|
+
else:
|
|
106
|
+
method, target, kwargs = "GET", api_base(selected) + "/models?pageSize=1", {}
|
|
107
|
+
async with client.stream(method, target, headers=headers, **kwargs) as response:
|
|
108
|
+
if response.status_code in {400, 401, 403}: return "authentication_failed" if not model or response.status_code != 400 else "unsupported"
|
|
109
|
+
if response.status_code == 429: return "rate_limited"
|
|
110
|
+
if response.status_code != 200: return "connection_failed"
|
|
111
|
+
raw = bytearray()
|
|
112
|
+
async for chunk in response.aiter_bytes():
|
|
113
|
+
raw.extend(chunk)
|
|
114
|
+
if len(raw) > 128 * 1024: return "invalid_response"
|
|
115
|
+
value = json.loads(raw)
|
|
116
|
+
if model:
|
|
117
|
+
return "verified" if any(part.get("text", "").strip() for candidate in value.get("candidates", [])
|
|
118
|
+
for part in candidate.get("content", {}).get("parts", []) if not part.get("thought")) else "invalid_response"
|
|
119
|
+
parse_models(value)
|
|
120
|
+
# A public gateway catalog cannot prove its key was accepted.
|
|
121
|
+
async with client.stream("GET", target) as public:
|
|
122
|
+
return "verified" if public.status_code in {400, 401, 403} else "model_required"
|
|
123
|
+
try:
|
|
124
|
+
return await asyncio.wait_for(send(), 20)
|
|
125
|
+
except (asyncio.TimeoutError, httpx.HTTPError):
|
|
126
|
+
return "connection_failed"
|
|
127
|
+
except (ValueError, TypeError, AttributeError):
|
|
128
|
+
return "invalid_response"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Bounded native ACP content and provider-reported metadata (no estimates)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException
|
|
10
|
+
from gemini_acp_client import GeminiProtocolError
|
|
11
|
+
|
|
12
|
+
MAX_FILE_BYTES = 512 * 1024
|
|
13
|
+
MAX_TOTAL_BYTES = 2 * 1024 * 1024
|
|
14
|
+
MAX_FILES = 8
|
|
15
|
+
MEDIA = {"image/png", "image/jpeg", "image/webp", "image/gif", "audio/wav", "audio/mpeg", "audio/ogg", "audio/flac"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tool_output(content) -> str:
|
|
19
|
+
"""Keep native diffs/resources in traces, not just text-only tool results."""
|
|
20
|
+
if not isinstance(content, list):
|
|
21
|
+
return json.dumps(content, ensure_ascii=False)
|
|
22
|
+
parts = []
|
|
23
|
+
for item in content:
|
|
24
|
+
block = item.get("content") if isinstance(item, dict) and item.get("type") == "content" else None
|
|
25
|
+
if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
26
|
+
parts.append(block["text"])
|
|
27
|
+
else:
|
|
28
|
+
parts.append(json.dumps(item, ensure_ascii=False))
|
|
29
|
+
return "\n".join(parts)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def attachments(api, session_id: str, file_ids: list[str]) -> list[dict]:
|
|
33
|
+
ids = api.validate_session_file_ids(session_id, file_ids)
|
|
34
|
+
if len(ids) > MAX_FILES:
|
|
35
|
+
raise HTTPException(413, "Gemini accepts at most 8 attachments per turn.")
|
|
36
|
+
result, total = [], 0
|
|
37
|
+
for file_id in ids:
|
|
38
|
+
record = api.load_file_meta(file_id)
|
|
39
|
+
source = api.fork_file_source_path(file_id, record)
|
|
40
|
+
if source is None:
|
|
41
|
+
raise HTTPException(409, "Gemini attachment is no longer available.")
|
|
42
|
+
try:
|
|
43
|
+
descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
44
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
45
|
+
before = os.fstat(handle.fileno())
|
|
46
|
+
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_FILE_BYTES:
|
|
47
|
+
raise HTTPException(413, "Gemini attachments must be regular files of at most 512 KiB each.")
|
|
48
|
+
raw = handle.read(MAX_FILE_BYTES + 1)
|
|
49
|
+
after = os.fstat(handle.fileno())
|
|
50
|
+
if len(raw) > MAX_FILE_BYTES or (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
|
51
|
+
raise HTTPException(409, "Gemini attachment changed while reading; attach it again.")
|
|
52
|
+
except OSError:
|
|
53
|
+
raise HTTPException(409, "Gemini attachment could not be safely read.") from None
|
|
54
|
+
total += len(raw)
|
|
55
|
+
if total > MAX_TOTAL_BYTES:
|
|
56
|
+
raise HTTPException(413, "Gemini attachments exceed the 2 MiB total limit.")
|
|
57
|
+
mime = str(record.get("content_type") or "").split(";", 1)[0].strip().lower()
|
|
58
|
+
if mime in MEDIA:
|
|
59
|
+
result.append({"type": "image" if mime.startswith("image/") else "audio",
|
|
60
|
+
"mimeType": mime, "data": base64.b64encode(raw).decode("ascii")})
|
|
61
|
+
else:
|
|
62
|
+
try:
|
|
63
|
+
text = raw.decode("utf-8")
|
|
64
|
+
if "\0" in text:
|
|
65
|
+
raise ValueError()
|
|
66
|
+
except (UnicodeError, ValueError):
|
|
67
|
+
raise HTTPException(415, "Gemini supports UTF-8 text, PNG/JPEG/WebP/GIF images and WAV/MP3/OGG/FLAC audio attachments.") from None
|
|
68
|
+
# Pinned ACP resolvePrompt drops inline media when resource blocks
|
|
69
|
+
# are mixed with it. Explicit text parts preserve mixed uploads;
|
|
70
|
+
# no URL fetching or arbitrary path resolution is involved.
|
|
71
|
+
result.append({"type": "text", "text": "\nAttached file (quoted content): "
|
|
72
|
+
+ json.dumps({"name": str(record.get("filename") or file_id)[:256], "text": text}, ensure_ascii=False)})
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def plan_entries(value) -> list[dict]:
|
|
77
|
+
if not isinstance(value, list) or len(value) > 128:
|
|
78
|
+
raise GeminiProtocolError("Gemini returned an invalid plan.")
|
|
79
|
+
result = []
|
|
80
|
+
for row in value:
|
|
81
|
+
if (not isinstance(row, dict) or not isinstance(row.get("content"), str)
|
|
82
|
+
or len(row["content"]) > 2048 or row.get("status") not in {"pending", "in_progress", "completed"}
|
|
83
|
+
or row.get("priority") not in {"high", "medium", "low"}):
|
|
84
|
+
raise GeminiProtocolError("Gemini returned an invalid plan entry.")
|
|
85
|
+
result.append({key: row[key] for key in ("content", "status", "priority")})
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def usage(value) -> dict:
|
|
90
|
+
if not isinstance(value, dict):
|
|
91
|
+
return {}
|
|
92
|
+
result = {}
|
|
93
|
+
for source, target in (("inputTokens", "input_tokens"), ("outputTokens", "output_tokens"),
|
|
94
|
+
("totalTokens", "total_tokens"), ("cachedReadTokens", "cached_input_tokens"),
|
|
95
|
+
("thoughtTokens", "reasoning_tokens")):
|
|
96
|
+
count = value.get(source)
|
|
97
|
+
if count is None:
|
|
98
|
+
continue
|
|
99
|
+
if type(count) is not int or not 0 <= count <= 2**53 - 1:
|
|
100
|
+
raise GeminiProtocolError("Gemini returned invalid token usage.")
|
|
101
|
+
result[target] = count
|
|
102
|
+
return result
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Read-only replay fence for the pinned CLI's asynchronous session/load.
|
|
2
|
+
|
|
3
|
+
Gemini 0.64.0 returns load before streamHistory finishes, with no replay-end
|
|
4
|
+
notification. Match a bounded native snapshot BEFORE sending a new prompt.
|
|
5
|
+
Unknown formats fail closed; this is not an import/reconciliation API.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import stat
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
from gemini_acp_client import GeminiProtocolError, identifier
|
|
18
|
+
from gemini_runtime import native_home
|
|
19
|
+
|
|
20
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
21
|
+
MAX_ENTRIES = 10000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def replay_text(value) -> str:
|
|
25
|
+
"""Match pinned partListUnionToString for supported text/tool history.
|
|
26
|
+
|
|
27
|
+
Native skill/tool responses are user-role functionResponse parts. They
|
|
28
|
+
replay as placeholders, not additional user turns. This is only a replay
|
|
29
|
+
fence: do not import those placeholders into the AgentsDock timeline.
|
|
30
|
+
Unknown media/part shapes still fail closed.
|
|
31
|
+
"""
|
|
32
|
+
if value is None:
|
|
33
|
+
return ""
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
return value
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
if len(value) > MAX_ENTRIES or any(isinstance(part, list) for part in value):
|
|
38
|
+
raise GeminiProtocolError("Unsupported Gemini replay part list.")
|
|
39
|
+
return "".join(replay_text(part) for part in value)
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
if set(value) <= {"text", "thought", "thoughtSignature"} and isinstance(value.get("text"), str):
|
|
42
|
+
if "thought" in value:
|
|
43
|
+
if not isinstance(value["thought"], bool):
|
|
44
|
+
raise GeminiProtocolError("Invalid Gemini native thought part.")
|
|
45
|
+
return "[Thought: true]" if value["thought"] else "[Thought: false]"
|
|
46
|
+
return value["text"]
|
|
47
|
+
for key, label in (("functionCall", "Function Call"), ("functionResponse", "Function Response")):
|
|
48
|
+
if set(value) <= {key, "thoughtSignature"} and isinstance(value.get(key), dict):
|
|
49
|
+
name = identifier(value[key].get("name"))
|
|
50
|
+
return f"[{label}: {name}]"
|
|
51
|
+
if set(value) <= {"inlineData", "thoughtSignature"} and isinstance(value.get("inlineData"), dict):
|
|
52
|
+
media = value["inlineData"]
|
|
53
|
+
mime, data = media.get("mimeType"), media.get("data")
|
|
54
|
+
if isinstance(mime, str) and isinstance(data, str):
|
|
55
|
+
category = next((label for prefix, label in (("audio/", "Audio"), ("video/", "Video"), ("image/", "Image")) if mime.startswith(prefix)), "Media")
|
|
56
|
+
# Match JS toFixed(1), including its half-up behavior.
|
|
57
|
+
kb = math.floor(math.ceil(len(data) * 3 / 4) / 1024 * 10 + .5) / 10
|
|
58
|
+
return f"[{category}: {mime}, {kb:.1f} KB]"
|
|
59
|
+
raise GeminiProtocolError("This Gemini history format needs a newer resume adapter; continue it in Gemini CLI.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fingerprint(update: dict) -> tuple[str, str]:
|
|
63
|
+
kind = update.get("sessionUpdate")
|
|
64
|
+
if kind in {"user_message_chunk", "agent_message_chunk", "agent_thought_chunk"}:
|
|
65
|
+
content = update.get("content")
|
|
66
|
+
if not isinstance(content, dict) or content.get("type") != "text" or not isinstance(content.get("text"), str):
|
|
67
|
+
raise GeminiProtocolError("Unsupported Gemini replay content.")
|
|
68
|
+
return kind, hashlib.sha256(content["text"].encode()).hexdigest()
|
|
69
|
+
if kind == "tool_call":
|
|
70
|
+
return kind, identifier(update.get("toolCallId"))
|
|
71
|
+
raise GeminiProtocolError("Unsupported Gemini replay event.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def native_snapshot(env: dict[str, str], cwd: str, session_id: str, *, missing_ok: bool = False) -> dict | None:
|
|
75
|
+
identifier(session_id, session=True)
|
|
76
|
+
root = native_home(env) / ".gemini" / "tmp"
|
|
77
|
+
found = []
|
|
78
|
+
count = 0
|
|
79
|
+
def entries(directory):
|
|
80
|
+
nonlocal count
|
|
81
|
+
if directory.is_symlink():
|
|
82
|
+
raise GeminiProtocolError("Linked Gemini history cannot be resumed safely.")
|
|
83
|
+
with os.scandir(directory) as iterator:
|
|
84
|
+
for entry in iterator:
|
|
85
|
+
count += 1
|
|
86
|
+
if count > MAX_ENTRIES:
|
|
87
|
+
raise GeminiProtocolError("Gemini history exceeds the supported scan limit.")
|
|
88
|
+
yield entry
|
|
89
|
+
try:
|
|
90
|
+
if missing_ok and not root.exists():
|
|
91
|
+
return None
|
|
92
|
+
for project in entries(root):
|
|
93
|
+
if not project.is_dir(follow_symlinks=False):
|
|
94
|
+
continue
|
|
95
|
+
chats = Path(project.path) / "chats"
|
|
96
|
+
if not chats.is_dir():
|
|
97
|
+
continue
|
|
98
|
+
for entry in entries(chats):
|
|
99
|
+
if (entry.name.startswith("session-") and entry.name.endswith((f"-{session_id[:8]}.json", f"-{session_id[:8]}.jsonl"))
|
|
100
|
+
and entry.is_file(follow_symlinks=False)):
|
|
101
|
+
value = read_snapshot(Path(entry.path))
|
|
102
|
+
if value.get("sessionId") == session_id:
|
|
103
|
+
found.append(value)
|
|
104
|
+
except (OSError, ValueError, TypeError, RecursionError):
|
|
105
|
+
raise GeminiProtocolError("Gemini native history cannot be safely read for resume.") from None
|
|
106
|
+
if not found and missing_ok:
|
|
107
|
+
return None
|
|
108
|
+
if len(found) != 1 or found[0].get("projectHash") != hashlib.sha256(cwd.encode()).hexdigest():
|
|
109
|
+
raise GeminiProtocolError("Gemini native history does not uniquely match this chat and workspace.")
|
|
110
|
+
return found[0]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def replay_snapshot(env: dict[str, str], cwd: str, session_id: str) -> list[tuple[str, str]]:
|
|
114
|
+
return snapshot_replay(native_snapshot(env, cwd, session_id))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def snapshot_replay(snapshot: dict) -> list[tuple[str, str]]:
|
|
118
|
+
result = []
|
|
119
|
+
for message in snapshot["messages"]:
|
|
120
|
+
if not isinstance(message, dict):
|
|
121
|
+
raise GeminiProtocolError("Invalid Gemini native message.")
|
|
122
|
+
kind = message.get("type")
|
|
123
|
+
if kind not in {"user", "gemini"}:
|
|
124
|
+
continue # Pinned streamHistory ignores native info/error records.
|
|
125
|
+
content = replay_text(message.get("content"))
|
|
126
|
+
if kind == "gemini":
|
|
127
|
+
for thought in message.get("thoughts") or []:
|
|
128
|
+
if not isinstance(thought, dict) or not all(isinstance(thought.get(key), str) for key in ("subject", "description")):
|
|
129
|
+
raise GeminiProtocolError("Invalid Gemini native thought.")
|
|
130
|
+
result.append(fingerprint({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": f"**{thought['subject']}**\n{thought['description']}"}}))
|
|
131
|
+
if content.strip():
|
|
132
|
+
result.append(fingerprint({"sessionUpdate": "user_message_chunk" if kind == "user" else "agent_message_chunk", "content": {"type": "text", "text": content}}))
|
|
133
|
+
if kind == "gemini":
|
|
134
|
+
for tool in message.get("toolCalls") or []:
|
|
135
|
+
result.append(fingerprint({"sessionUpdate": "tool_call", "toolCallId": tool.get("id")}))
|
|
136
|
+
if len(result) > MAX_ENTRIES:
|
|
137
|
+
raise GeminiProtocolError("Gemini replay exceeds the supported limit.")
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def verify_resume_snapshot(before: dict, after: dict) -> None:
|
|
142
|
+
"""Allow only the pinned loader's exact text/tool normalization.
|
|
143
|
+
|
|
144
|
+
streamHistory emits the pre-normalization messages, while resumeChat writes
|
|
145
|
+
converted model parts and deterministic *_response rows back to disk. Merely
|
|
146
|
+
comparing replay fingerprints therefore rejects every resumed tool turn.
|
|
147
|
+
Preserve the concurrency fence by predicting those mutations, not by
|
|
148
|
+
dropping it or allowing arbitrary appended records.
|
|
149
|
+
"""
|
|
150
|
+
failure = "Gemini history changed during resume; close the other client before retrying."
|
|
151
|
+
if any(before.get(key) != after.get(key) for key in ("sessionId", "projectHash")):
|
|
152
|
+
raise GeminiProtocolError(failure)
|
|
153
|
+
if before["messages"] == after["messages"]:
|
|
154
|
+
return
|
|
155
|
+
records = deepcopy(before["messages"])
|
|
156
|
+
by_id = {row["id"]: row for row in records}
|
|
157
|
+
if len(by_id) != len(records):
|
|
158
|
+
raise GeminiProtocolError(failure)
|
|
159
|
+
converted = []
|
|
160
|
+
generated = set()
|
|
161
|
+
empty_model_ids = set()
|
|
162
|
+
|
|
163
|
+
def parts(value):
|
|
164
|
+
values = value if isinstance(value, list) else [value]
|
|
165
|
+
return [{"text": part} if isinstance(part, str) else deepcopy(part) for part in values]
|
|
166
|
+
|
|
167
|
+
for row in records:
|
|
168
|
+
kind, content = row.get("type"), row.get("content")
|
|
169
|
+
if kind == "user":
|
|
170
|
+
text = replay_text(content).strip()
|
|
171
|
+
if not text or text.startswith(("/", "?", "<session_context>", "<hook_context>")):
|
|
172
|
+
continue
|
|
173
|
+
row["content"] = parts(content)
|
|
174
|
+
converted.append(row)
|
|
175
|
+
elif kind == "gemini":
|
|
176
|
+
model_parts = parts(content) if content else []
|
|
177
|
+
if not any(p.get("functionCall") or p.get("thought") for p in model_parts):
|
|
178
|
+
thoughts = [{"text": f"**{t['subject']}** {t['description']}" if t.get("subject") else t["description"],
|
|
179
|
+
"thought": True} for t in row.get("thoughts", [])]
|
|
180
|
+
calls = [{"functionCall": {key: tool[key] for key in ("id", "name", "args")}}
|
|
181
|
+
for tool in row.get("toolCalls", [])]
|
|
182
|
+
model_parts = thoughts + model_parts + calls
|
|
183
|
+
if not model_parts:
|
|
184
|
+
empty_model_ids.add(row["id"])
|
|
185
|
+
continue
|
|
186
|
+
row["content"] = model_parts
|
|
187
|
+
converted.append(row)
|
|
188
|
+
responses = []
|
|
189
|
+
for tool in row.get("toolCalls", []):
|
|
190
|
+
value = tool.get("result")
|
|
191
|
+
if not value:
|
|
192
|
+
continue
|
|
193
|
+
if isinstance(value, str):
|
|
194
|
+
responses.append({"functionResponse": {"id": tool["id"], "name": tool["name"], "response": {"output": value}}})
|
|
195
|
+
else:
|
|
196
|
+
responses.extend(parts(value))
|
|
197
|
+
if responses:
|
|
198
|
+
key = row["id"] + "_response"
|
|
199
|
+
response = deepcopy(by_id.get(key, {"id": key, "type": "user"}))
|
|
200
|
+
if response.get("type") != "user":
|
|
201
|
+
raise GeminiProtocolError(failure)
|
|
202
|
+
response["content"] = responses
|
|
203
|
+
generated.add(key)
|
|
204
|
+
converted.append(response)
|
|
205
|
+
# updateMessagesFromHistory keeps ignored native context/info records, then
|
|
206
|
+
# writes converted orderIds. Native JSONL loading de-duplicates those IDs.
|
|
207
|
+
ordered = {row["id"]: row for row in converted}
|
|
208
|
+
tools = {tool["id"]: tool for row in records for tool in row.get("toolCalls", [])}
|
|
209
|
+
for row in converted:
|
|
210
|
+
if row.get("type") == "user":
|
|
211
|
+
for part in row["content"]:
|
|
212
|
+
response = part.get("functionResponse", {})
|
|
213
|
+
if response.get("id") in tools:
|
|
214
|
+
tools[response["id"]]["result"] = deepcopy(row["content"])
|
|
215
|
+
# Cancelling while awaiting approval can leave an empty model record. The
|
|
216
|
+
# native loader drops precisely these records, not user text/tool history.
|
|
217
|
+
expected = [row for row in records if row["id"] not in ordered and row["id"] not in empty_model_ids] + list(ordered.values())
|
|
218
|
+
actual = deepcopy(after["messages"])
|
|
219
|
+
# Pinned startChat rebuilds its leading environment header (directory tree,
|
|
220
|
+
# date, etc.) on resume. A tool that created a file legitimately changes it.
|
|
221
|
+
# Only that first, exact native context row is allowed to refresh; real user
|
|
222
|
+
# content, tool results, identities, metadata and additional rows stay exact.
|
|
223
|
+
def environment_header(row):
|
|
224
|
+
parts = row.get("content")
|
|
225
|
+
return (row.get("type") == "user" and isinstance(parts, list) and len(parts) == 1
|
|
226
|
+
and isinstance(parts[0], dict) and set(parts[0]) == {"text"}
|
|
227
|
+
and isinstance(parts[0]["text"], str)
|
|
228
|
+
and parts[0]["text"].startswith("<session_context>\n")
|
|
229
|
+
and parts[0]["text"].endswith("\n</session_context>"))
|
|
230
|
+
if expected and actual and environment_header(expected[0]) and environment_header(actual[0]):
|
|
231
|
+
if {k: v for k, v in expected[0].items() if k != "content"} == {k: v for k, v in actual[0].items() if k != "content"}:
|
|
232
|
+
actual[0]["content"] = expected[0]["content"]
|
|
233
|
+
for rows in (expected, actual):
|
|
234
|
+
for row in rows:
|
|
235
|
+
if row["id"] in generated and row["id"] not in by_id:
|
|
236
|
+
row.pop("timestamp", None) # The only newly generated metadata.
|
|
237
|
+
if expected != actual:
|
|
238
|
+
raise GeminiProtocolError(failure)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def read_snapshot(path: Path) -> dict:
|
|
242
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
243
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
244
|
+
before = os.fstat(handle.fileno())
|
|
245
|
+
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_BYTES:
|
|
246
|
+
raise GeminiProtocolError("Gemini history exceeds the supported file limit.")
|
|
247
|
+
raw = handle.read(MAX_BYTES + 1)
|
|
248
|
+
after = os.fstat(handle.fileno())
|
|
249
|
+
if len(raw) > MAX_BYTES or (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
|
250
|
+
raise GeminiProtocolError("Gemini history changed while preparing resume; retry after the other client stops.")
|
|
251
|
+
if path.suffix == ".json":
|
|
252
|
+
return json.loads(raw)
|
|
253
|
+
metadata, messages = {}, {}
|
|
254
|
+
for line in raw.splitlines():
|
|
255
|
+
if not line.strip():
|
|
256
|
+
continue
|
|
257
|
+
value = json.loads(line)
|
|
258
|
+
if not isinstance(value, dict):
|
|
259
|
+
raise GeminiProtocolError("Invalid Gemini history record.")
|
|
260
|
+
if "$rewindTo" in value:
|
|
261
|
+
ids = list(messages)
|
|
262
|
+
target = value["$rewindTo"]
|
|
263
|
+
messages = {key: messages[key] for key in ids[:ids.index(target)]} if target in ids else {}
|
|
264
|
+
elif "$patch" in value:
|
|
265
|
+
patch = value["$patch"]
|
|
266
|
+
if not isinstance(patch, dict) or set(patch) - {"id", "content", "toolCalls", "updates", "removeIds", "orderIds"}:
|
|
267
|
+
raise GeminiProtocolError("Unsupported Gemini history patch.")
|
|
268
|
+
for update in ([patch] if "id" in patch else []) + patch.get("updates", []):
|
|
269
|
+
if not isinstance(update, dict) or set(update) - {"id", "content", "toolCalls"}:
|
|
270
|
+
raise GeminiProtocolError("Unsupported Gemini message patch.")
|
|
271
|
+
existing = messages.get(update.get("id"))
|
|
272
|
+
if existing and "content" in update:
|
|
273
|
+
existing["content"] = update["content"]
|
|
274
|
+
for tool_patch in update.get("toolCalls", []):
|
|
275
|
+
if not isinstance(tool_patch, dict) or set(tool_patch) - {"id", "result"}:
|
|
276
|
+
raise GeminiProtocolError("Unsupported Gemini tool-result patch.")
|
|
277
|
+
tool = next((row for row in (existing or {}).get("toolCalls", []) if row.get("id") == tool_patch.get("id")), None)
|
|
278
|
+
if tool is not None and "result" in tool_patch:
|
|
279
|
+
tool["result"] = tool_patch["result"]
|
|
280
|
+
for key in patch.get("removeIds", []):
|
|
281
|
+
messages.pop(key, None)
|
|
282
|
+
order = patch.get("orderIds", [])
|
|
283
|
+
if order:
|
|
284
|
+
messages = {**{key: row for key, row in messages.items() if key not in order},
|
|
285
|
+
**{key: messages[key] for key in order if key in messages}}
|
|
286
|
+
elif "id" in value:
|
|
287
|
+
messages[value["id"]] = value
|
|
288
|
+
else:
|
|
289
|
+
update = value.get("$set", value)
|
|
290
|
+
if "messages" in update:
|
|
291
|
+
messages = {row["id"]: row for row in update["messages"]}
|
|
292
|
+
metadata.update(update)
|
|
293
|
+
return {**metadata, "messages": list(messages.values())}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Read-only Gemini 0.64 main-session discovery and initial text snapshots.
|
|
2
|
+
|
|
3
|
+
The native .project_root marker plus transcript hash binds an exact workspace.
|
|
4
|
+
Never reverse a slug, launch Gemini, or treat tool responses as user messages.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import stat
|
|
12
|
+
|
|
13
|
+
from gemini_acp_client import GeminiProtocolError, identifier
|
|
14
|
+
from gemini_history import MAX_ENTRIES, read_snapshot
|
|
15
|
+
from gemini_runtime import native_home
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _regular_text(path: Path) -> str:
|
|
19
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
20
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
21
|
+
info = os.fstat(handle.fileno())
|
|
22
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > 16384:
|
|
23
|
+
raise ValueError("Invalid Gemini workspace marker")
|
|
24
|
+
raw = handle.read(16385)
|
|
25
|
+
if len(raw) > 16384:
|
|
26
|
+
raise ValueError("Invalid Gemini workspace marker")
|
|
27
|
+
return raw.decode("utf-8").strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def records(env: dict):
|
|
31
|
+
root = native_home(env) / ".gemini" / "tmp"
|
|
32
|
+
if not root.exists():
|
|
33
|
+
return
|
|
34
|
+
if root.is_symlink() or root.parent.is_symlink():
|
|
35
|
+
raise GeminiProtocolError("Linked Gemini history is not supported.")
|
|
36
|
+
count = 0
|
|
37
|
+
with os.scandir(root) as projects:
|
|
38
|
+
for project in projects:
|
|
39
|
+
count += 1
|
|
40
|
+
if count > MAX_ENTRIES:
|
|
41
|
+
raise GeminiProtocolError("Gemini discovery exceeded its scan limit.")
|
|
42
|
+
if not project.is_dir(follow_symlinks=False):
|
|
43
|
+
continue
|
|
44
|
+
directory = Path(project.path)
|
|
45
|
+
chats = directory / "chats"
|
|
46
|
+
try:
|
|
47
|
+
cwd = _regular_text(directory / ".project_root")
|
|
48
|
+
if not Path(cwd).is_absolute() or not Path(cwd).is_dir() or os.path.normpath(cwd) != cwd:
|
|
49
|
+
continue
|
|
50
|
+
if chats.is_symlink() or not chats.is_dir():
|
|
51
|
+
continue
|
|
52
|
+
with os.scandir(chats) as entries:
|
|
53
|
+
for entry in entries:
|
|
54
|
+
count += 1
|
|
55
|
+
if count > MAX_ENTRIES:
|
|
56
|
+
raise GeminiProtocolError("Gemini discovery exceeded its scan limit.")
|
|
57
|
+
if not entry.name.startswith("session-") or not entry.name.endswith((".json", ".jsonl")) or not entry.is_file(follow_symlinks=False):
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
path = Path(entry.path)
|
|
61
|
+
value = read_snapshot(path)
|
|
62
|
+
provider_id = identifier(value.get("sessionId"), session=True)
|
|
63
|
+
if (value.get("kind", "main") != "main" or value.get("archived") or value.get("deleted")
|
|
64
|
+
or value.get("parentSessionId") or value.get("projectHash") != hashlib.sha256(cwd.encode()).hexdigest()
|
|
65
|
+
or not any(row.get("type") == "gemini" for row in value["messages"])):
|
|
66
|
+
continue
|
|
67
|
+
yield path, cwd, provider_id, value
|
|
68
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError, RecursionError, GeminiProtocolError):
|
|
69
|
+
continue # A malformed entry cannot hide other main conversations.
|
|
70
|
+
except (OSError, ValueError, UnicodeError):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def visible_items(api, snapshot: dict) -> list[dict]:
|
|
75
|
+
result = []
|
|
76
|
+
for row in snapshot["messages"]:
|
|
77
|
+
role = row.get("type")
|
|
78
|
+
if role not in {"user", "gemini"}:
|
|
79
|
+
continue
|
|
80
|
+
content = row.get("content")
|
|
81
|
+
parts = content if isinstance(content, list) else [content]
|
|
82
|
+
# A user-role functionResponse is tool I/O, never a new human turn.
|
|
83
|
+
if role == "user" and any(isinstance(part, dict) and "functionResponse" in part for part in parts):
|
|
84
|
+
continue
|
|
85
|
+
text = "".join(part if isinstance(part, str) else part.get("text", "")
|
|
86
|
+
if isinstance(part, dict) and not part.get("thought") else "" for part in parts)
|
|
87
|
+
if role == "user":
|
|
88
|
+
if text.lstrip().startswith(("<session_context>", "<hook_context>", "/", "?")):
|
|
89
|
+
continue
|
|
90
|
+
text = api.strip_agentsdock_provider_context(text, allow_jobless_legacy=True)
|
|
91
|
+
text = api.strip_agentsdock_generated_user_text(text, provider_history=True)
|
|
92
|
+
if text.strip():
|
|
93
|
+
result.append({"kind": "user" if role == "user" else "assistant", "text": text,
|
|
94
|
+
"provider_message_id": identifier(row.get("id"))})
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def candidates(api, env: dict, known: set[str]) -> list[dict]:
|
|
99
|
+
unique, ambiguous = {}, set()
|
|
100
|
+
for path, cwd, provider_id, snapshot in records(env):
|
|
101
|
+
if provider_id in known:
|
|
102
|
+
continue
|
|
103
|
+
if provider_id in unique:
|
|
104
|
+
ambiguous.add(provider_id)
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
items = visible_items(api, snapshot)
|
|
108
|
+
first_user = next((item["text"] for item in items if item["kind"] == "user"), None)
|
|
109
|
+
if not first_user:
|
|
110
|
+
continue
|
|
111
|
+
unique[provider_id] = {"provider_session_id": provider_id, "backend": "gemini", "cwd": cwd,
|
|
112
|
+
"label": api.local_session_label(api.native_session_title(snapshot.get("summary")) or first_user, f"Gemini chat {provider_id[:8]}"),
|
|
113
|
+
"updated_at": api.iso_from_timestamp(path.stat().st_mtime)}
|
|
114
|
+
except (OSError, ValueError, GeminiProtocolError):
|
|
115
|
+
continue
|
|
116
|
+
return [value for key, value in unique.items() if key not in ambiguous]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def initial_history(api, env: dict, provider_id: str, cwd: str, limit=None):
|
|
120
|
+
identifier(provider_id, session=True)
|
|
121
|
+
matches = [(path, value) for path, workspace, pid, value in records(env) if pid == provider_id and workspace == cwd]
|
|
122
|
+
if len(matches) != 1:
|
|
123
|
+
raise GeminiProtocolError("Gemini history does not uniquely match this ID and workspace.")
|
|
124
|
+
path, value = matches[0]
|
|
125
|
+
items = visible_items(api, value)
|
|
126
|
+
return path, items[-limit:] if limit else items
|