@agentsdock/server 1.0.10 → 1.0.11-beta.12
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 +1658 -498
- 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 +26 -2
- package/server/interactive_chat_controls.py +9 -3
- package/server/interactive_chat_native.py +1 -1
- package/server/interactive_chat_share_routes.py +2 -2
- package/server/interactive_chat_share_web.py +2 -2
- package/server/local_session_ownership.py +1 -0
- package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
- package/server/npm/agentsdock/pair.cjs +123 -0
- package/server/npm/agentsdock/qr-renderer.cjs +15 -0
- package/server/provider_commands.py +65 -0
- package/server/provider_connections.py +39 -10
- package/server/public_chat_share_routes.py +8 -6
- package/server/public_chat_shares.py +17 -7
- package/server/server_instances.py +217 -15
- package/server/shared_chat_analytics_web.py +5 -0
- package/server/title_generation.py +60 -1
- package/server/transcript_reader.py +334 -0
- package/server/update_runner.py +4 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Gemini native approval modes, not Codex sandbox or authorization grants."""
|
|
2
|
+
from gemini_acp_client import GeminiProtocolError
|
|
3
|
+
|
|
4
|
+
MODES = ("default", "autoEdit", "yolo", "plan")
|
|
5
|
+
# Plan is offered by the UI only after this workspace's native handshake has
|
|
6
|
+
# advertised it. Every turn revalidates the native list before any prompt.
|
|
7
|
+
INITIAL_MODES = MODES[:3]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def effective_mode(session: dict) -> str:
|
|
11
|
+
value = session.get("gemini_permission_mode") or "default"
|
|
12
|
+
if value not in MODES:
|
|
13
|
+
raise GeminiProtocolError("Invalid Gemini permission mode. Choose a native Gemini mode.")
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def startup_mode(mode: str) -> str:
|
|
18
|
+
# Explicit argv prevents inheriting a user's default YOLO setting. Starting
|
|
19
|
+
# YOLO through the CLI also enforces native disableYoloMode/secureModeEnabled;
|
|
20
|
+
# pinned ACP set_mode alone does not check those administrator restrictions.
|
|
21
|
+
# Plan is selected over ACP: CLI --approval-mode plan silently falls back to
|
|
22
|
+
# default when Plan is disabled, which must never masquerade as read-only.
|
|
23
|
+
return {"default": "default", "autoEdit": "auto_edit", "yolo": "yolo", "plan": "default"}[mode]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def available_modes(opened: dict) -> list[str]:
|
|
27
|
+
modes = opened.get("modes")
|
|
28
|
+
options = modes.get("availableModes") if isinstance(modes, dict) else None
|
|
29
|
+
if not isinstance(options, list) or len(options) > 32:
|
|
30
|
+
raise GeminiProtocolError("Gemini did not return its native permission modes. Check the supported CLI version.")
|
|
31
|
+
ids = []
|
|
32
|
+
for option in options:
|
|
33
|
+
if not isinstance(option, dict) or not isinstance(option.get("id"), str):
|
|
34
|
+
raise GeminiProtocolError("Gemini returned an invalid permission mode.")
|
|
35
|
+
if option["id"] in MODES and option["id"] not in ids:
|
|
36
|
+
ids.append(option["id"])
|
|
37
|
+
if "default" not in ids:
|
|
38
|
+
raise GeminiProtocolError("Gemini did not advertise normal tool approvals.")
|
|
39
|
+
return ids
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def apply_mode(client, opened: dict, mode: str) -> list[str]:
|
|
43
|
+
modes = available_modes(opened)
|
|
44
|
+
if mode not in modes:
|
|
45
|
+
raise GeminiProtocolError(f"Gemini permission mode '{mode}' is not enabled in this workspace. Choose an available mode; no prompt was sent.")
|
|
46
|
+
await client.request("session/set_mode", {"sessionId": client.session_id, "modeId": mode})
|
|
47
|
+
return modes
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Gemini turns and run-owned approvals using AgentsServer's durable lifecycle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import secrets
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
|
|
14
|
+
from gemini_acp_client import GeminiACPClient, GeminiProtocolError, identifier, session_models
|
|
15
|
+
from gemini_runtime import build_environment, GeminiConfigurationError, resolve_executable
|
|
16
|
+
from gemini_permissions import effective_mode, startup_mode, apply_mode, available_modes, INITIAL_MODES
|
|
17
|
+
|
|
18
|
+
PERMISSIONS: dict[str, dict] = {}
|
|
19
|
+
MODEL_CATALOG: dict[str, dict] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def communication_unavailable_message(cwd: str) -> str:
|
|
23
|
+
# Point at the actual project, not the terminal's default HOME or the
|
|
24
|
+
# credential-isolated API profile. Never change native trust on retry.
|
|
25
|
+
return ("No cross-chat message was sent by this turn. Gemini did not load the AgentsDock communication tool. "
|
|
26
|
+
"This is not a sign-in error. "
|
|
27
|
+
f"Workspace: {json.dumps(cwd, ensure_ascii=False)}. "
|
|
28
|
+
"On the server, open Gemini in that directory and review folder trust and MCP settings (/permissions and /mcp). "
|
|
29
|
+
"Trust only projects you recognize, then retry in AgentsDock. "
|
|
30
|
+
"AgentsDock does not grant folder trust automatically.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def clear_cleanup_fence(api, session_id: str, run_id: str):
|
|
34
|
+
session = api.STORE.sessions.get(session_id) or {}
|
|
35
|
+
if session.get("gemini_cleanup_pending_run_id") != run_id:
|
|
36
|
+
return
|
|
37
|
+
session.pop("gemini_cleanup_pending_run_id")
|
|
38
|
+
try:
|
|
39
|
+
await api.STORE.save(durable=True)
|
|
40
|
+
except BaseException:
|
|
41
|
+
session["gemini_cleanup_pending_run_id"] = run_id
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def owns(api, session_id, run_id):
|
|
46
|
+
active = api.ACTIVE.get(session_id) or {}
|
|
47
|
+
return (session_id in api.STORE.sessions and session_id not in api.DELETING_SESSIONS
|
|
48
|
+
and run_id not in api.STOPPED_RUNS and active.get("run_id") == run_id
|
|
49
|
+
and active.get("backend") == "gemini" and not active.get("stop_requested"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def ask_permission(api, session_id: str, run_id: str, params: dict) -> dict:
|
|
53
|
+
cancelled = {"outcome": {"outcome": "cancelled"}}
|
|
54
|
+
if not owns(api, session_id, run_id) or len(PERMISSIONS) >= 128:
|
|
55
|
+
return cancelled
|
|
56
|
+
options = params.get("options")
|
|
57
|
+
if not isinstance(options, list) or len(options) > 16:
|
|
58
|
+
return cancelled
|
|
59
|
+
allowed = {item["kind"]: item["optionId"] for item in options
|
|
60
|
+
if isinstance(item, dict) and item.get("kind") in {"allow_once", "reject_once"}
|
|
61
|
+
and isinstance(item.get("optionId"), str) and len(item["optionId"]) <= 240}
|
|
62
|
+
tool = params.get("toolCall") or {}
|
|
63
|
+
key = "geminireq_" + secrets.token_hex(16)
|
|
64
|
+
public = {"id": key, "session_id": session_id, "thread_id": params.get("sessionId"),
|
|
65
|
+
"turn_id": run_id, "item_id": identifier(tool.get("toolCallId")),
|
|
66
|
+
"method": "item/tool/requestApproval", "created_at": api.now_iso(),
|
|
67
|
+
"params": {"toolName": str(tool.get("title") or "Gemini tool")[:512],
|
|
68
|
+
"availableDecisions": [name for name, kind in [("accept", "allow_once"), ("decline", "reject_once")] if kind in allowed],
|
|
69
|
+
"toolInput": {"content": api.bounded_codex_interaction_value(tool.get("content") or [])}}}
|
|
70
|
+
future = asyncio.get_running_loop().create_future()
|
|
71
|
+
PERMISSIONS[key] = {"public": public, "future": future, "options": allowed, "run_id": run_id}
|
|
72
|
+
try:
|
|
73
|
+
await api.append_event(session_id, "gemini_interaction_requested", {"interaction": public, "run_id": run_id})
|
|
74
|
+
choice = await asyncio.wait_for(future, 1800)
|
|
75
|
+
if choice not in allowed or not owns(api, session_id, run_id):
|
|
76
|
+
return cancelled
|
|
77
|
+
return {"outcome": {"outcome": "selected", "optionId": allowed[choice]}}
|
|
78
|
+
finally:
|
|
79
|
+
PERMISSIONS.pop(key, None)
|
|
80
|
+
if session_id in api.STORE.sessions:
|
|
81
|
+
await api.append_event(session_id, "gemini_interaction_resolved", {"interaction": public, "run_id": run_id})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def resolve_permission(api, session_id: str, interaction_id: str, response: dict) -> dict:
|
|
85
|
+
"""Exact run/chat ownership shared by native and authenticated share controls."""
|
|
86
|
+
if (not isinstance(response, dict) or set(response) != {"decision"}
|
|
87
|
+
or not isinstance(response["decision"], str)
|
|
88
|
+
or response["decision"] not in {"accept", "decline", "cancel"}):
|
|
89
|
+
raise HTTPException(400, "Invalid approval decision.")
|
|
90
|
+
async with api.session_lifecycle_lock(session_id):
|
|
91
|
+
api.ensure_session_not_deleting(session_id)
|
|
92
|
+
row = PERMISSIONS.get(interaction_id)
|
|
93
|
+
if (not row or row["public"]["session_id"] != session_id or row["future"].done()
|
|
94
|
+
or not owns(api, session_id, row["run_id"])):
|
|
95
|
+
raise HTTPException(409, "This Gemini approval is no longer active.")
|
|
96
|
+
choice = {"accept": "allow_once", "decline": "reject_once", "cancel": "cancelled"}[response["decision"]]
|
|
97
|
+
if choice != "cancelled" and choice not in row["options"]:
|
|
98
|
+
raise HTTPException(400, "This decision is unavailable for the tool.")
|
|
99
|
+
row["future"].set_result(choice)
|
|
100
|
+
return {"interaction": row["public"]}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def router(api) -> APIRouter:
|
|
104
|
+
routes = APIRouter()
|
|
105
|
+
|
|
106
|
+
@routes.get("/api/sessions/{session_id}/gemini/runtime")
|
|
107
|
+
async def runtime(session_id: str, request: Request):
|
|
108
|
+
api.require_native_admin_control(request)
|
|
109
|
+
sess = api.STORE.sessions.get(session_id)
|
|
110
|
+
if not sess or sess.get("backend") != "gemini":
|
|
111
|
+
raise HTTPException(404, "Gemini chat not found.")
|
|
112
|
+
return {"pending_interactions": [row["public"] for row in PERMISSIONS.values()
|
|
113
|
+
if row["public"]["session_id"] == session_id and owns(api, session_id, row["run_id"])],
|
|
114
|
+
"available": True,
|
|
115
|
+
"permission_modes": sess.get("gemini_permission_modes") or list(INITIAL_MODES),
|
|
116
|
+
"permission_mode": effective_mode(sess),
|
|
117
|
+
"active_permission_mode": (api.ACTIVE.get(session_id) or {}).get("gemini_permission_mode")}
|
|
118
|
+
|
|
119
|
+
@routes.post("/api/sessions/{session_id}/gemini/interactions/{interaction_id}/resolve")
|
|
120
|
+
async def respond(session_id: str, interaction_id: str, request: Request):
|
|
121
|
+
api.require_native_admin_control(request)
|
|
122
|
+
raw = bytearray()
|
|
123
|
+
async def read():
|
|
124
|
+
async for chunk in request.stream():
|
|
125
|
+
raw.extend(chunk)
|
|
126
|
+
if len(raw) > 4096:
|
|
127
|
+
raise HTTPException(413, "Approval response is too large.")
|
|
128
|
+
try:
|
|
129
|
+
await asyncio.wait_for(read(), 10)
|
|
130
|
+
payload = json.loads(raw)
|
|
131
|
+
except asyncio.TimeoutError:
|
|
132
|
+
raise HTTPException(408, "Approval response timed out.") from None
|
|
133
|
+
except (ValueError, RecursionError):
|
|
134
|
+
raise HTTPException(400, "Invalid approval response.") from None
|
|
135
|
+
if not isinstance(payload, dict) or set(payload) != {"response"} or not isinstance(payload["response"], dict):
|
|
136
|
+
raise HTTPException(400, "Provide an approval response.")
|
|
137
|
+
return await resolve_permission(api, session_id, interaction_id, payload["response"])
|
|
138
|
+
|
|
139
|
+
return routes
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def run(api, session_id: str, run_id: str, prompt: str, sess: dict,
|
|
143
|
+
manifest_path: Path, *, standalone_provider_context=False,
|
|
144
|
+
communication_required=False, **_kwargs):
|
|
145
|
+
"""A new owned process per turn, resuming the same durable native UUID."""
|
|
146
|
+
from cursor_provider_mcp import CursorToolBroker
|
|
147
|
+
|
|
148
|
+
text_parts: list[str] = []
|
|
149
|
+
block: list[str] = []
|
|
150
|
+
block_kind = ""
|
|
151
|
+
block_index = 0
|
|
152
|
+
char_count = 0
|
|
153
|
+
tool_calls: dict[str, dict] = {}
|
|
154
|
+
finished_tools: set[str] = set()
|
|
155
|
+
broker = None
|
|
156
|
+
error = None
|
|
157
|
+
stopped = False
|
|
158
|
+
owner = secrets.token_urlsafe(48)
|
|
159
|
+
completed_reasoning = set()
|
|
160
|
+
env = None
|
|
161
|
+
provider_id = None
|
|
162
|
+
token_usage = {}
|
|
163
|
+
last_plan = None
|
|
164
|
+
context_usage = None
|
|
165
|
+
identity_bound = False
|
|
166
|
+
runtime_env = api.validate_provider_runtime_env(_kwargs.get("provider_runtime_env"))
|
|
167
|
+
helper_redactor = api.opencode_helper_output_redactor(session_id, run_id, runtime_env)
|
|
168
|
+
private_values = [owner]
|
|
169
|
+
|
|
170
|
+
def redact(value):
|
|
171
|
+
if isinstance(value, dict): return {key: redact(item) for key, item in value.items()}
|
|
172
|
+
if isinstance(value, list): return [redact(item) for item in value]
|
|
173
|
+
if not isinstance(value, str): return value
|
|
174
|
+
text = helper_redactor(value)
|
|
175
|
+
for private in private_values:
|
|
176
|
+
if private: text = text.replace(private, "<provider-private>")
|
|
177
|
+
return text
|
|
178
|
+
|
|
179
|
+
async def bind_visible_identity():
|
|
180
|
+
# Persist BEFORE exposing output or approving a tool. A server crash
|
|
181
|
+
# must never turn an already-observed first turn into a fresh chat.
|
|
182
|
+
# A merely allocated UUID on an auth/model failure is still retryable.
|
|
183
|
+
nonlocal identity_bound
|
|
184
|
+
if not identity_bound and client.session_id:
|
|
185
|
+
await api.persist_run_provider_session(session_id, run_id, "gemini", client.session_id,
|
|
186
|
+
cwd=cwd, standalone_provider_context=standalone_provider_context)
|
|
187
|
+
identity_bound = True
|
|
188
|
+
|
|
189
|
+
async def flush():
|
|
190
|
+
nonlocal block, block_kind, block_index
|
|
191
|
+
if not block or not await api.turn_slot_is_owned(session_id, run_id) or session_id not in api.STORE.sessions:
|
|
192
|
+
return
|
|
193
|
+
text = redact("".join(block))
|
|
194
|
+
block = []
|
|
195
|
+
item_id = f"gemini-{run_id}-{block_index}"
|
|
196
|
+
block_index += 1
|
|
197
|
+
payload = {"run_id": run_id, "backend": "gemini", "text": text, "item_id": item_id,
|
|
198
|
+
**api.run_event_metadata(run_id)}
|
|
199
|
+
if block_kind == "text":
|
|
200
|
+
text_parts.append(text)
|
|
201
|
+
async def commit():
|
|
202
|
+
await api.append_event(session_id, "assistant_text", payload)
|
|
203
|
+
await api.clear_reasoning_summary_stream(session_id, run_id, item_id, "assistant")
|
|
204
|
+
await api.join_task_despite_caller_cancellation(asyncio.create_task(commit()))
|
|
205
|
+
else:
|
|
206
|
+
await api.persist_reasoning_summary(session_id, {**payload, "phase": "summary"}, completed_reasoning)
|
|
207
|
+
|
|
208
|
+
async def update(event):
|
|
209
|
+
nonlocal block_kind, char_count, last_plan, context_usage
|
|
210
|
+
if not owns(api, session_id, run_id):
|
|
211
|
+
return
|
|
212
|
+
kind = event["type"]
|
|
213
|
+
if kind == "permission_mode":
|
|
214
|
+
# Native plan tools can change the current mode. Report it without
|
|
215
|
+
# changing the saved next-turn preference or another run's state.
|
|
216
|
+
api.ACTIVE[session_id]["gemini_permission_mode"] = event["mode"]
|
|
217
|
+
await api.append_event(session_id, "gemini_permission_mode", {
|
|
218
|
+
"run_id": run_id, "backend": "gemini", "mode": event["mode"]})
|
|
219
|
+
if kind in {"text", "reasoning", "tool", "plan"}:
|
|
220
|
+
await bind_visible_identity()
|
|
221
|
+
if kind in {"text", "reasoning"}:
|
|
222
|
+
if kind != block_kind:
|
|
223
|
+
await flush()
|
|
224
|
+
block_kind = kind
|
|
225
|
+
text = redact(event.get("text") or "")
|
|
226
|
+
char_count += len(text)
|
|
227
|
+
if char_count > 2 * 1024 * 1024:
|
|
228
|
+
raise GeminiProtocolError("Gemini output exceeded the per-turn limit.")
|
|
229
|
+
block.append(text)
|
|
230
|
+
await api.update_reasoning_summary_stream(session_id, run_id,
|
|
231
|
+
f"gemini-{run_id}-{block_index}", {"delta": text},
|
|
232
|
+
{**api.run_event_metadata(run_id), "backend": "gemini"},
|
|
233
|
+
phase="assistant" if kind == "text" else "summary")
|
|
234
|
+
elif kind == "tool":
|
|
235
|
+
await flush()
|
|
236
|
+
call_id = event["call_id"]
|
|
237
|
+
if call_id in finished_tools:
|
|
238
|
+
return
|
|
239
|
+
if call_id not in tool_calls:
|
|
240
|
+
if len(tool_calls) + len(finished_tools) >= 4096:
|
|
241
|
+
raise GeminiProtocolError("Gemini exceeded the per-turn tool limit.")
|
|
242
|
+
tool_calls[call_id] = {"id": call_id, "name": redact(event.get("title") or event.get("kind") or "Tool")[:512],
|
|
243
|
+
# ACP does not always expose raw tool arguments. Preserve
|
|
244
|
+
# its actual diff/location content without inventing args.
|
|
245
|
+
"input": redact(api.bounded_codex_interaction_value(event.get("input") if event.get("input") is not None else {
|
|
246
|
+
"acp_content": event.get("content") or [], "locations": event.get("locations") or []}))}
|
|
247
|
+
await api.append_event(session_id, "tool_started", {"run_id": run_id, "tool": tool_calls[call_id]})
|
|
248
|
+
if event.get("status") in {"completed", "failed"}:
|
|
249
|
+
content = event.get("output")
|
|
250
|
+
if content is None:
|
|
251
|
+
from gemini_content import tool_output
|
|
252
|
+
content = tool_output(api.bounded_codex_interaction_value(event.get("content") or []))
|
|
253
|
+
await api.append_event(session_id, "tool_finished", {"run_id": run_id,
|
|
254
|
+
"tool": {**tool_calls.pop(call_id), "output": redact(str(content))[:24000],
|
|
255
|
+
"is_error": event.get("status") == "failed"}})
|
|
256
|
+
finished_tools.add(call_id)
|
|
257
|
+
elif kind == "plan":
|
|
258
|
+
from gemini_content import plan_entries
|
|
259
|
+
entries = plan_entries(event.get("entries"))
|
|
260
|
+
if entries != last_plan:
|
|
261
|
+
await flush()
|
|
262
|
+
last_plan = entries
|
|
263
|
+
await api.append_event(session_id, "reasoning_summary", {
|
|
264
|
+
"run_id": run_id, "backend": "gemini", "phase": "plan",
|
|
265
|
+
"text": "\n".join(f"[{entry['status']}] {redact(entry['content'])}" for entry in entries),
|
|
266
|
+
**api.run_event_metadata(run_id)})
|
|
267
|
+
elif kind == "usage":
|
|
268
|
+
value = event.get("usage") or {}
|
|
269
|
+
if all(type(value.get(key)) is int and 0 <= value[key] <= 2**53 - 1 for key in ("used", "size")):
|
|
270
|
+
context_usage = {"used": value["used"], "size": value["size"]}
|
|
271
|
+
|
|
272
|
+
async def permission(params):
|
|
273
|
+
await bind_visible_identity()
|
|
274
|
+
return await ask_permission(api, session_id, run_id, redact(params))
|
|
275
|
+
|
|
276
|
+
client = GeminiACPClient(on_update=update, on_permission=permission)
|
|
277
|
+
|
|
278
|
+
async def cleanup():
|
|
279
|
+
try:
|
|
280
|
+
await client.close()
|
|
281
|
+
finally:
|
|
282
|
+
# Even a surviving process must lose its helper transport.
|
|
283
|
+
if broker:
|
|
284
|
+
await broker.close()
|
|
285
|
+
try:
|
|
286
|
+
cwd = str(Path(str(sess.get("cwd") or api.DEFAULT_CWD)).expanduser().resolve(strict=True))
|
|
287
|
+
if not Path(cwd).is_dir():
|
|
288
|
+
raise GeminiConfigurationError("The Gemini workspace must be an existing directory.")
|
|
289
|
+
provider_id = None if standalone_provider_context else sess.get("gemini_session_id") or sess.get("session_id")
|
|
290
|
+
if provider_id and sess.get("gemini_session_cwd") != cwd:
|
|
291
|
+
raise GeminiConfigurationError("Resume Gemini in the original workspace; create a new chat to change its directory.")
|
|
292
|
+
connection = api.PROVIDER_CONNECTION_STORE.for_session(sess)
|
|
293
|
+
if connection: private_values.append(connection["api_key"])
|
|
294
|
+
profile = api.PROVIDER_CONNECTION_STORE.root / ("gemini-runtime-" + connection["credential_id"]) if connection else None
|
|
295
|
+
env = build_environment(api.runner_env(), cwd=cwd, api_home=profile,
|
|
296
|
+
api_key=connection.get("api_key") if connection else None,
|
|
297
|
+
base_url=connection.get("base_url") if connection else None,
|
|
298
|
+
native_history=bool(sess.get("gemini_native_history")) and not standalone_provider_context)
|
|
299
|
+
selected_skill = _kwargs.get("provider_command")
|
|
300
|
+
if selected_skill is not None:
|
|
301
|
+
# Recheck immediately before launching, including after queueing.
|
|
302
|
+
snapshot, inventory = await api.discover_session_provider_commands(session_id, sess)
|
|
303
|
+
current = inventory.resolve(selected_skill.public["id"])
|
|
304
|
+
if snapshot["support"].get("available") is not True or current != selected_skill:
|
|
305
|
+
raise GeminiConfigurationError("The selected Gemini skill changed. Refresh skills and select it again.")
|
|
306
|
+
invocation = selected_skill.public["invocation"]
|
|
307
|
+
task = prompt[len(invocation):].lstrip() if api.provider_command_prompt_matches(prompt, invocation) else prompt
|
|
308
|
+
prompt = ("The user explicitly selected the installed Gemini skill "
|
|
309
|
+
+ json.dumps(selected_skill.native["name"]) + ". Use the native activate_skill tool to activate it, "
|
|
310
|
+
"then follow its instructions for the task below. If activation is unavailable or denied, "
|
|
311
|
+
"report that instead of pretending the skill ran. This selection grants no additional tool or chat permissions.\n\n"
|
|
312
|
+
+ task)
|
|
313
|
+
executable = sess.get("_gemini_executable") or resolve_executable(env)
|
|
314
|
+
if not executable:
|
|
315
|
+
raise GeminiConfigurationError("Gemini CLI is not installed on the server.")
|
|
316
|
+
mcp = []
|
|
317
|
+
permission_mode = effective_mode(sess)
|
|
318
|
+
command = [str(executable), "--acp", "--approval-mode", startup_mode(permission_mode)]
|
|
319
|
+
async with api.CROSS_CHAT_CAPABILITY_LOCK:
|
|
320
|
+
capabilities = [c for c in api.CROSS_CHAT_CAPABILITIES.values()
|
|
321
|
+
if c.get("source_session_id") == session_id and c.get("source_run_id") == run_id]
|
|
322
|
+
has_authority = bool(capabilities)
|
|
323
|
+
if has_authority:
|
|
324
|
+
async def execute(value, key):
|
|
325
|
+
try:
|
|
326
|
+
return await api.execute_provider_tool_once(session_id, run_id, value,
|
|
327
|
+
replay_key="gemini:" + key, backend="gemini", cursor_owner_token=owner)
|
|
328
|
+
except api.ProviderToolError as exc:
|
|
329
|
+
return str(exc), True
|
|
330
|
+
broker = CursorToolBroker(api.codex_provider_mcp_tool_definition(), execute)
|
|
331
|
+
tool_env = await broker.start()
|
|
332
|
+
from cursor_provider_mcp import ENV_SECRET
|
|
333
|
+
private_values.append(tool_env[ENV_SECRET])
|
|
334
|
+
name = "agentsdock_" + secrets.token_hex(24)
|
|
335
|
+
mcp = [{"name": name, "command": sys.executable,
|
|
336
|
+
"args": [str(Path(__file__).with_name("cursor_provider_mcp.py")), "--mcp"],
|
|
337
|
+
"env": [{"name": key, "value": value} for key, value in tool_env.items()]}]
|
|
338
|
+
# Preserve Gemini's native policy hierarchy. In particular, a CLI
|
|
339
|
+
# --policy allow could outrank the user's existing deny rule.
|
|
340
|
+
# Native permission requests go through the explicit approval UI;
|
|
341
|
+
# the broker separately enforces AgentsDock's run/task authority.
|
|
342
|
+
# No shell authority-file paths or credentials belong in the model
|
|
343
|
+
# prompt/native transcript. The broker binds those out of band.
|
|
344
|
+
instructions = api.PROVIDER_AUTHORITY_USAGE_INSTRUCTIONS.split("- Print-only provider fallback:", 1)[0]
|
|
345
|
+
prompt += ("\n\n[AgentsDock provider authority]\n" + instructions
|
|
346
|
+
+ api.CROSS_CHAT_DELIVERY_INSTRUCTIONS
|
|
347
|
+
+ f"\nUse the run-bound AgentsDock MCP tool from {name} for authorized helper operations. "
|
|
348
|
+
"There is no shell-helper fallback for this integration."
|
|
349
|
+
"\n[End AgentsDock provider authority]")
|
|
350
|
+
model = sess.get("model")
|
|
351
|
+
if connection:
|
|
352
|
+
from gemini_api import default_model
|
|
353
|
+
from provider_connections import require_model
|
|
354
|
+
# Re-resolve the immutable binding at execution, including queued
|
|
355
|
+
# runs. Never ignore its default or inherit a native-account model.
|
|
356
|
+
model = require_model({**connection, "model": default_model(connection)})
|
|
357
|
+
if model:
|
|
358
|
+
command += ["--model", str(model)]
|
|
359
|
+
|
|
360
|
+
async def started(proc):
|
|
361
|
+
bound, stop = await api.bind_active_turn(session_id, run_id, {
|
|
362
|
+
"proc": proc, "run_id": run_id, "backend": "gemini", "transport": "exec",
|
|
363
|
+
"pid": proc.pid, "pgid": proc.pid, "cwd": cwd, "argv": [str(executable), "--acp"],
|
|
364
|
+
"started_at": time.time(), "started_at_iso": api.now_iso(),
|
|
365
|
+
"gemini_mcp_owner_token": owner, "provider_turn_ready": False,
|
|
366
|
+
"provider_tools_ready": asyncio.Event(),
|
|
367
|
+
"gemini_cleanup": cleanup,
|
|
368
|
+
})
|
|
369
|
+
if not bound or stop:
|
|
370
|
+
raise asyncio.CancelledError()
|
|
371
|
+
|
|
372
|
+
await client.start(command, cwd=cwd, env=env, on_started=started)
|
|
373
|
+
from gemini_history import native_snapshot, snapshot_replay, verify_resume_snapshot
|
|
374
|
+
snapshot = await asyncio.to_thread(native_snapshot, env, cwd, provider_id) if provider_id else None
|
|
375
|
+
replay = snapshot_replay(snapshot) if snapshot is not None else None
|
|
376
|
+
opened = await client.open_session(cwd=cwd, session_id=provider_id, mcp_servers=mcp, replay=replay)
|
|
377
|
+
modes = available_modes(opened)
|
|
378
|
+
if not owns(api, session_id, run_id):
|
|
379
|
+
raise asyncio.CancelledError()
|
|
380
|
+
# Save only native capability evidence, never overwrite a newer user's
|
|
381
|
+
# next-turn mode choice with this run's immutable snapshot.
|
|
382
|
+
api.STORE.sessions[session_id]["gemini_permission_modes"] = modes
|
|
383
|
+
await api.STORE.save(durable=True)
|
|
384
|
+
await apply_mode(client, opened, permission_mode)
|
|
385
|
+
if not owns(api, session_id, run_id):
|
|
386
|
+
raise asyncio.CancelledError()
|
|
387
|
+
if api.ACTIVE[session_id].get("gemini_permission_mode") != permission_mode:
|
|
388
|
+
api.ACTIVE[session_id]["gemini_permission_mode"] = permission_mode
|
|
389
|
+
await api.append_event(session_id, "gemini_permission_mode", {
|
|
390
|
+
"run_id": run_id, "backend": "gemini", "mode": permission_mode})
|
|
391
|
+
if broker and not broker.definition_requested.is_set():
|
|
392
|
+
# Native Gemini refuses MCP discovery in an untrusted workspace.
|
|
393
|
+
# Never grant folder trust or weaken an existing MCP policy here.
|
|
394
|
+
# A durable route permits communication; it does not require every
|
|
395
|
+
# later ordinary question to use it. Admission supplies this bit
|
|
396
|
+
# from this turn's validated references/internal delivery purpose.
|
|
397
|
+
# The broker's authorization and native tool policies are unchanged.
|
|
398
|
+
if communication_required:
|
|
399
|
+
raise GeminiConfigurationError(communication_unavailable_message(cwd))
|
|
400
|
+
prompt += "\nThe native AgentsDock MCP tool was not loaded. Do not use shell helpers as a substitute; report unavailable helper operations instead."
|
|
401
|
+
if snapshot is not None:
|
|
402
|
+
verify_resume_snapshot(snapshot, await asyncio.to_thread(native_snapshot, env, cwd, provider_id))
|
|
403
|
+
if not connection:
|
|
404
|
+
MODEL_CATALOG["default"] = session_models(opened)
|
|
405
|
+
# session/new allocates a UUID before any resumable history exists.
|
|
406
|
+
# Commit new identities only after the native recorder has flushed.
|
|
407
|
+
# Existing identities must never be cleared/replaced after a failure.
|
|
408
|
+
if provider_id:
|
|
409
|
+
await api.persist_run_provider_session(session_id, run_id, "gemini", client.session_id,
|
|
410
|
+
cwd=cwd, standalone_provider_context=standalone_provider_context)
|
|
411
|
+
await api.mark_provider_turn_ready(session_id, run_id, client.session_id)
|
|
412
|
+
async with api.ACTIVE_LOCK:
|
|
413
|
+
active = api.ACTIVE.get(session_id) or {}
|
|
414
|
+
if active.get("run_id") == run_id:
|
|
415
|
+
active["provider_tools_ready"].set()
|
|
416
|
+
if not owns(api, session_id, run_id):
|
|
417
|
+
raise asyncio.CancelledError()
|
|
418
|
+
from gemini_content import attachments, usage
|
|
419
|
+
content = await asyncio.to_thread(attachments, api, session_id, list(_kwargs.get("file_ids") or []))
|
|
420
|
+
result = await client.prompt([{"type": "text", "text": prompt + api.session_prompt_addendum(sess)}, *content])
|
|
421
|
+
token_usage = usage(result.get("usage"))
|
|
422
|
+
stopped = result.get("stopReason") == "cancelled"
|
|
423
|
+
if result.get("stopReason") not in {"end_turn", "cancelled"}:
|
|
424
|
+
raise GeminiProtocolError("Gemini stopped before completing the turn (limit or refusal).")
|
|
425
|
+
if not stopped:
|
|
426
|
+
api.record_runtime_success("gemini")
|
|
427
|
+
except asyncio.CancelledError:
|
|
428
|
+
stopped = True
|
|
429
|
+
except (GeminiConfigurationError, GeminiProtocolError) as exc:
|
|
430
|
+
error = str(exc)
|
|
431
|
+
except Exception:
|
|
432
|
+
error = "Gemini could not complete this request. Check the server's Gemini installation and selected connection."
|
|
433
|
+
finally:
|
|
434
|
+
stopped = stopped or run_id in api.STOPPED_RUNS
|
|
435
|
+
# Flush exposed partial output even on interruption before retiring its
|
|
436
|
+
# owner; do not publish raw stderr or protocol payloads.
|
|
437
|
+
with suppress(Exception):
|
|
438
|
+
await flush()
|
|
439
|
+
try:
|
|
440
|
+
await api.join_task_despite_caller_cancellation(asyncio.create_task(cleanup()))
|
|
441
|
+
except Exception:
|
|
442
|
+
# Do not release the slot or report a successful terminal event if
|
|
443
|
+
# the owned provider process could not be reaped.
|
|
444
|
+
if await api.turn_slot_is_owned(session_id, run_id):
|
|
445
|
+
async with api.ACTIVE_LOCK:
|
|
446
|
+
active = api.ACTIVE.get(session_id) or {}
|
|
447
|
+
if active.get("run_id") == run_id:
|
|
448
|
+
active["gemini_cleanup_failed"] = True
|
|
449
|
+
# A restart must not erase an unconfirmed cleanup and
|
|
450
|
+
# allow new workspace edits. Without the live owned
|
|
451
|
+
# cleanup closure, recovery needs operator verification.
|
|
452
|
+
api.STORE.sessions[session_id]["gemini_cleanup_pending_run_id"] = run_id
|
|
453
|
+
with suppress(Exception):
|
|
454
|
+
await api.STORE.save(durable=True)
|
|
455
|
+
await api.append_event(session_id, "error", {"run_id": run_id, "backend": "gemini",
|
|
456
|
+
"message": "Gemini cleanup did not complete. Stop this chat before retrying."})
|
|
457
|
+
return
|
|
458
|
+
if not provider_id and client.session_id and env is not None:
|
|
459
|
+
# Never silently replace a conversation that exposed output or
|
|
460
|
+
# executed a tool, even if the recorder subsequently fails. Keeping
|
|
461
|
+
# its UUID makes the next attempt fail closed on missing history.
|
|
462
|
+
preserve_identity = bool(char_count or tool_calls or finished_tools or last_plan)
|
|
463
|
+
try:
|
|
464
|
+
from gemini_history import native_snapshot
|
|
465
|
+
history = await asyncio.to_thread(native_snapshot, env, cwd, client.session_id, missing_ok=True)
|
|
466
|
+
# The pinned CLI prunes sessions containing only user/context
|
|
467
|
+
# records on its next startup. An allocated file alone is not
|
|
468
|
+
# proof that this UUID can be resumed after a model failure.
|
|
469
|
+
if history is not None and any(row.get("type") == "gemini" for row in history["messages"]):
|
|
470
|
+
preserve_identity = True
|
|
471
|
+
elif not error and not stopped:
|
|
472
|
+
error = "Gemini did not save resumable history for this turn. Check its native history before retrying."
|
|
473
|
+
except Exception:
|
|
474
|
+
preserve_identity = True # Unreadable is not evidence of empty history.
|
|
475
|
+
error = "Gemini history could not be verified after this turn. Check the server's Gemini history before retrying."
|
|
476
|
+
if preserve_identity:
|
|
477
|
+
await api.persist_run_provider_session(session_id, run_id, "gemini", client.session_id,
|
|
478
|
+
cwd=cwd, standalone_provider_context=standalone_provider_context)
|
|
479
|
+
await api.clear_reasoning_summary_stream(session_id, run_id)
|
|
480
|
+
for call_id, tool in tool_calls.items():
|
|
481
|
+
if session_id in api.STORE.sessions and await api.turn_slot_is_owned(session_id, run_id):
|
|
482
|
+
await api.append_event(session_id, "tool_finished", {"run_id": run_id,
|
|
483
|
+
"tool": {**tool, "output": "Gemini tool did not complete.", "is_error": True}})
|
|
484
|
+
if error and not stopped and await api.turn_slot_is_owned(session_id, run_id):
|
|
485
|
+
await api.append_event(session_id, "error", {"run_id": run_id, "backend": "gemini", "message": error})
|
|
486
|
+
await api.clear_active_process(session_id, expected_run_id=run_id)
|
|
487
|
+
await api.finalize_owned_turn_finished(session_id, run_id, stopped=stopped, payload={
|
|
488
|
+
"run_id": run_id, "backend": "gemini", "exit_code": 1 if error else 0,
|
|
489
|
+
"is_error": bool(error and not stopped), "stopped": stopped,
|
|
490
|
+
"result_text": "\n\n".join(text_parts), **api.run_event_metadata(run_id),
|
|
491
|
+
**token_usage,
|
|
492
|
+
**({"gemini_context_usage": context_usage} if context_usage is not None else {}),
|
|
493
|
+
})
|