magpie-gemini-web 0.3.2 → 0.4.0
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/README.en.md +29 -5
- package/README.md +33 -5
- package/index.mjs +23 -6
- package/lib/backend.mjs +9 -5
- package/lib/task.mjs +87 -0
- package/lib/usage.mjs +118 -16
- package/native-task/LICENSE +21 -0
- package/native-task/README.md +87 -0
- package/native-task/bridge/gemini_native_bridge/__init__.py +3 -0
- package/native-task/bridge/gemini_native_bridge/__main__.py +235 -0
- package/native-task/bridge/gemini_native_bridge/auth.py +143 -0
- package/native-task/bridge/gemini_native_bridge/config.py +222 -0
- package/native-task/bridge/gemini_native_bridge/contract.py +283 -0
- package/native-task/bridge/gemini_native_bridge/native_auth_keychain.swift +111 -0
- package/native-task/bridge/gemini_native_bridge/oauth.py +154 -0
- package/native-task/bridge/gemini_native_bridge/protocol.py +363 -0
- package/native-task/bridge/gemini_native_bridge/server.py +441 -0
- package/native-task/bridge/gemini_native_bridge/state.py +806 -0
- package/native-task/bridge/gemini_native_bridge/transport.py +531 -0
- package/native-task/index.mjs +76 -0
- package/native-task/lib/runtime.mjs +189 -0
- package/native-task/pyproject.toml +18 -0
- package/native-task/requirements.txt +2 -0
- package/native-task/scripts/bridge.mjs +97 -0
- package/package.json +11 -3
- package/patches/argon.patch +231 -51
- package/scripts/backend-argon.mjs +1 -1
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""State management, persistence, session snapshots, and ticket lifecycle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import secrets
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import RuntimeConfig, atomic_write_file
|
|
14
|
+
from .contract import BridgeError, CompletionRequest, SESSION, canonical, invalid, json_loads
|
|
15
|
+
from .protocol import MODEL_ID
|
|
16
|
+
from .transport import NativeTask
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_tool_response(call_id: str, name: str, args: dict, created: int) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"id": f"chatcmpl-{secrets.token_hex(12)}",
|
|
22
|
+
"object": "chat.completion",
|
|
23
|
+
"created": created,
|
|
24
|
+
"model": MODEL_ID,
|
|
25
|
+
"choices": [{
|
|
26
|
+
"index": 0,
|
|
27
|
+
"message": {
|
|
28
|
+
"role": "assistant",
|
|
29
|
+
"content": None,
|
|
30
|
+
"tool_calls": [{
|
|
31
|
+
"id": call_id,
|
|
32
|
+
"type": "function",
|
|
33
|
+
"function": {
|
|
34
|
+
"name": name,
|
|
35
|
+
"arguments": canonical(args),
|
|
36
|
+
},
|
|
37
|
+
}],
|
|
38
|
+
},
|
|
39
|
+
"finish_reason": "tool_calls",
|
|
40
|
+
}],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_final_response(text: str, created: int) -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"id": f"chatcmpl-{secrets.token_hex(12)}",
|
|
47
|
+
"object": "chat.completion",
|
|
48
|
+
"created": created,
|
|
49
|
+
"model": MODEL_ID,
|
|
50
|
+
"choices": [{
|
|
51
|
+
"index": 0,
|
|
52
|
+
"message": {
|
|
53
|
+
"role": "assistant",
|
|
54
|
+
"content": text,
|
|
55
|
+
},
|
|
56
|
+
"finish_reason": "stop",
|
|
57
|
+
}],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Ticket:
|
|
62
|
+
def __init__(self, task_id: str):
|
|
63
|
+
self.task_id = task_id
|
|
64
|
+
self.event = threading.Event()
|
|
65
|
+
self.response: dict | None = None
|
|
66
|
+
self.error: BridgeError | None = None
|
|
67
|
+
|
|
68
|
+
def result(self, timeout: float | None = None) -> dict:
|
|
69
|
+
if not self.event.wait(timeout):
|
|
70
|
+
raise TimeoutError("Ticket timed out waiting for result.")
|
|
71
|
+
if self.error is not None:
|
|
72
|
+
raise self.error
|
|
73
|
+
if self.response is None:
|
|
74
|
+
raise BridgeError("empty_response", "Ticket finished without a response.", 409)
|
|
75
|
+
return self.response
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _TaskSink:
|
|
79
|
+
def __init__(self, manager: Manager, session_id: str, task_id: str):
|
|
80
|
+
self.manager = manager
|
|
81
|
+
self.session_id = session_id
|
|
82
|
+
self.task_id = task_id
|
|
83
|
+
|
|
84
|
+
def tool(self, name: str, arguments: dict) -> str:
|
|
85
|
+
return self.manager._on_sink_tool(self.session_id, self.task_id, name, arguments)
|
|
86
|
+
|
|
87
|
+
def complete(self, text: str) -> None:
|
|
88
|
+
self.manager._on_sink_complete(self.session_id, self.task_id, text)
|
|
89
|
+
|
|
90
|
+
def fail(self, error: BridgeError) -> None:
|
|
91
|
+
self.manager._on_sink_fail(self.session_id, self.task_id, error)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Manager:
|
|
95
|
+
"""Manages Task sessions, persistent snapshots, and ticket dispatch."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, config: RuntimeConfig, transport_factory=NativeTask):
|
|
98
|
+
self.config = config
|
|
99
|
+
self.transport_factory = transport_factory
|
|
100
|
+
self.sessions_dir = self.config.data_dir / "sessions"
|
|
101
|
+
self.sessions_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
self._lock = threading.RLock()
|
|
104
|
+
self._storage_failed = False
|
|
105
|
+
self._closed = False
|
|
106
|
+
|
|
107
|
+
self._active_task_id: str | None = None
|
|
108
|
+
self._active_session_id: str | None = None
|
|
109
|
+
self._active_digest: str | None = None
|
|
110
|
+
self._active_request: CompletionRequest | None = None
|
|
111
|
+
self._active_transport: Any = None
|
|
112
|
+
self._active_ticket: Ticket | None = None
|
|
113
|
+
self._task_start_time: float | None = None
|
|
114
|
+
self._pending_deadline: float | None = None
|
|
115
|
+
|
|
116
|
+
self._stop_watchdog = threading.Event()
|
|
117
|
+
self._watchdog_thread = threading.Thread(target=self._watchdog_loop, daemon=True, name="ManagerWatchdog")
|
|
118
|
+
self._recover_and_start()
|
|
119
|
+
|
|
120
|
+
def _snapshot_path(self, session_id: str) -> Path:
|
|
121
|
+
key = canonical([self.config.instance_id, session_id])
|
|
122
|
+
h = hashlib.sha256(key.encode()).hexdigest()
|
|
123
|
+
return self.sessions_dir / f"{h}.json"
|
|
124
|
+
|
|
125
|
+
def _clear_active(self):
|
|
126
|
+
self._active_task_id = None
|
|
127
|
+
self._active_session_id = None
|
|
128
|
+
self._active_digest = None
|
|
129
|
+
self._active_request = None
|
|
130
|
+
self._active_ticket = None
|
|
131
|
+
self._task_start_time = None
|
|
132
|
+
self._pending_deadline = None
|
|
133
|
+
self._active_transport = None
|
|
134
|
+
|
|
135
|
+
def _storage_fault(self, error: BridgeError) -> BridgeError:
|
|
136
|
+
self._storage_failed = True
|
|
137
|
+
if self._active_transport is not None:
|
|
138
|
+
try:
|
|
139
|
+
self._active_transport.stop()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
ticket = self._active_ticket
|
|
143
|
+
self._clear_active()
|
|
144
|
+
if ticket and not ticket.event.is_set():
|
|
145
|
+
ticket.error = error
|
|
146
|
+
ticket.event.set()
|
|
147
|
+
return error
|
|
148
|
+
|
|
149
|
+
def _validate_snapshot(self, data: Any, path: Path, expected_session: str | None = None) -> dict:
|
|
150
|
+
def need(condition):
|
|
151
|
+
if not condition:
|
|
152
|
+
raise BridgeError("state_corrupt", "Session journal failed validation.", 409)
|
|
153
|
+
|
|
154
|
+
def ident(value):
|
|
155
|
+
return isinstance(value, str) and SESSION.fullmatch(value) is not None
|
|
156
|
+
|
|
157
|
+
def error_record(value):
|
|
158
|
+
if value is not None:
|
|
159
|
+
need(isinstance(value, dict))
|
|
160
|
+
need(isinstance(value.get("code"), str) and bool(value["code"]))
|
|
161
|
+
need(isinstance(value.get("message"), str))
|
|
162
|
+
need(type(value.get("status")) is int and 400 <= value["status"] <= 499)
|
|
163
|
+
|
|
164
|
+
def tool_call(value):
|
|
165
|
+
need(isinstance(value, dict) and ident(value.get("id")) and value.get("type") == "function")
|
|
166
|
+
fn = value.get("function")
|
|
167
|
+
need(isinstance(fn, dict) and ident(fn.get("name")) and isinstance(fn.get("arguments"), str))
|
|
168
|
+
args = json_loads(fn["arguments"])
|
|
169
|
+
need(isinstance(args, dict))
|
|
170
|
+
return value["id"], fn["name"], args
|
|
171
|
+
|
|
172
|
+
need(isinstance(data, dict) and type(data.get("version")) is int and data["version"] == 1)
|
|
173
|
+
need(data.get("instance_id") == self.config.instance_id and ident(data.get("session_id")))
|
|
174
|
+
session = data["session_id"]
|
|
175
|
+
need(expected_session in (None, session) and self._snapshot_path(session) == path)
|
|
176
|
+
need(ident(data.get("task_id")) and ident(data.get("signature")))
|
|
177
|
+
need(type(data.get("calls")) is int and 0 <= data["calls"] <= self.config.max_tool_calls)
|
|
178
|
+
need(type(data.get("updated_at")) in (int, float) and math.isfinite(data["updated_at"]))
|
|
179
|
+
need(isinstance(data.get("history"), list) and bool(data["history"]))
|
|
180
|
+
for message in data["history"]:
|
|
181
|
+
need(isinstance(message, dict) and message.get("role") in {"system", "developer", "user", "assistant", "tool"})
|
|
182
|
+
need(isinstance(message.get("content"), str))
|
|
183
|
+
if message.get("role") == "tool":
|
|
184
|
+
need(ident(message.get("tool_call_id")))
|
|
185
|
+
if "tool_calls" in message:
|
|
186
|
+
need(message["role"] == "assistant" and isinstance(message["tool_calls"], list) and bool(message["tool_calls"]))
|
|
187
|
+
for call in message["tool_calls"]:
|
|
188
|
+
tool_call(call)
|
|
189
|
+
|
|
190
|
+
need(isinstance(data.get("requests"), dict) and 1 <= len(data["requests"]) <= 256)
|
|
191
|
+
current_records = []
|
|
192
|
+
current_tools = []
|
|
193
|
+
for request_id, record in data["requests"].items():
|
|
194
|
+
need(ident(request_id) and isinstance(record, dict) and ident(record.get("task_id")))
|
|
195
|
+
need("response" in record and "error" in record)
|
|
196
|
+
response, error = record["response"], record["error"]
|
|
197
|
+
need(response is None or error is None)
|
|
198
|
+
error_record(error)
|
|
199
|
+
if record["task_id"] == data["task_id"]:
|
|
200
|
+
current_records.append(record)
|
|
201
|
+
if response is None:
|
|
202
|
+
continue
|
|
203
|
+
need(isinstance(response, dict) and ident(response.get("id")) and response.get("object") == "chat.completion")
|
|
204
|
+
choices = response.get("choices")
|
|
205
|
+
need(isinstance(choices, list) and len(choices) == 1 and isinstance(choices[0], dict))
|
|
206
|
+
choice = choices[0]
|
|
207
|
+
need(type(choice.get("index")) is int and choice["index"] == 0)
|
|
208
|
+
message = choice.get("message")
|
|
209
|
+
need(isinstance(message, dict) and message.get("role") == "assistant")
|
|
210
|
+
if choice.get("finish_reason") == "tool_calls":
|
|
211
|
+
calls = message.get("tool_calls")
|
|
212
|
+
need(isinstance(calls, list) and len(calls) == 1)
|
|
213
|
+
details = tool_call(calls[0])
|
|
214
|
+
if record["task_id"] == data["task_id"]:
|
|
215
|
+
current_tools.append(details)
|
|
216
|
+
else:
|
|
217
|
+
need(choice.get("finish_reason") == "stop" and isinstance(message.get("content"), str) and bool(message["content"].strip()))
|
|
218
|
+
need(bool(current_records))
|
|
219
|
+
need("error" in data and "pending" in data)
|
|
220
|
+
error_record(data["error"])
|
|
221
|
+
status = data.get("status")
|
|
222
|
+
need(status in {"running", "waiting_for_tool", "complete", "failed", "abandoned", "interrupted"})
|
|
223
|
+
if status == "waiting_for_tool":
|
|
224
|
+
pending = data["pending"]
|
|
225
|
+
need(isinstance(pending, dict) and ident(pending.get("id")) and ident(pending.get("name")))
|
|
226
|
+
need(isinstance(pending.get("arguments"), dict))
|
|
227
|
+
need(type(pending.get("received_at")) in (int, float) and math.isfinite(pending["received_at"]))
|
|
228
|
+
last = data["history"][-1]
|
|
229
|
+
need(last.get("role") == "assistant" and len(last.get("tool_calls", [])) == 1)
|
|
230
|
+
details = (pending["id"], pending["name"], pending["arguments"])
|
|
231
|
+
need(tool_call(last["tool_calls"][0]) == details and details in current_tools)
|
|
232
|
+
else:
|
|
233
|
+
need(data["pending"] is None)
|
|
234
|
+
if status == "running":
|
|
235
|
+
need(any(r["response"] is None and r["error"] is None for r in current_records))
|
|
236
|
+
if status in {"running", "waiting_for_tool", "complete"}:
|
|
237
|
+
need(data["error"] is None)
|
|
238
|
+
else:
|
|
239
|
+
need(data["error"] is not None)
|
|
240
|
+
return data
|
|
241
|
+
|
|
242
|
+
def _load_snapshot(self, session_id: str) -> dict | None:
|
|
243
|
+
path = self._snapshot_path(session_id)
|
|
244
|
+
if not path.exists():
|
|
245
|
+
if self._active_session_id == session_id:
|
|
246
|
+
raise self._storage_fault(BridgeError("state_corrupt", "Active session snapshot disappeared.", 409))
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
raw_text = path.read_text(encoding="utf-8")
|
|
251
|
+
data = json_loads(raw_text)
|
|
252
|
+
return self._validate_snapshot(data, path, expected_session=session_id)
|
|
253
|
+
except BridgeError as be:
|
|
254
|
+
raise self._storage_fault(be)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise self._storage_fault(BridgeError("state_corrupt", "Session journal cannot be read or validated.", 409)) from e
|
|
257
|
+
|
|
258
|
+
def _save_snapshot(self, snapshot: dict):
|
|
259
|
+
if self._storage_failed:
|
|
260
|
+
raise BridgeError("state_storage_failed", "Storage is in a failed state.", 409)
|
|
261
|
+
|
|
262
|
+
session_id = snapshot["session_id"]
|
|
263
|
+
path = self._snapshot_path(session_id)
|
|
264
|
+
data = json.dumps(snapshot, indent=2).encode()
|
|
265
|
+
try:
|
|
266
|
+
atomic_write_file(path, data, mode=0o600)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
raise self._storage_fault(BridgeError("state_storage_failed", "Session journal could not be persisted.", 409)) from e
|
|
269
|
+
|
|
270
|
+
def _record_terminal_error(self, snapshot: dict, error: BridgeError, status: str):
|
|
271
|
+
snapshot["status"] = status
|
|
272
|
+
snapshot["pending"] = None
|
|
273
|
+
snapshot["error"] = {"code": error.code, "message": error.message, "status": error.status}
|
|
274
|
+
snapshot["updated_at"] = time.time()
|
|
275
|
+
for record in snapshot.get("requests", {}).values():
|
|
276
|
+
if record.get("task_id") == snapshot.get("task_id") and record.get("response") is None and record.get("error") is None:
|
|
277
|
+
record["error"] = dict(snapshot["error"])
|
|
278
|
+
|
|
279
|
+
def _recover_and_start(self):
|
|
280
|
+
with self._lock:
|
|
281
|
+
# 1. Validate all files and filename bindings first
|
|
282
|
+
validated = []
|
|
283
|
+
for p in self.sessions_dir.glob("*.json"):
|
|
284
|
+
try:
|
|
285
|
+
raw = p.read_text(encoding="utf-8")
|
|
286
|
+
data = json_loads(raw)
|
|
287
|
+
snap = self._validate_snapshot(data, p)
|
|
288
|
+
validated.append((p, snap))
|
|
289
|
+
except Exception as e:
|
|
290
|
+
self._storage_failed = True
|
|
291
|
+
raise BridgeError("state_corrupt", f"Corrupt snapshot found on startup: {p}", 409) from e
|
|
292
|
+
|
|
293
|
+
# 2. Record interruption on any active snapshot
|
|
294
|
+
for p, snap in validated:
|
|
295
|
+
if snap.get("status") in ("running", "waiting_for_tool"):
|
|
296
|
+
interr_err = BridgeError("interrupted", "Task interrupted on daemon restart.", 409)
|
|
297
|
+
self._record_terminal_error(snap, interr_err, "interrupted")
|
|
298
|
+
atomic_write_file(p, json.dumps(snap, indent=2).encode(), mode=0o600)
|
|
299
|
+
|
|
300
|
+
self._watchdog_thread.start()
|
|
301
|
+
|
|
302
|
+
def submit(self, session_id: str, request: CompletionRequest) -> Ticket:
|
|
303
|
+
if not isinstance(session_id, str) or not SESSION.fullmatch(session_id):
|
|
304
|
+
raise invalid("invalid_session_id", "Session ID must be 1-256 non-whitespace ASCII characters.")
|
|
305
|
+
|
|
306
|
+
with self._lock:
|
|
307
|
+
if self._closed:
|
|
308
|
+
raise BridgeError("service_stopped", "Bridge service is closed.", 409)
|
|
309
|
+
if self._storage_failed:
|
|
310
|
+
raise BridgeError("state_storage_failed", "Storage is in a failed state.", 409)
|
|
311
|
+
|
|
312
|
+
# Enforce total deadline of existing active task if any
|
|
313
|
+
now = time.monotonic()
|
|
314
|
+
if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
|
|
315
|
+
if self._active_session_id and self._active_task_id:
|
|
316
|
+
err = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
|
|
317
|
+
self._on_sink_fail(self._active_session_id, self._active_task_id, err)
|
|
318
|
+
|
|
319
|
+
snapshot = self._load_snapshot(session_id)
|
|
320
|
+
|
|
321
|
+
if snapshot is None:
|
|
322
|
+
# New session
|
|
323
|
+
if request.messages[-1]["role"] == "tool":
|
|
324
|
+
raise BridgeError("unknown_task", "Cannot submit a tool result to an unknown session.", 409)
|
|
325
|
+
if request.messages[-1]["role"] != "user":
|
|
326
|
+
raise invalid("invalid_initial_message", "A new session must end in a user message.")
|
|
327
|
+
|
|
328
|
+
existing_count = len(list(self.sessions_dir.glob("*.json")))
|
|
329
|
+
if existing_count >= 128:
|
|
330
|
+
raise BridgeError("session_capacity", "Maximum stored sessions reached (128).", 409)
|
|
331
|
+
|
|
332
|
+
if self._active_task_id is not None:
|
|
333
|
+
raise BridgeError("concurrency_limit", "Another native task is currently active.", 409)
|
|
334
|
+
|
|
335
|
+
task_id = "task_" + secrets.token_hex(12)
|
|
336
|
+
snapshot = {
|
|
337
|
+
"version": 1,
|
|
338
|
+
"session_id": session_id,
|
|
339
|
+
"instance_id": self.config.instance_id,
|
|
340
|
+
"task_id": task_id,
|
|
341
|
+
"status": "running",
|
|
342
|
+
"signature": request.signature,
|
|
343
|
+
"history": request.messages,
|
|
344
|
+
"pending": None,
|
|
345
|
+
"calls": 0,
|
|
346
|
+
"error": None,
|
|
347
|
+
"requests": {
|
|
348
|
+
request.digest: {
|
|
349
|
+
"task_id": task_id,
|
|
350
|
+
"response": None,
|
|
351
|
+
"error": None,
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
"updated_at": time.time(),
|
|
355
|
+
}
|
|
356
|
+
self._save_snapshot(snapshot)
|
|
357
|
+
|
|
358
|
+
ticket = Ticket(task_id)
|
|
359
|
+
self._active_task_id = task_id
|
|
360
|
+
self._active_session_id = session_id
|
|
361
|
+
self._active_digest = request.digest
|
|
362
|
+
self._active_request = request
|
|
363
|
+
self._active_ticket = ticket
|
|
364
|
+
self._task_start_time = time.monotonic()
|
|
365
|
+
self._pending_deadline = None
|
|
366
|
+
|
|
367
|
+
sink = _TaskSink(self, session_id, task_id)
|
|
368
|
+
try:
|
|
369
|
+
self._active_transport = self.transport_factory(self.config, request, sink)
|
|
370
|
+
self._active_transport.start()
|
|
371
|
+
except Exception as e:
|
|
372
|
+
err = BridgeError("startup_failed", "Failed to start native transport.", 409)
|
|
373
|
+
self._on_sink_fail(session_id, task_id, err)
|
|
374
|
+
raise err from e
|
|
375
|
+
|
|
376
|
+
return ticket
|
|
377
|
+
|
|
378
|
+
# Existing session
|
|
379
|
+
if len(snapshot.get("requests", {})) >= 256 and request.digest not in snapshot.get("requests", {}):
|
|
380
|
+
raise BridgeError("session_capacity", "Session request capacity reached (256). Open a new session.", 409)
|
|
381
|
+
|
|
382
|
+
# Check cached request
|
|
383
|
+
req_record = snapshot.get("requests", {}).get(request.digest)
|
|
384
|
+
if req_record is not None:
|
|
385
|
+
if req_record.get("response") is not None:
|
|
386
|
+
cached_resp = req_record["response"]
|
|
387
|
+
finish_reason = cached_resp["choices"][0]["finish_reason"]
|
|
388
|
+
if finish_reason == "tool_calls":
|
|
389
|
+
call_id = cached_resp["choices"][0]["message"]["tool_calls"][0]["id"]
|
|
390
|
+
# Enforce deadlines before returning cached tool call
|
|
391
|
+
if self._pending_deadline is not None and time.monotonic() >= self._pending_deadline:
|
|
392
|
+
err = BridgeError("tool_timeout", "Tool deadline expired.", 409)
|
|
393
|
+
self._on_sink_fail(session_id, snapshot["task_id"], err)
|
|
394
|
+
raise err
|
|
395
|
+
|
|
396
|
+
if (
|
|
397
|
+
snapshot["status"] == "waiting_for_tool"
|
|
398
|
+
and snapshot["pending"]
|
|
399
|
+
and snapshot["pending"]["id"] == call_id
|
|
400
|
+
):
|
|
401
|
+
t = Ticket(req_record["task_id"])
|
|
402
|
+
t.response = cached_resp
|
|
403
|
+
t.event.set()
|
|
404
|
+
return t
|
|
405
|
+
else:
|
|
406
|
+
raise BridgeError(
|
|
407
|
+
"stale_tool_phase",
|
|
408
|
+
"Cached tool-call response cannot be replayed after the tool phase has advanced or ended.",
|
|
409
|
+
410,
|
|
410
|
+
)
|
|
411
|
+
else:
|
|
412
|
+
t = Ticket(req_record["task_id"])
|
|
413
|
+
t.response = cached_resp
|
|
414
|
+
t.event.set()
|
|
415
|
+
return t
|
|
416
|
+
|
|
417
|
+
elif req_record.get("error") is not None:
|
|
418
|
+
err = req_record["error"]
|
|
419
|
+
raise BridgeError(err["code"], err["message"], err.get("status", 409))
|
|
420
|
+
else:
|
|
421
|
+
raise BridgeError("request_in_progress", "An identical request is already in progress.", 409)
|
|
422
|
+
|
|
423
|
+
# Check if this is a tool result
|
|
424
|
+
if request.messages[-1]["role"] == "tool":
|
|
425
|
+
if snapshot["status"] != "waiting_for_tool" or not snapshot["pending"]:
|
|
426
|
+
raise BridgeError("unexpected_tool_result", "Session is not waiting for a tool result.", 409)
|
|
427
|
+
|
|
428
|
+
# Deadline check before accepting tool result
|
|
429
|
+
if self._pending_deadline is not None and time.monotonic() >= self._pending_deadline:
|
|
430
|
+
err = BridgeError("tool_timeout", f"Tool response exceeded deadline ({self.config.tool_timeout}s).", 409)
|
|
431
|
+
self._on_sink_fail(session_id, snapshot["task_id"], err)
|
|
432
|
+
raise err
|
|
433
|
+
|
|
434
|
+
if request.signature != snapshot["signature"]:
|
|
435
|
+
raise BridgeError("signature_mismatch", "Request signature changed during active task.", 409)
|
|
436
|
+
if request.messages[:-1] != snapshot["history"]:
|
|
437
|
+
raise BridgeError("history_mismatch", "History prefix does not match active task history.", 409)
|
|
438
|
+
|
|
439
|
+
tool_msg = request.messages[-1]
|
|
440
|
+
if tool_msg["tool_call_id"] != snapshot["pending"]["id"]:
|
|
441
|
+
raise BridgeError(
|
|
442
|
+
"tool_call_id_mismatch",
|
|
443
|
+
"Submitted tool result ID does not match the pending call.",
|
|
444
|
+
409,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
if (
|
|
448
|
+
self._active_task_id != snapshot["task_id"]
|
|
449
|
+
or self._active_session_id != session_id
|
|
450
|
+
or self._active_transport is None
|
|
451
|
+
):
|
|
452
|
+
err = BridgeError("interrupted", "Task was interrupted and cannot be resumed.", 409)
|
|
453
|
+
self._on_sink_fail(session_id, snapshot["task_id"], err)
|
|
454
|
+
raise err
|
|
455
|
+
|
|
456
|
+
pending_call_id = snapshot["pending"]["id"]
|
|
457
|
+
tool_text = tool_msg["content"]
|
|
458
|
+
|
|
459
|
+
snapshot["history"].append(tool_msg)
|
|
460
|
+
snapshot["pending"] = None
|
|
461
|
+
snapshot["status"] = "running"
|
|
462
|
+
snapshot["updated_at"] = time.time()
|
|
463
|
+
snapshot["requests"][request.digest] = {
|
|
464
|
+
"task_id": snapshot["task_id"],
|
|
465
|
+
"response": None,
|
|
466
|
+
"error": None,
|
|
467
|
+
}
|
|
468
|
+
self._save_snapshot(snapshot)
|
|
469
|
+
|
|
470
|
+
ticket = Ticket(snapshot["task_id"])
|
|
471
|
+
self._active_ticket = ticket
|
|
472
|
+
self._active_digest = request.digest
|
|
473
|
+
self._pending_deadline = None
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
self._active_transport.submit_result(pending_call_id, tool_text)
|
|
477
|
+
except Exception as e:
|
|
478
|
+
err = BridgeError("transport_failure", "Failed to submit tool result to transport.", 409)
|
|
479
|
+
self._on_sink_fail(session_id, snapshot["task_id"], err)
|
|
480
|
+
raise err from e
|
|
481
|
+
|
|
482
|
+
return ticket
|
|
483
|
+
|
|
484
|
+
else:
|
|
485
|
+
# New user turn
|
|
486
|
+
if snapshot["status"] in ("running", "waiting_for_tool"):
|
|
487
|
+
raise BridgeError("session_busy", "Session is currently busy with an active task.", 409)
|
|
488
|
+
|
|
489
|
+
stored_history = snapshot["history"]
|
|
490
|
+
incoming_history = request.messages
|
|
491
|
+
if len(incoming_history) <= len(stored_history) or incoming_history[:len(stored_history)] != stored_history:
|
|
492
|
+
raise BridgeError("history_diverged", "Incoming history does not match stored history prefix; open a new session.", 409)
|
|
493
|
+
if incoming_history[-1]["role"] != "user":
|
|
494
|
+
raise invalid("invalid_continuation", "Appended continuation message must be a user turn.")
|
|
495
|
+
|
|
496
|
+
if self._active_task_id is not None:
|
|
497
|
+
raise BridgeError("concurrency_limit", "Another native task is currently active.", 409)
|
|
498
|
+
|
|
499
|
+
new_task_id = "task_" + secrets.token_hex(12)
|
|
500
|
+
snapshot["task_id"] = new_task_id
|
|
501
|
+
snapshot["status"] = "running"
|
|
502
|
+
snapshot["signature"] = request.signature
|
|
503
|
+
snapshot["history"] = request.messages
|
|
504
|
+
snapshot["pending"] = None
|
|
505
|
+
snapshot["error"] = None
|
|
506
|
+
snapshot["calls"] = 0
|
|
507
|
+
snapshot["updated_at"] = time.time()
|
|
508
|
+
snapshot["requests"][request.digest] = {
|
|
509
|
+
"task_id": new_task_id,
|
|
510
|
+
"response": None,
|
|
511
|
+
"error": None,
|
|
512
|
+
}
|
|
513
|
+
self._save_snapshot(snapshot)
|
|
514
|
+
|
|
515
|
+
ticket = Ticket(new_task_id)
|
|
516
|
+
self._active_task_id = new_task_id
|
|
517
|
+
self._active_session_id = session_id
|
|
518
|
+
self._active_digest = request.digest
|
|
519
|
+
self._active_request = request
|
|
520
|
+
self._active_ticket = ticket
|
|
521
|
+
self._task_start_time = time.monotonic()
|
|
522
|
+
self._pending_deadline = None
|
|
523
|
+
|
|
524
|
+
sink = _TaskSink(self, session_id, new_task_id)
|
|
525
|
+
try:
|
|
526
|
+
self._active_transport = self.transport_factory(self.config, request, sink)
|
|
527
|
+
self._active_transport.start()
|
|
528
|
+
except Exception as e:
|
|
529
|
+
err = BridgeError("startup_failed", "Failed to start native transport.", 409)
|
|
530
|
+
self._on_sink_fail(session_id, new_task_id, err)
|
|
531
|
+
raise err from e
|
|
532
|
+
|
|
533
|
+
return ticket
|
|
534
|
+
|
|
535
|
+
def _on_sink_tool(self, session_id: str, task_id: str, name: str, arguments: dict) -> str:
|
|
536
|
+
with self._lock:
|
|
537
|
+
# 1. Stale callback check
|
|
538
|
+
if self._closed or task_id != self._active_task_id or session_id != self._active_session_id:
|
|
539
|
+
return ""
|
|
540
|
+
|
|
541
|
+
snapshot = self._load_snapshot(session_id)
|
|
542
|
+
if snapshot is None or snapshot["task_id"] != task_id:
|
|
543
|
+
return ""
|
|
544
|
+
|
|
545
|
+
# Check deadline before tool publication
|
|
546
|
+
now = time.monotonic()
|
|
547
|
+
if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
|
|
548
|
+
err = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
|
|
549
|
+
self._on_sink_fail(session_id, task_id, err)
|
|
550
|
+
raise err
|
|
551
|
+
|
|
552
|
+
# 2. Status check
|
|
553
|
+
if snapshot["status"] != "running" or snapshot.get("pending") is not None:
|
|
554
|
+
error = BridgeError("unsupported_parallel_call", "A tool is already pending.", 409)
|
|
555
|
+
self._on_sink_fail(session_id, task_id, error)
|
|
556
|
+
raise error
|
|
557
|
+
|
|
558
|
+
# 3. Active catalog validation
|
|
559
|
+
if self._active_request is None:
|
|
560
|
+
error = BridgeError("missing_request", "Active request missing.", 409)
|
|
561
|
+
self._on_sink_fail(session_id, task_id, error)
|
|
562
|
+
raise error
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
self._active_request.active_catalog.validate(name, arguments)
|
|
566
|
+
except Exception:
|
|
567
|
+
err = BridgeError("invalid_upstream_arguments", "The upstream tool arguments do not satisfy the supplied JSON Schema.", 409)
|
|
568
|
+
self._on_sink_fail(session_id, task_id, err)
|
|
569
|
+
raise err
|
|
570
|
+
|
|
571
|
+
# 4. Capacity check
|
|
572
|
+
if len(snapshot.get("requests", {})) >= 256:
|
|
573
|
+
error = BridgeError("session_capacity", "Open a new session before another tool phase.", 409)
|
|
574
|
+
self._on_sink_fail(session_id, task_id, error)
|
|
575
|
+
raise error
|
|
576
|
+
|
|
577
|
+
snapshot["calls"] += 1
|
|
578
|
+
if snapshot["calls"] > self.config.max_tool_calls:
|
|
579
|
+
err = BridgeError("max_tool_calls_exceeded", f"Exceeded maximum tool calls ({self.config.max_tool_calls}).", 409)
|
|
580
|
+
self._on_sink_fail(session_id, task_id, err)
|
|
581
|
+
raise err
|
|
582
|
+
|
|
583
|
+
call_id = "call_" + secrets.token_hex(12)
|
|
584
|
+
resp = make_tool_response(call_id, name, arguments, int(time.time()))
|
|
585
|
+
|
|
586
|
+
snapshot["history"].append({
|
|
587
|
+
"role": "assistant",
|
|
588
|
+
"content": "",
|
|
589
|
+
"tool_calls": resp["choices"][0]["message"]["tool_calls"],
|
|
590
|
+
})
|
|
591
|
+
snapshot["pending"] = {
|
|
592
|
+
"id": call_id,
|
|
593
|
+
"name": name,
|
|
594
|
+
"arguments": arguments,
|
|
595
|
+
"received_at": time.time(),
|
|
596
|
+
}
|
|
597
|
+
snapshot["status"] = "waiting_for_tool"
|
|
598
|
+
snapshot["updated_at"] = time.time()
|
|
599
|
+
|
|
600
|
+
digest = self._active_digest
|
|
601
|
+
if digest and digest in snapshot["requests"]:
|
|
602
|
+
snapshot["requests"][digest]["response"] = resp
|
|
603
|
+
|
|
604
|
+
# Save state BEFORE publishing ticket!
|
|
605
|
+
self._save_snapshot(snapshot)
|
|
606
|
+
|
|
607
|
+
self._pending_deadline = time.monotonic() + self.config.tool_timeout
|
|
608
|
+
|
|
609
|
+
ticket = self._active_ticket
|
|
610
|
+
if ticket and not ticket.event.is_set():
|
|
611
|
+
ticket.response = resp
|
|
612
|
+
ticket.event.set()
|
|
613
|
+
|
|
614
|
+
return call_id
|
|
615
|
+
|
|
616
|
+
def _on_sink_complete(self, session_id: str, task_id: str, text: str) -> None:
|
|
617
|
+
with self._lock:
|
|
618
|
+
if self._closed or task_id != self._active_task_id or session_id != self._active_session_id:
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
snapshot = self._load_snapshot(session_id)
|
|
622
|
+
if snapshot is None or snapshot["task_id"] != task_id:
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
now = time.monotonic()
|
|
626
|
+
if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
|
|
627
|
+
error = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
|
|
628
|
+
self._on_sink_fail(session_id, task_id, error)
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
if snapshot["status"] != "running" or snapshot.get("pending") is not None or not text or not text.strip():
|
|
632
|
+
error = BridgeError("incomplete_task", "Invalid completion state.", 409)
|
|
633
|
+
self._on_sink_fail(session_id, task_id, error)
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
resp = make_final_response(text, int(time.time()))
|
|
637
|
+
snapshot["history"].append(resp["choices"][0]["message"])
|
|
638
|
+
snapshot["status"] = "complete"
|
|
639
|
+
snapshot["pending"] = None
|
|
640
|
+
snapshot["error"] = None
|
|
641
|
+
snapshot["updated_at"] = time.time()
|
|
642
|
+
|
|
643
|
+
digest = self._active_digest
|
|
644
|
+
if digest and digest in snapshot["requests"]:
|
|
645
|
+
snapshot["requests"][digest]["response"] = resp
|
|
646
|
+
|
|
647
|
+
# Save BEFORE publishing
|
|
648
|
+
self._save_snapshot(snapshot)
|
|
649
|
+
|
|
650
|
+
ticket = self._active_ticket
|
|
651
|
+
self._clear_active()
|
|
652
|
+
|
|
653
|
+
if ticket and not ticket.event.is_set():
|
|
654
|
+
ticket.response = resp
|
|
655
|
+
ticket.event.set()
|
|
656
|
+
|
|
657
|
+
def _on_sink_fail(self, session_id: str, task_id: str, error: BridgeError) -> None:
|
|
658
|
+
with self._lock:
|
|
659
|
+
if task_id != self._active_task_id or session_id != self._active_session_id:
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
snapshot = None
|
|
663
|
+
try:
|
|
664
|
+
snapshot = self._load_snapshot(session_id)
|
|
665
|
+
except Exception:
|
|
666
|
+
pass
|
|
667
|
+
|
|
668
|
+
if snapshot is not None and snapshot["task_id"] == task_id:
|
|
669
|
+
if snapshot["status"] != "complete":
|
|
670
|
+
self._record_terminal_error(snapshot, error, "failed")
|
|
671
|
+
try:
|
|
672
|
+
self._save_snapshot(snapshot)
|
|
673
|
+
except Exception:
|
|
674
|
+
pass
|
|
675
|
+
|
|
676
|
+
if self._active_transport is not None:
|
|
677
|
+
try:
|
|
678
|
+
self._active_transport.stop()
|
|
679
|
+
except Exception:
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
ticket = self._active_ticket
|
|
683
|
+
self._clear_active()
|
|
684
|
+
|
|
685
|
+
if ticket and not ticket.event.is_set():
|
|
686
|
+
ticket.error = error
|
|
687
|
+
ticket.event.set()
|
|
688
|
+
|
|
689
|
+
def abandon_delivery(self, session_id: str, task_id: str, request_digest: str):
|
|
690
|
+
"""Conditionally abandon active delivery if session, task, and digest match current request."""
|
|
691
|
+
with self._lock:
|
|
692
|
+
if (
|
|
693
|
+
self._active_session_id == session_id
|
|
694
|
+
and self._active_task_id == task_id
|
|
695
|
+
and self._active_digest == request_digest
|
|
696
|
+
):
|
|
697
|
+
self.cancel(session_id)
|
|
698
|
+
|
|
699
|
+
def status(self, session_id: str) -> dict:
|
|
700
|
+
if not isinstance(session_id, str) or not SESSION.fullmatch(session_id):
|
|
701
|
+
raise invalid("invalid_session_id", "Session ID must be 1-256 non-whitespace ASCII characters.")
|
|
702
|
+
|
|
703
|
+
with self._lock:
|
|
704
|
+
snap = self._load_snapshot(session_id)
|
|
705
|
+
if snap is None:
|
|
706
|
+
raise BridgeError("session_not_found", "Session not found.", 404)
|
|
707
|
+
return {
|
|
708
|
+
"session_id": snap["session_id"],
|
|
709
|
+
"instance_id": self.config.instance_id,
|
|
710
|
+
"status": snap["status"],
|
|
711
|
+
"task_id": snap["task_id"],
|
|
712
|
+
"calls": snap.get("calls", 0),
|
|
713
|
+
"pending_tool": snap["pending"]["id"] if snap.get("pending") else None,
|
|
714
|
+
"pending_tool_name": snap["pending"]["name"] if snap.get("pending") else None,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
def cancel(self, session_id: str) -> dict:
|
|
718
|
+
if not isinstance(session_id, str) or not SESSION.fullmatch(session_id):
|
|
719
|
+
raise invalid("invalid_session_id", "Session ID must be 1-256 non-whitespace ASCII characters.")
|
|
720
|
+
|
|
721
|
+
with self._lock:
|
|
722
|
+
snap = self._load_snapshot(session_id)
|
|
723
|
+
if snap is None:
|
|
724
|
+
raise BridgeError("session_not_found", "Session not found.", 404)
|
|
725
|
+
|
|
726
|
+
task_id = snap["task_id"]
|
|
727
|
+
if snap["status"] in ("complete", "failed", "abandoned", "interrupted"):
|
|
728
|
+
return {"status": snap["status"], "native_cancel_confirmed": False}
|
|
729
|
+
|
|
730
|
+
cancel_err = BridgeError("cancelled", "Task locally abandoned.", 409)
|
|
731
|
+
ticket = self._active_ticket if self._active_task_id == task_id else None
|
|
732
|
+
transport = self._active_transport if self._active_task_id == task_id else None
|
|
733
|
+
self._record_terminal_error(snap, cancel_err, "abandoned")
|
|
734
|
+
try:
|
|
735
|
+
self._save_snapshot(snap)
|
|
736
|
+
finally:
|
|
737
|
+
if transport is not None:
|
|
738
|
+
transport.stop()
|
|
739
|
+
if self._active_task_id == task_id:
|
|
740
|
+
self._clear_active()
|
|
741
|
+
if ticket and not ticket.event.is_set():
|
|
742
|
+
ticket.error = cancel_err
|
|
743
|
+
ticket.event.set()
|
|
744
|
+
return {"status": "abandoned", "native_cancel_confirmed": False}
|
|
745
|
+
|
|
746
|
+
def close(self) -> None:
|
|
747
|
+
with self._lock:
|
|
748
|
+
self._closed = True
|
|
749
|
+
|
|
750
|
+
self._stop_watchdog.set()
|
|
751
|
+
if self._watchdog_thread.is_alive():
|
|
752
|
+
self._watchdog_thread.join(timeout=2.0)
|
|
753
|
+
|
|
754
|
+
with self._lock:
|
|
755
|
+
if self._active_transport is not None:
|
|
756
|
+
try:
|
|
757
|
+
self._active_transport.stop()
|
|
758
|
+
except Exception:
|
|
759
|
+
pass
|
|
760
|
+
|
|
761
|
+
if self._active_session_id and self._active_task_id:
|
|
762
|
+
snap = None
|
|
763
|
+
try:
|
|
764
|
+
snap = self._load_snapshot(self._active_session_id)
|
|
765
|
+
except Exception:
|
|
766
|
+
pass
|
|
767
|
+
if snap and snap["status"] in ("running", "waiting_for_tool"):
|
|
768
|
+
close_err = BridgeError("service_stopped", "Bridge service closed.", 409)
|
|
769
|
+
self._record_terminal_error(snap, close_err, "abandoned")
|
|
770
|
+
try:
|
|
771
|
+
self._save_snapshot(snap)
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
|
|
775
|
+
ticket = self._active_ticket
|
|
776
|
+
self._clear_active()
|
|
777
|
+
|
|
778
|
+
if ticket and not ticket.event.is_set():
|
|
779
|
+
ticket.error = BridgeError("service_stopped", "Bridge service closed.", 409)
|
|
780
|
+
ticket.event.set()
|
|
781
|
+
|
|
782
|
+
def _watchdog_loop(self):
|
|
783
|
+
while not self._stop_watchdog.wait(0.2):
|
|
784
|
+
with self._lock:
|
|
785
|
+
if self._closed or self._storage_failed:
|
|
786
|
+
continue
|
|
787
|
+
if self._active_task_id is None:
|
|
788
|
+
continue
|
|
789
|
+
task_id = self._active_task_id
|
|
790
|
+
session_id = self._active_session_id
|
|
791
|
+
if not session_id:
|
|
792
|
+
continue
|
|
793
|
+
|
|
794
|
+
now = time.monotonic()
|
|
795
|
+
if self._task_start_time is not None and now - self._task_start_time >= self.config.task_timeout:
|
|
796
|
+
err = BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
|
|
797
|
+
self._on_sink_fail(session_id, task_id, err)
|
|
798
|
+
continue
|
|
799
|
+
|
|
800
|
+
if self._pending_deadline is not None and now >= self._pending_deadline:
|
|
801
|
+
err = BridgeError(
|
|
802
|
+
"tool_timeout",
|
|
803
|
+
f"Tool response exceeded deadline ({self.config.tool_timeout}s).",
|
|
804
|
+
409,
|
|
805
|
+
)
|
|
806
|
+
self._on_sink_fail(session_id, task_id, err)
|