@agentsdock/server 1.0.7-beta.5 → 1.0.8
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.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
package/server/side_questions.py
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Native side questions, durable side chats and request ownership.
|
|
2
2
|
|
|
3
3
|
Nothing starts on import. This module has no access to the main turn, queue,
|
|
4
|
-
goal, provider authority, provider history import
|
|
4
|
+
goal, provider authority, or provider history import. Side-chat persistence
|
|
5
|
+
is separate from the main conversation's event history.
|
|
5
6
|
"""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import asyncio
|
|
9
|
-
from contextlib import suppress
|
|
10
|
+
from contextlib import suppress, closing
|
|
10
11
|
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
11
13
|
import hashlib
|
|
12
14
|
import json
|
|
13
15
|
import os
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
import re
|
|
16
18
|
import signal
|
|
19
|
+
import sqlite3
|
|
17
20
|
import stat
|
|
18
21
|
import tempfile
|
|
19
22
|
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from copy import deepcopy
|
|
20
25
|
|
|
21
26
|
from fastapi import APIRouter, HTTPException, Request
|
|
22
27
|
from fastapi.responses import JSONResponse
|
|
@@ -29,7 +34,6 @@ MAX_REQUEST_BYTES = 512 * 1024
|
|
|
29
34
|
MAX_CONTEXT_CHARS = 60000
|
|
30
35
|
MAX_LOG_BYTES = 4 * 1024 * 1024
|
|
31
36
|
MAX_OUTPUT_BYTES = 1024 * 1024
|
|
32
|
-
REQUEST_TIMEOUT_SECONDS = 150
|
|
33
37
|
RECEIPT_TTL_SECONDS = 600
|
|
34
38
|
IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}\Z")
|
|
35
39
|
SYSTEM_PROMPT = (
|
|
@@ -53,7 +57,7 @@ class SideQuestionError(Exception):
|
|
|
53
57
|
|
|
54
58
|
def capability():
|
|
55
59
|
return {"available": True, "version": 2, "native_context": True, "backends": ["codex", "claude"],
|
|
56
|
-
"max_question_chars": MAX_QUESTION_CHARS}
|
|
60
|
+
"max_question_chars": MAX_QUESTION_CHARS, "sync": True}
|
|
57
61
|
|
|
58
62
|
|
|
59
63
|
def validate_history(value) -> tuple[tuple[str, str], ...]:
|
|
@@ -215,7 +219,7 @@ async def terminate_isolated_process(proc, *, force_group: bool = False):
|
|
|
215
219
|
|
|
216
220
|
|
|
217
221
|
async def run_isolated_command(command, *, prompt: str, cwd: str, env: dict,
|
|
218
|
-
timeout: float =
|
|
222
|
+
timeout: float | None = None) -> str:
|
|
219
223
|
"""Own only this fresh process group, including cancellation during spawn."""
|
|
220
224
|
spawn = asyncio.create_task(asyncio.create_subprocess_exec(
|
|
221
225
|
*command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
|
|
@@ -341,12 +345,25 @@ NATIVE_IDLE_SECONDS = 30 * 60
|
|
|
341
345
|
|
|
342
346
|
|
|
343
347
|
class SideQuestions:
|
|
344
|
-
def __init__(self, answer=None, *, native_factory=None):
|
|
348
|
+
def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None):
|
|
345
349
|
self.answer = answer
|
|
346
350
|
self.native_factory = native_factory
|
|
351
|
+
self.admission_check = admission_check
|
|
347
352
|
self.receipts: dict[tuple[str, str, str], _Receipt] = {}
|
|
348
353
|
self.conversations: dict[tuple[str, str, str], _NativeConversation] = {}
|
|
349
354
|
self.cleanup_tasks: set[asyncio.Task] = set()
|
|
355
|
+
self.synced = (SyncedSideChats(storage_path, native_factory=native_factory, notify=notify, admission_check=admission_check)
|
|
356
|
+
if storage_path is not None else None)
|
|
357
|
+
|
|
358
|
+
def active_session_ids(self):
|
|
359
|
+
sessions = {key[1] for key, receipt in self.receipts.items()
|
|
360
|
+
if receipt.task is not None and not receipt.task.done()}
|
|
361
|
+
if self.synced is not None:
|
|
362
|
+
sessions.update(key[1] for key, task in self.synced.tasks.items() if not task.done())
|
|
363
|
+
return sessions
|
|
364
|
+
|
|
365
|
+
def active_work_labels(self):
|
|
366
|
+
return [f"Side chat in {session_id}" for session_id in sorted(self.active_session_ids())]
|
|
350
367
|
|
|
351
368
|
async def _close_conversation(self, conversation):
|
|
352
369
|
conversation.closed = True
|
|
@@ -434,6 +451,8 @@ class SideQuestions:
|
|
|
434
451
|
|
|
435
452
|
def submit(self, owner: str, session_id: str, request_id: str, question: str, *, history: list[dict] | None = None,
|
|
436
453
|
side_chat_id: str | None = None, after_request_id: str | None = None):
|
|
454
|
+
if self.admission_check is not None:
|
|
455
|
+
self.admission_check()
|
|
437
456
|
frozen_history = validate_history([] if history is None else history)
|
|
438
457
|
self._prune()
|
|
439
458
|
key = (owner, session_id, request_id)
|
|
@@ -456,9 +475,10 @@ class SideQuestions:
|
|
|
456
475
|
if self.native_factory is not None else
|
|
457
476
|
self.answer(session_id, question, history=history_messages(frozen_history))
|
|
458
477
|
if frozen_history else self.answer(session_id, question))
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
478
|
+
# A live side answer may be thinking, using tools or waiting
|
|
479
|
+
# for approval. Only explicit cancellation or provider failure
|
|
480
|
+
# ends it; elapsed time does not close its native conversation.
|
|
481
|
+
return await answer
|
|
462
482
|
finally:
|
|
463
483
|
receipt.expires_at = time.monotonic() + RECEIPT_TTL_SECONDS
|
|
464
484
|
|
|
@@ -485,7 +505,27 @@ class SideQuestions:
|
|
|
485
505
|
await asyncio.gather(receipt.task, return_exceptions=True)
|
|
486
506
|
return status
|
|
487
507
|
|
|
508
|
+
async def close_session(self, session_id):
|
|
509
|
+
for key in tuple(self.receipts):
|
|
510
|
+
if key[1] == session_id:
|
|
511
|
+
await self.cancel(*key)
|
|
512
|
+
for key, conversation in tuple(self.conversations.items()):
|
|
513
|
+
if key[1] == session_id:
|
|
514
|
+
await self._close_conversation(conversation)
|
|
515
|
+
self.conversations.pop(key, None)
|
|
516
|
+
if self.synced is not None:
|
|
517
|
+
await self.synced.close_session(session_id)
|
|
518
|
+
|
|
519
|
+
def delete_history(self, session_id):
|
|
520
|
+
if self.synced is not None:
|
|
521
|
+
self.synced.delete_history(session_id)
|
|
522
|
+
|
|
523
|
+
def provider_thread_ids(self):
|
|
524
|
+
return self.synced.store.provider_thread_ids() if self.synced is not None else set()
|
|
525
|
+
|
|
488
526
|
async def close(self):
|
|
527
|
+
if self.synced is not None:
|
|
528
|
+
await self.synced.close()
|
|
489
529
|
tasks = [receipt.task for receipt in self.receipts.values()
|
|
490
530
|
if receipt.task is not None and not receipt.task.done()]
|
|
491
531
|
for task in tasks:
|
|
@@ -497,6 +537,387 @@ class SideQuestions:
|
|
|
497
537
|
await asyncio.gather(*self.cleanup_tasks, return_exceptions=True)
|
|
498
538
|
|
|
499
539
|
|
|
540
|
+
def side_chat_timestamp():
|
|
541
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class SideChatStore:
|
|
545
|
+
"""Private, atomic side-chat state; never part of the main event log.
|
|
546
|
+
|
|
547
|
+
A request receipt is retained after Clear so a delayed/retried submission
|
|
548
|
+
cannot re-run a previously accepted tool-using request. Opening a database
|
|
549
|
+
after process restart marks unfinished answers interrupted, never resends.
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
def __init__(self, path):
|
|
553
|
+
self.path = Path(path)
|
|
554
|
+
self.database = None
|
|
555
|
+
|
|
556
|
+
def _db(self):
|
|
557
|
+
if self.database is None:
|
|
558
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
559
|
+
descriptor = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
560
|
+
os.close(descriptor)
|
|
561
|
+
database = sqlite3.connect(self.path)
|
|
562
|
+
database.execute("CREATE TABLE IF NOT EXISTS chats (owner TEXT, session TEXT, document TEXT NOT NULL, PRIMARY KEY(owner, session))")
|
|
563
|
+
database.execute("CREATE TABLE IF NOT EXISTS receipts (owner TEXT, session TEXT, request TEXT, side_chat TEXT NOT NULL, question TEXT NOT NULL, PRIMARY KEY(owner, session, request))")
|
|
564
|
+
database.execute("CREATE TABLE IF NOT EXISTS provider_threads (thread TEXT PRIMARY KEY)")
|
|
565
|
+
with database:
|
|
566
|
+
if database.execute("PRAGMA user_version").fetchone()[0] == 0:
|
|
567
|
+
# Early development databases stored receipt question
|
|
568
|
+
# text. Only a digest is needed for duplicate detection.
|
|
569
|
+
for owner, session, request, question in database.execute("SELECT owner, session, request, question FROM receipts").fetchall():
|
|
570
|
+
database.execute("UPDATE receipts SET question=? WHERE owner=? AND session=? AND request=?",
|
|
571
|
+
(hashlib.sha256(question.encode()).hexdigest(), owner, session, request))
|
|
572
|
+
database.execute("PRAGMA user_version=1")
|
|
573
|
+
for owner, session, raw in database.execute("SELECT owner, session, document FROM chats").fetchall():
|
|
574
|
+
document = json.loads(raw)
|
|
575
|
+
thread = ((document.get("_provider_state") or {}).get("codex") or {}).get("thread_id")
|
|
576
|
+
if isinstance(thread, str) and thread:
|
|
577
|
+
database.execute("INSERT OR IGNORE INTO provider_threads VALUES(?)", (thread,))
|
|
578
|
+
changed = False
|
|
579
|
+
for exchange in document["exchanges"]:
|
|
580
|
+
if exchange["status"] == "running":
|
|
581
|
+
exchange.update(status="interrupted", error="side_question_interrupted", updated_at=side_chat_timestamp())
|
|
582
|
+
document["last_request_id"] = exchange["request_id"]
|
|
583
|
+
changed = True
|
|
584
|
+
if changed:
|
|
585
|
+
document["revision"] += 1
|
|
586
|
+
database.execute("UPDATE chats SET document=? WHERE owner=? AND session=?",
|
|
587
|
+
(json.dumps(document), owner, session))
|
|
588
|
+
self.database = database
|
|
589
|
+
return self.database
|
|
590
|
+
|
|
591
|
+
def load(self, owner, session):
|
|
592
|
+
row = self._db().execute("SELECT document FROM chats WHERE owner=? AND session=?", (owner, session)).fetchone()
|
|
593
|
+
if row:
|
|
594
|
+
return json.loads(row[0])
|
|
595
|
+
document = {"session_id": session, "side_chat_id": uuid.uuid4().hex, "revision": 0,
|
|
596
|
+
"exchanges": [], "last_request_id": None, "_provider_state": None}
|
|
597
|
+
self.save(owner, session, document)
|
|
598
|
+
return document
|
|
599
|
+
|
|
600
|
+
def save(self, owner, session, document, *, receipt=None):
|
|
601
|
+
database = self._db()
|
|
602
|
+
with database:
|
|
603
|
+
if receipt is not None:
|
|
604
|
+
database.execute("INSERT INTO receipts VALUES(?,?,?,?,?)",
|
|
605
|
+
(owner, session, receipt["request_id"], document["side_chat_id"],
|
|
606
|
+
hashlib.sha256(receipt["question"].encode()).hexdigest()))
|
|
607
|
+
provider_state = document.get("_provider_state") or {}
|
|
608
|
+
thread_id = (provider_state.get("codex") or {}).get("thread_id")
|
|
609
|
+
if isinstance(thread_id, str) and thread_id:
|
|
610
|
+
database.execute("INSERT OR IGNORE INTO provider_threads VALUES(?)", (thread_id,))
|
|
611
|
+
database.execute("INSERT OR REPLACE INTO chats VALUES(?,?,?)",
|
|
612
|
+
(owner, session, json.dumps(document, ensure_ascii=False)))
|
|
613
|
+
|
|
614
|
+
def receipt(self, owner, session, request_id):
|
|
615
|
+
return self._db().execute("SELECT side_chat, question FROM receipts WHERE owner=? AND session=? AND request=?",
|
|
616
|
+
(owner, session, request_id)).fetchone()
|
|
617
|
+
|
|
618
|
+
def close(self):
|
|
619
|
+
if self.database is not None:
|
|
620
|
+
self.database.close()
|
|
621
|
+
self.database = None
|
|
622
|
+
|
|
623
|
+
def delete_session(self, session):
|
|
624
|
+
if self.database is None and not self.path.exists():
|
|
625
|
+
return
|
|
626
|
+
database = self._db()
|
|
627
|
+
with database:
|
|
628
|
+
database.execute("DELETE FROM chats WHERE session=?", (session,))
|
|
629
|
+
database.execute("DELETE FROM receipts WHERE session=?", (session,))
|
|
630
|
+
|
|
631
|
+
def provider_thread_ids(self):
|
|
632
|
+
# Local history discovery runs in a worker thread. Use a separate
|
|
633
|
+
# read-only connection instead of sharing the event-loop writer. The
|
|
634
|
+
# opaque registry survives Clear/deletion so old side forks never
|
|
635
|
+
# reappear as importable main chats.
|
|
636
|
+
if not self.path.exists():
|
|
637
|
+
return set()
|
|
638
|
+
with closing(sqlite3.connect(self.path.resolve().as_uri() + "?mode=ro", uri=True)) as database:
|
|
639
|
+
tables = {row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
640
|
+
if "provider_threads" in tables:
|
|
641
|
+
return {row[0] for row in database.execute("SELECT thread FROM provider_threads")}
|
|
642
|
+
if "chats" in tables:
|
|
643
|
+
threads = set()
|
|
644
|
+
for (raw,) in database.execute("SELECT document FROM chats"):
|
|
645
|
+
state = json.loads(raw).get("_provider_state") or {}
|
|
646
|
+
thread = (state.get("codex") or {}).get("thread_id")
|
|
647
|
+
if isinstance(thread, str) and thread:
|
|
648
|
+
threads.add(thread)
|
|
649
|
+
return threads
|
|
650
|
+
return set()
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
class SyncedSideChats:
|
|
654
|
+
"""Own detached requests and persisted native provider continuations.
|
|
655
|
+
|
|
656
|
+
The single server event loop serializes admission and state changes. The
|
|
657
|
+
SQLite commit precedes task creation, so accepted requests remain visible
|
|
658
|
+
after an app disconnect or an unexpected process exit.
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
def __init__(self, path, *, native_factory, notify=None, admission_check=None):
|
|
662
|
+
self.store = SideChatStore(path)
|
|
663
|
+
self.native_factory = native_factory
|
|
664
|
+
self.notify = notify
|
|
665
|
+
self.admission_check = admission_check
|
|
666
|
+
self.locks = {}
|
|
667
|
+
self.tasks = {}
|
|
668
|
+
self.handles = {}
|
|
669
|
+
self.timers = {}
|
|
670
|
+
self.cleanup_tasks = set()
|
|
671
|
+
self.pending_writes = {}
|
|
672
|
+
self.stopping = False
|
|
673
|
+
|
|
674
|
+
def _lock(self, key):
|
|
675
|
+
return self.locks.setdefault(key, asyncio.Lock())
|
|
676
|
+
|
|
677
|
+
def _load(self, key):
|
|
678
|
+
pending = self.pending_writes.get(key)
|
|
679
|
+
if pending is not None:
|
|
680
|
+
# A disk failure after an answer must not strand a permanently
|
|
681
|
+
# running receipt. The next client read retries only this write,
|
|
682
|
+
# never the provider request or its tools.
|
|
683
|
+
self.store.save(*key, pending)
|
|
684
|
+
self.pending_writes.pop(key, None)
|
|
685
|
+
return self.store.load(*key)
|
|
686
|
+
|
|
687
|
+
@staticmethod
|
|
688
|
+
def public(document):
|
|
689
|
+
return deepcopy({key: value for key, value in document.items() if not key.startswith("_")})
|
|
690
|
+
|
|
691
|
+
async def _changed(self, document):
|
|
692
|
+
if self.notify is not None:
|
|
693
|
+
# Notification is invalidation only. Loss of a socket never rolls
|
|
694
|
+
# back accepted work; reconnect always reads authoritative state.
|
|
695
|
+
with suppress(Exception):
|
|
696
|
+
await self.notify(document["session_id"], {
|
|
697
|
+
"type": "side_chat_updated", "session_id": document["session_id"],
|
|
698
|
+
"revision": document["revision"],
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
async def snapshot(self, owner, session):
|
|
702
|
+
async with self._lock((owner, session)):
|
|
703
|
+
return self.public(self._load((owner, session)))
|
|
704
|
+
|
|
705
|
+
async def submit(self, owner, session, request_id, question, side_chat_id, after_request_id=None):
|
|
706
|
+
key = (owner, session)
|
|
707
|
+
async with self._lock(key):
|
|
708
|
+
if self.stopping:
|
|
709
|
+
raise SideQuestionError(503, "Server is shutting down")
|
|
710
|
+
if self.admission_check is not None:
|
|
711
|
+
self.admission_check()
|
|
712
|
+
document = self._load(key)
|
|
713
|
+
previous = self.store.receipt(*key, request_id)
|
|
714
|
+
if previous is not None:
|
|
715
|
+
if previous != (side_chat_id, hashlib.sha256(question.encode()).hexdigest()) or document["side_chat_id"] != side_chat_id:
|
|
716
|
+
raise SideQuestionError(409, "Request ID belongs to a different or cleared side chat")
|
|
717
|
+
return self.public(document)
|
|
718
|
+
if document["side_chat_id"] != side_chat_id:
|
|
719
|
+
raise SideQuestionError(409, "Side chat was cleared; refresh before sending")
|
|
720
|
+
if any(item["status"] == "running" for item in document["exchanges"]):
|
|
721
|
+
raise SideQuestionError(409, "A side question is already running in this conversation")
|
|
722
|
+
active = self.tasks.get(key)
|
|
723
|
+
if active is not None and not active.done():
|
|
724
|
+
raise SideQuestionError(409, "The previous side question is stopping; retry after it stops")
|
|
725
|
+
if document["last_request_id"] != after_request_id:
|
|
726
|
+
raise SideQuestionError(409, "Side chat changed; refresh before sending")
|
|
727
|
+
exchange = {"request_id": request_id, "question": question, "status": "running",
|
|
728
|
+
"created_at": side_chat_timestamp(), "updated_at": side_chat_timestamp()}
|
|
729
|
+
document["exchanges"].append(exchange)
|
|
730
|
+
document["revision"] += 1
|
|
731
|
+
self.store.save(*key, document, receipt=exchange)
|
|
732
|
+
timer = self.timers.pop(key, None)
|
|
733
|
+
if timer is not None:
|
|
734
|
+
timer.cancel()
|
|
735
|
+
task = asyncio.create_task(self._answer(key, side_chat_id, request_id, question), name="synced-side-question")
|
|
736
|
+
self.tasks[key] = task
|
|
737
|
+
def done(completed):
|
|
738
|
+
if self.tasks.get(key) is completed:
|
|
739
|
+
self.tasks.pop(key, None)
|
|
740
|
+
if not completed.cancelled():
|
|
741
|
+
completed.exception()
|
|
742
|
+
task.add_done_callback(done)
|
|
743
|
+
snapshot = self.public(document)
|
|
744
|
+
await self._changed(document)
|
|
745
|
+
return snapshot
|
|
746
|
+
|
|
747
|
+
async def _provider_state(self, key, side_chat_id, value):
|
|
748
|
+
async with self._lock(key):
|
|
749
|
+
document = self._load(key)
|
|
750
|
+
if document["side_chat_id"] != side_chat_id:
|
|
751
|
+
raise SideQuestionError(409, "Side chat was cleared")
|
|
752
|
+
document["_provider_state"] = value
|
|
753
|
+
self.store.save(*key, document)
|
|
754
|
+
|
|
755
|
+
async def _answer(self, key, side_chat_id, request_id, question):
|
|
756
|
+
handle = None
|
|
757
|
+
failed = False
|
|
758
|
+
try:
|
|
759
|
+
document = self._load(key)
|
|
760
|
+
if document["side_chat_id"] != side_chat_id:
|
|
761
|
+
return
|
|
762
|
+
handle = self.handles.get(key)
|
|
763
|
+
if handle is None:
|
|
764
|
+
handle = await self.native_factory(key[1], persisted_state=document.get("_provider_state"),
|
|
765
|
+
persist_state=lambda value: self._provider_state(key, side_chat_id, value), durable=True)
|
|
766
|
+
self.handles[key] = handle
|
|
767
|
+
history = [{"question": item["question"], "response": item["answer"]}
|
|
768
|
+
for item in document["exchanges"] if item["status"] == "completed"][-20:]
|
|
769
|
+
result = await handle.ask(question, history=history)
|
|
770
|
+
await self._finish(key, side_chat_id, request_id, "completed", **result)
|
|
771
|
+
except asyncio.CancelledError:
|
|
772
|
+
failed = True
|
|
773
|
+
await self._finish(key, side_chat_id, request_id,
|
|
774
|
+
"interrupted" if self.stopping else "cancelled",
|
|
775
|
+
**({"error": "side_question_interrupted"} if self.stopping else {}))
|
|
776
|
+
raise
|
|
777
|
+
except Exception as exc:
|
|
778
|
+
failed = True
|
|
779
|
+
await self._finish(key, side_chat_id, request_id, "failed",
|
|
780
|
+
error=f"side_question_http_{exc.status_code}" if isinstance(exc, SideQuestionError) else "side_question_failed")
|
|
781
|
+
finally:
|
|
782
|
+
if failed and handle is not None:
|
|
783
|
+
if self.handles.get(key) is handle:
|
|
784
|
+
self.handles.pop(key, None)
|
|
785
|
+
cleanup = asyncio.create_task(handle.close(), name="synced-side-chat-close")
|
|
786
|
+
self.cleanup_tasks.add(cleanup)
|
|
787
|
+
try:
|
|
788
|
+
# Stop, Clear and shutdown can race. Once a native process
|
|
789
|
+
# is ours, repeated cancellation must still join its close.
|
|
790
|
+
while not cleanup.done():
|
|
791
|
+
try:
|
|
792
|
+
await asyncio.shield(cleanup)
|
|
793
|
+
except asyncio.CancelledError:
|
|
794
|
+
continue
|
|
795
|
+
cleanup.result()
|
|
796
|
+
finally:
|
|
797
|
+
self.cleanup_tasks.discard(cleanup)
|
|
798
|
+
if self.tasks.get(key) is asyncio.current_task():
|
|
799
|
+
self.tasks.pop(key, None)
|
|
800
|
+
if not self.stopping:
|
|
801
|
+
self.timers[key] = asyncio.get_running_loop().call_later(NATIVE_IDLE_SECONDS, self._expire, key)
|
|
802
|
+
|
|
803
|
+
async def _finish(self, key, side_chat_id, request_id, status, **result):
|
|
804
|
+
async with self._lock(key):
|
|
805
|
+
document = self._load(key)
|
|
806
|
+
if document["side_chat_id"] != side_chat_id:
|
|
807
|
+
return
|
|
808
|
+
exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
|
|
809
|
+
if exchange is None or exchange["status"] != "running":
|
|
810
|
+
return
|
|
811
|
+
exchange.update(status=status, updated_at=side_chat_timestamp(), **result)
|
|
812
|
+
document["last_request_id"] = request_id
|
|
813
|
+
document["revision"] += 1
|
|
814
|
+
try:
|
|
815
|
+
self.store.save(*key, document)
|
|
816
|
+
except (OSError, sqlite3.Error):
|
|
817
|
+
self.pending_writes[key] = document
|
|
818
|
+
await self._changed(document)
|
|
819
|
+
|
|
820
|
+
def _expire(self, key):
|
|
821
|
+
self.timers.pop(key, None)
|
|
822
|
+
if key in self.tasks:
|
|
823
|
+
return
|
|
824
|
+
handle = self.handles.pop(key, None)
|
|
825
|
+
if handle is not None:
|
|
826
|
+
task = asyncio.create_task(handle.close(), name="synced-side-chat-idle-close")
|
|
827
|
+
self.cleanup_tasks.add(task)
|
|
828
|
+
task.add_done_callback(lambda done: (self.cleanup_tasks.discard(done), None if done.cancelled() else done.exception()))
|
|
829
|
+
|
|
830
|
+
async def cancel(self, owner, session, request_id):
|
|
831
|
+
key = (owner, session)
|
|
832
|
+
async with self._lock(key):
|
|
833
|
+
document = self._load(key)
|
|
834
|
+
exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
|
|
835
|
+
if exchange is None:
|
|
836
|
+
# Stop can overtake submission in transit. Preserve an opaque
|
|
837
|
+
# receipt so the late POST cannot start provider/tool work.
|
|
838
|
+
if self.store.receipt(*key, request_id) is None:
|
|
839
|
+
self.store.save(*key, document, receipt={"request_id": request_id, "question": ""})
|
|
840
|
+
return self.public(document)
|
|
841
|
+
if exchange["status"] != "running":
|
|
842
|
+
return self.public(document)
|
|
843
|
+
exchange.update(status="cancelled", updated_at=side_chat_timestamp())
|
|
844
|
+
document["last_request_id"] = request_id
|
|
845
|
+
document["revision"] += 1
|
|
846
|
+
try:
|
|
847
|
+
self.store.save(*key, document)
|
|
848
|
+
except (OSError, sqlite3.Error):
|
|
849
|
+
self.pending_writes[key] = document
|
|
850
|
+
task = self.tasks.get(key)
|
|
851
|
+
if task is not None:
|
|
852
|
+
task.cancel()
|
|
853
|
+
if task is not None:
|
|
854
|
+
await asyncio.shield(asyncio.gather(task, return_exceptions=True))
|
|
855
|
+
await self._changed(document)
|
|
856
|
+
return await self.snapshot(*key)
|
|
857
|
+
|
|
858
|
+
async def clear(self, owner, session, side_chat_id):
|
|
859
|
+
key = (owner, session)
|
|
860
|
+
async with self._lock(key):
|
|
861
|
+
document = self._load(key)
|
|
862
|
+
if document["side_chat_id"] != side_chat_id:
|
|
863
|
+
# A repeated Clear is harmless. A stale device cannot clear a
|
|
864
|
+
# new conversation it has not seen.
|
|
865
|
+
return self.public(document)
|
|
866
|
+
document.update(side_chat_id=uuid.uuid4().hex, exchanges=[], last_request_id=None, _provider_state=None)
|
|
867
|
+
document["revision"] += 1
|
|
868
|
+
try:
|
|
869
|
+
self.store.save(*key, document)
|
|
870
|
+
except (OSError, sqlite3.Error):
|
|
871
|
+
self.pending_writes[key] = document
|
|
872
|
+
task = self.tasks.get(key)
|
|
873
|
+
if task is not None:
|
|
874
|
+
task.cancel()
|
|
875
|
+
handle = self.handles.pop(key, None)
|
|
876
|
+
if task is not None:
|
|
877
|
+
await asyncio.shield(asyncio.gather(task, return_exceptions=True))
|
|
878
|
+
if handle is not None:
|
|
879
|
+
await handle.close()
|
|
880
|
+
await self._changed(document)
|
|
881
|
+
return await self.snapshot(*key)
|
|
882
|
+
|
|
883
|
+
async def close(self):
|
|
884
|
+
self.stopping = True
|
|
885
|
+
for timer in self.timers.values():
|
|
886
|
+
timer.cancel()
|
|
887
|
+
self.timers.clear()
|
|
888
|
+
tasks = list(self.tasks.values())
|
|
889
|
+
for task in tasks:
|
|
890
|
+
task.cancel()
|
|
891
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
892
|
+
handles, self.handles = list(self.handles.values()), {}
|
|
893
|
+
await asyncio.gather(*(handle.close() for handle in handles), return_exceptions=True)
|
|
894
|
+
await asyncio.gather(*self.cleanup_tasks, return_exceptions=True)
|
|
895
|
+
for key in tuple(self.pending_writes):
|
|
896
|
+
with suppress(OSError, sqlite3.Error):
|
|
897
|
+
self._load(key)
|
|
898
|
+
self.store.close()
|
|
899
|
+
# Managed-update retirement can be cancelled before process restart.
|
|
900
|
+
self.stopping = False
|
|
901
|
+
|
|
902
|
+
async def close_session(self, session):
|
|
903
|
+
tasks = [task for key, task in self.tasks.items() if key[1] == session]
|
|
904
|
+
for task in tasks:
|
|
905
|
+
task.cancel()
|
|
906
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
907
|
+
for key, timer in tuple(self.timers.items()):
|
|
908
|
+
if key[1] == session:
|
|
909
|
+
timer.cancel()
|
|
910
|
+
self.timers.pop(key, None)
|
|
911
|
+
handles = [self.handles.pop(key) for key in tuple(self.handles) if key[1] == session]
|
|
912
|
+
await asyncio.gather(*(handle.close() for handle in handles))
|
|
913
|
+
|
|
914
|
+
def delete_history(self, session):
|
|
915
|
+
self.store.delete_session(session)
|
|
916
|
+
for key in tuple(self.pending_writes):
|
|
917
|
+
if key[1] == session:
|
|
918
|
+
self.pending_writes.pop(key, None)
|
|
919
|
+
|
|
920
|
+
|
|
500
921
|
def create_side_question_router(*, authorize, session_exists, runtime: SideQuestions):
|
|
501
922
|
router = APIRouter()
|
|
502
923
|
|
|
@@ -591,4 +1012,66 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
591
1012
|
await runtime.close_conversation(owner, session_id, side_chat_id)
|
|
592
1013
|
return JSONResponse({"side_chat_id": side_chat_id, "status": "closed"}, headers={"Cache-Control": "no-store"})
|
|
593
1014
|
|
|
1015
|
+
def synced_runtime():
|
|
1016
|
+
if runtime.synced is None:
|
|
1017
|
+
raise HTTPException(503, "Side chat storage is unavailable")
|
|
1018
|
+
return runtime.synced
|
|
1019
|
+
|
|
1020
|
+
async def synced_response(operation, *, status_code=200):
|
|
1021
|
+
try:
|
|
1022
|
+
return JSONResponse(await operation, status_code=status_code, headers={"Cache-Control": "no-store"})
|
|
1023
|
+
except SideQuestionError as exc:
|
|
1024
|
+
raise HTTPException(exc.status_code, str(exc)) from None
|
|
1025
|
+
except (OSError, sqlite3.Error):
|
|
1026
|
+
raise HTTPException(503, "Side chat could not be saved; check server storage and retry") from None
|
|
1027
|
+
|
|
1028
|
+
@router.get("/api/sessions/{session_id}/side-chat")
|
|
1029
|
+
async def get_synced(session_id: str, request: Request):
|
|
1030
|
+
owner = guard(request, session_id)
|
|
1031
|
+
return await synced_response(synced_runtime().snapshot(owner, session_id))
|
|
1032
|
+
|
|
1033
|
+
@router.post("/api/sessions/{session_id}/side-chat")
|
|
1034
|
+
async def post_synced(session_id: str, request: Request):
|
|
1035
|
+
owner = guard(request, session_id)
|
|
1036
|
+
async def body():
|
|
1037
|
+
data = bytearray()
|
|
1038
|
+
async for chunk in request.stream():
|
|
1039
|
+
if len(data) + len(chunk) > MAX_REQUEST_BYTES:
|
|
1040
|
+
raise HTTPException(413, "Side question request is too large")
|
|
1041
|
+
data.extend(chunk)
|
|
1042
|
+
return json.loads(data)
|
|
1043
|
+
try:
|
|
1044
|
+
value = await asyncio.wait_for(body(), 10)
|
|
1045
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
1046
|
+
raise HTTPException(400, "Invalid side question request") from None
|
|
1047
|
+
except asyncio.TimeoutError:
|
|
1048
|
+
raise HTTPException(408, "Side question request was not received") from None
|
|
1049
|
+
if (not isinstance(value, dict) or not {"request_id", "question", "side_chat_id"} <= set(value)
|
|
1050
|
+
or set(value) - {"request_id", "question", "side_chat_id", "after_request_id"}):
|
|
1051
|
+
raise HTTPException(400, "Invalid side question fields")
|
|
1052
|
+
for field in ("request_id", "side_chat_id", "after_request_id"):
|
|
1053
|
+
if field == "after_request_id" and value.get(field) is None:
|
|
1054
|
+
continue
|
|
1055
|
+
if not isinstance(value.get(field), str) or not IDENTIFIER.fullmatch(value[field]):
|
|
1056
|
+
raise HTTPException(400, "Invalid side conversation ID")
|
|
1057
|
+
question = value["question"]
|
|
1058
|
+
if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
|
|
1059
|
+
raise HTTPException(400, "Question must contain 1 to 8000 characters")
|
|
1060
|
+
try:
|
|
1061
|
+
question.encode("utf-8")
|
|
1062
|
+
except UnicodeEncodeError:
|
|
1063
|
+
raise HTTPException(400, "Question must contain valid Unicode text") from None
|
|
1064
|
+
return await synced_response(synced_runtime().submit(owner, session_id, value["request_id"], question,
|
|
1065
|
+
value["side_chat_id"], value.get("after_request_id")), status_code=202)
|
|
1066
|
+
|
|
1067
|
+
@router.delete("/api/sessions/{session_id}/side-chat/requests/{request_id}")
|
|
1068
|
+
async def cancel_synced(session_id: str, request_id: str, request: Request):
|
|
1069
|
+
owner = guard(request, session_id, request_id)
|
|
1070
|
+
return await synced_response(synced_runtime().cancel(owner, session_id, request_id))
|
|
1071
|
+
|
|
1072
|
+
@router.delete("/api/sessions/{session_id}/side-chat/{side_chat_id}")
|
|
1073
|
+
async def clear_synced(session_id: str, side_chat_id: str, request: Request):
|
|
1074
|
+
owner = guard(request, session_id, side_chat_id)
|
|
1075
|
+
return await synced_response(synced_runtime().clear(owner, session_id, side_chat_id))
|
|
1076
|
+
|
|
594
1077
|
return router
|
|
@@ -242,6 +242,9 @@ def cursor_profile(root: Path, original_env: dict) -> tuple[Path, dict]:
|
|
|
242
242
|
'NODE_EXTRA_CA_CERTS', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
243
243
|
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'CURSOR_API_KEY', 'CURSOR_API_ENDPOINT'}
|
|
244
244
|
env = {k: v for k, v in original_env.items() if k in allowed}
|
|
245
|
+
if original_env.get('AGENT_CLI_CREDENTIAL_STORE') == 'memory' and env.get('CURSOR_API_KEY'):
|
|
246
|
+
from cursor_api_key import overrides
|
|
247
|
+
env.update(overrides(env['CURSOR_API_KEY']))
|
|
245
248
|
env.update(HOME=str(home), USERPROFILE=str(home), CURSOR_CONFIG_DIR=str(config),
|
|
246
249
|
CURSOR_DATA_DIR=str(root / 'data'), XDG_CONFIG_HOME=str(home / '.config'),
|
|
247
250
|
XDG_DATA_HOME=str(root / 'data'), XDG_CACHE_HOME=str(root / 'cache'),
|
|
@@ -298,7 +301,7 @@ async def cursor_keychain_snapshot(env: dict, isolated: dict, workspace: Path) -
|
|
|
298
301
|
|
|
299
302
|
|
|
300
303
|
async def generate_cursor_title(prompt: str, *, executable: str, model: str | None, env: dict) -> str | None:
|
|
301
|
-
model = model or cursor_default_model(env)
|
|
304
|
+
model = model or ('auto' if env.get('AGENT_CLI_CREDENTIAL_STORE') == 'memory' else cursor_default_model(env))
|
|
302
305
|
with tempfile.TemporaryDirectory(prefix='agentsdock-title-cursor-') as directory:
|
|
303
306
|
workspace, isolated = cursor_profile(Path(directory), env)
|
|
304
307
|
await cursor_keychain_snapshot(env, isolated, workspace)
|