Persist Space sessions in MongoDB (#170)
Browse files* Persist Space sessions across backend restarts
The hosted Space lost active conversations because sessions, approval state, event streams, and Claude quotas only lived in process memory. This adds a shared optional Mongo store for frontend sessions while keeping local and CLI runs on the no-op/local path when Mongo is absent.
Constraint: Space deployment needs durable state without making PyPI/local CLI users configure Mongo.
Constraint: Frontend deletes must hide sessions with a visibility tag, not erase traces needed for recovery or SFT export.
Rejected: Restore every active session at startup | large user counts would create a thundering herd after deploys.
Rejected: Store one giant session document | Mongo document limits make per-message snapshot docs safer.
Confidence: medium
Scope-risk: broad
Directive: Keep session_messages as compactable runtime snapshots and session_trace_messages/session_events as append-only raw traces; do not collapse them into one collection without revisiting SFT/replay needs.
Tested: UV_CACHE_DIR=/tmp/uv-cache uv run --extra dev pytest tests/unit/test_session_persistence.py tests/unit/test_user_quotas.py -q
Tested: UV_CACHE_DIR=/tmp/uv-cache uv run --extra dev python -m py_compile backend/session_manager.py backend/routes/agent.py backend/user_quotas.py backend/models.py agent/core/session_persistence.py agent/core/session.py agent/context_manager/manager.py
Tested: local Mongo smoke for snapshot load, event replay, quota cap, soft delete
Tested: npm run build
Not-tested: Full background worker recovery after Space restart; intentionally Phase 3/backlog.
Not-tested: Full unit suite currently has pre-existing doom-loop wording expectation failures unrelated to this change.
* Protect persisted session restore boundaries
Lazy restore now authorizes an existing session before mutating its HF token and starts at most one runtime task when concurrent reconnects race to restore the same Mongo-backed session. Regression tests cover cross-user token overwrite, duplicate task prevention, pending approval restore, dev listing, and Mongo quota cap delegation.
Constraint: Session restore is lazy because the Space may have many durable sessions and cannot eagerly hydrate all of them on startup.
Rejected: Per-session restore locks | idempotent task startup plus a post-load in-memory check prevents leaked loops with less bookkeeping.
Confidence: high
Scope-risk: narrow
Directive: Do not update runtime credentials before access checks in ensure_session_loaded.
Tested: UV_CACHE_DIR=/tmp/uv-cache uv run --extra dev pytest tests/unit/test_session_persistence.py tests/unit/test_session_manager_persistence.py tests/unit/test_user_quotas.py -q
Tested: UV_CACHE_DIR=/tmp/uv-cache uv run python -m py_compile backend/session_manager.py backend/user_quotas.py agent/core/session_persistence.py
Tested: git diff --check
- agent/context_manager/manager.py +3 -0
- agent/core/session.py +32 -2
- agent/core/session_persistence.py +428 -0
- backend/main.py +5 -3
- backend/models.py +1 -0
- backend/routes/agent.py +102 -48
- backend/session_manager.py +404 -57
- backend/user_quotas.py +42 -4
- frontend/src/components/SessionSidebar/SessionSidebar.tsx +19 -2
- frontend/src/hooks/useAgentChat.ts +85 -55
- frontend/src/lib/sse-chat-transport.ts +52 -19
- frontend/src/store/sessionStore.ts +47 -0
- frontend/src/types/events.ts +1 -0
- pyproject.toml +1 -0
- tests/unit/test_session_manager_persistence.py +240 -0
- tests/unit/test_session_persistence.py +31 -0
- tests/unit/test_user_quotas.py +28 -0
- uv.lock +63 -0
|
@@ -160,6 +160,7 @@ class ContextManager:
|
|
| 160 |
self.running_context_usage = 0
|
| 161 |
self.untouched_messages = untouched_messages
|
| 162 |
self.items: list[Message] = [Message(role="system", content=self.system_prompt)]
|
|
|
|
| 163 |
|
| 164 |
def _load_system_prompt(
|
| 165 |
self,
|
|
@@ -219,6 +220,8 @@ class ContextManager:
|
|
| 219 |
if token_count:
|
| 220 |
self.running_context_usage = token_count
|
| 221 |
self.items.append(message)
|
|
|
|
|
|
|
| 222 |
|
| 223 |
def get_messages(self) -> list[Message]:
|
| 224 |
"""Get all messages for sending to LLM.
|
|
|
|
| 160 |
self.running_context_usage = 0
|
| 161 |
self.untouched_messages = untouched_messages
|
| 162 |
self.items: list[Message] = [Message(role="system", content=self.system_prompt)]
|
| 163 |
+
self.on_message_added = None
|
| 164 |
|
| 165 |
def _load_system_prompt(
|
| 166 |
self,
|
|
|
|
| 220 |
if token_count:
|
| 221 |
self.running_context_usage = token_count
|
| 222 |
self.items.append(message)
|
| 223 |
+
if self.on_message_added:
|
| 224 |
+
self.on_message_added(message)
|
| 225 |
|
| 226 |
def get_messages(self) -> list[Message]:
|
| 227 |
"""Get all messages for sending to LLM.
|
|
@@ -65,6 +65,7 @@ class OpType(Enum):
|
|
| 65 |
class Event:
|
| 66 |
event_type: str
|
| 67 |
data: Optional[dict[str, Any]] = None
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
class Session:
|
|
@@ -87,9 +88,11 @@ class Session:
|
|
| 87 |
defer_turn_complete_notification: bool = False,
|
| 88 |
session_id: str | None = None,
|
| 89 |
user_id: str | None = None,
|
|
|
|
| 90 |
):
|
| 91 |
self.hf_token: Optional[str] = hf_token
|
| 92 |
self.user_id: Optional[str] = user_id
|
|
|
|
| 93 |
self.tool_router = tool_router
|
| 94 |
self.stream = stream
|
| 95 |
if config is None:
|
|
@@ -135,11 +138,10 @@ class Session:
|
|
| 135 |
# thinking params at all
|
| 136 |
# Key absent → not probed yet; fall back to the raw preference.
|
| 137 |
self.model_effective_effort: dict[str, str | None] = {}
|
|
|
|
| 138 |
|
| 139 |
async def send_event(self, event: Event) -> None:
|
| 140 |
"""Send event back to client and log to trajectory"""
|
| 141 |
-
await self.event_queue.put(event)
|
| 142 |
-
|
| 143 |
# Log event to trajectory
|
| 144 |
self.logged_events.append(
|
| 145 |
{
|
|
@@ -148,6 +150,15 @@ class Session:
|
|
| 148 |
"data": event.data,
|
| 149 |
}
|
| 150 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
await self._enqueue_auto_notification_requests(event)
|
| 152 |
|
| 153 |
# Mid-turn heartbeat flush (owned by telemetry module).
|
|
@@ -155,6 +166,25 @@ class Session:
|
|
| 155 |
|
| 156 |
HeartbeatSaver.maybe_fire(self)
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def set_notification_destinations(self, destinations: list[str]) -> None:
|
| 159 |
"""Replace the session's opted-in auto-notification destinations."""
|
| 160 |
deduped: list[str] = []
|
|
|
|
| 65 |
class Event:
|
| 66 |
event_type: str
|
| 67 |
data: Optional[dict[str, Any]] = None
|
| 68 |
+
seq: Optional[int] = None
|
| 69 |
|
| 70 |
|
| 71 |
class Session:
|
|
|
|
| 88 |
defer_turn_complete_notification: bool = False,
|
| 89 |
session_id: str | None = None,
|
| 90 |
user_id: str | None = None,
|
| 91 |
+
persistence_store: Any | None = None,
|
| 92 |
):
|
| 93 |
self.hf_token: Optional[str] = hf_token
|
| 94 |
self.user_id: Optional[str] = user_id
|
| 95 |
+
self.persistence_store = persistence_store
|
| 96 |
self.tool_router = tool_router
|
| 97 |
self.stream = stream
|
| 98 |
if config is None:
|
|
|
|
| 138 |
# thinking params at all
|
| 139 |
# Key absent → not probed yet; fall back to the raw preference.
|
| 140 |
self.model_effective_effort: dict[str, str | None] = {}
|
| 141 |
+
self.context_manager.on_message_added = self._schedule_trace_message
|
| 142 |
|
| 143 |
async def send_event(self, event: Event) -> None:
|
| 144 |
"""Send event back to client and log to trajectory"""
|
|
|
|
|
|
|
| 145 |
# Log event to trajectory
|
| 146 |
self.logged_events.append(
|
| 147 |
{
|
|
|
|
| 150 |
"data": event.data,
|
| 151 |
}
|
| 152 |
)
|
| 153 |
+
if self.persistence_store is not None:
|
| 154 |
+
try:
|
| 155 |
+
event.seq = await self.persistence_store.append_event(
|
| 156 |
+
self.session_id, event.event_type, event.data
|
| 157 |
+
)
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.debug("Event persistence failed for %s: %s", self.session_id, e)
|
| 160 |
+
|
| 161 |
+
await self.event_queue.put(event)
|
| 162 |
await self._enqueue_auto_notification_requests(event)
|
| 163 |
|
| 164 |
# Mid-turn heartbeat flush (owned by telemetry module).
|
|
|
|
| 166 |
|
| 167 |
HeartbeatSaver.maybe_fire(self)
|
| 168 |
|
| 169 |
+
def _schedule_trace_message(self, message: Any) -> None:
|
| 170 |
+
"""Best-effort append-only trace save for SFT/KPI export."""
|
| 171 |
+
if self.persistence_store is None:
|
| 172 |
+
return
|
| 173 |
+
try:
|
| 174 |
+
payload = message.model_dump(mode="json")
|
| 175 |
+
except Exception:
|
| 176 |
+
return
|
| 177 |
+
try:
|
| 178 |
+
loop = asyncio.get_running_loop()
|
| 179 |
+
except RuntimeError:
|
| 180 |
+
return
|
| 181 |
+
source = str(payload.get("role") or "message")
|
| 182 |
+
loop.create_task(
|
| 183 |
+
self.persistence_store.append_trace_message(
|
| 184 |
+
self.session_id, payload, source=source
|
| 185 |
+
)
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
def set_notification_destinations(self, destinations: list[str]) -> None:
|
| 189 |
"""Replace the session's opted-in auto-notification destinations."""
|
| 190 |
deduped: list[str] = []
|
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional durable session persistence for the hosted backend.
|
| 2 |
+
|
| 3 |
+
The public CLI must keep working without MongoDB. This module therefore
|
| 4 |
+
exposes one small async store interface and returns a no-op implementation
|
| 5 |
+
unless ``MONGODB_URI`` is configured and reachable.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from datetime import UTC, datetime
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from bson import BSON
|
| 16 |
+
from pymongo import AsyncMongoClient, DeleteMany, ReturnDocument, UpdateOne
|
| 17 |
+
from pymongo.errors import DuplicateKeyError, InvalidDocument, PyMongoError
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
SCHEMA_VERSION = 1
|
| 22 |
+
MAX_BSON_BYTES = 15 * 1024 * 1024
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _now() -> datetime:
|
| 26 |
+
return datetime.now(UTC)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _doc_id(session_id: str, idx: int) -> str:
|
| 30 |
+
return f"{session_id}:{idx}"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _safe_message_doc(message: dict[str, Any]) -> dict[str, Any]:
|
| 34 |
+
"""Return a Mongo-safe message document payload.
|
| 35 |
+
|
| 36 |
+
Mongo's hard document limit is 16 MB. We stay below that and store an
|
| 37 |
+
explicit marker rather than failing the whole snapshot for one huge tool log.
|
| 38 |
+
"""
|
| 39 |
+
try:
|
| 40 |
+
if len(BSON.encode({"message": message})) <= MAX_BSON_BYTES:
|
| 41 |
+
return message
|
| 42 |
+
except (InvalidDocument, OverflowError):
|
| 43 |
+
pass
|
| 44 |
+
return {
|
| 45 |
+
"role": "tool",
|
| 46 |
+
"content": (
|
| 47 |
+
"[SYSTEM: A single persisted message exceeded MongoDB's document "
|
| 48 |
+
"size/encoding limit and was replaced by this marker.]"
|
| 49 |
+
),
|
| 50 |
+
"ml_intern_persistence_error": "message_too_large_or_invalid",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class NoopSessionStore:
|
| 55 |
+
"""Async no-op store used when Mongo is not configured."""
|
| 56 |
+
|
| 57 |
+
enabled = False
|
| 58 |
+
|
| 59 |
+
async def init(self) -> None:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
async def close(self) -> None:
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
async def upsert_session(self, **_: Any) -> None:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
async def save_snapshot(self, **_: Any) -> None:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
async def load_session(self, *_: Any, **__: Any) -> dict[str, Any] | None:
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
async def list_sessions(self, *_: Any, **__: Any) -> list[dict[str, Any]]:
|
| 75 |
+
return []
|
| 76 |
+
|
| 77 |
+
async def soft_delete_session(self, *_: Any, **__: Any) -> None:
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
async def update_session_fields(self, *_: Any, **__: Any) -> None:
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
async def append_event(self, *_: Any, **__: Any) -> int | None:
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
async def load_events_after(self, *_: Any, **__: Any) -> list[dict[str, Any]]:
|
| 87 |
+
return []
|
| 88 |
+
|
| 89 |
+
async def append_trace_message(self, *_: Any, **__: Any) -> int | None:
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
async def get_quota(self, *_: Any, **__: Any) -> int | None:
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
async def try_increment_quota(self, *_: Any, **__: Any) -> int | None:
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
async def refund_quota(self, *_: Any, **__: Any) -> None:
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class MongoSessionStore(NoopSessionStore):
|
| 103 |
+
"""MongoDB-backed session store."""
|
| 104 |
+
|
| 105 |
+
enabled = True
|
| 106 |
+
|
| 107 |
+
def __init__(self, uri: str, db_name: str) -> None:
|
| 108 |
+
self.uri = uri
|
| 109 |
+
self.db_name = db_name
|
| 110 |
+
self.enabled = False
|
| 111 |
+
self.client: AsyncMongoClient | None = None
|
| 112 |
+
self.db = None
|
| 113 |
+
|
| 114 |
+
async def init(self) -> None:
|
| 115 |
+
try:
|
| 116 |
+
self.client = AsyncMongoClient(self.uri, serverSelectionTimeoutMS=3000)
|
| 117 |
+
self.db = self.client[self.db_name]
|
| 118 |
+
await self.client.admin.command("ping")
|
| 119 |
+
await self._create_indexes()
|
| 120 |
+
self.enabled = True
|
| 121 |
+
logger.info("Mongo session persistence enabled (db=%s)", self.db_name)
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.warning("Mongo session persistence disabled: %s", e)
|
| 124 |
+
self.enabled = False
|
| 125 |
+
if self.client is not None:
|
| 126 |
+
await self.client.close()
|
| 127 |
+
self.client = None
|
| 128 |
+
self.db = None
|
| 129 |
+
|
| 130 |
+
async def close(self) -> None:
|
| 131 |
+
if self.client is not None:
|
| 132 |
+
await self.client.close()
|
| 133 |
+
self.client = None
|
| 134 |
+
self.db = None
|
| 135 |
+
|
| 136 |
+
async def _create_indexes(self) -> None:
|
| 137 |
+
if self.db is None:
|
| 138 |
+
return
|
| 139 |
+
await self.db.sessions.create_index(
|
| 140 |
+
[("user_id", 1), ("visibility", 1), ("updated_at", -1)]
|
| 141 |
+
)
|
| 142 |
+
await self.db.sessions.create_index(
|
| 143 |
+
[("visibility", 1), ("status", 1), ("last_active_at", -1)]
|
| 144 |
+
)
|
| 145 |
+
await self.db.session_messages.create_index(
|
| 146 |
+
[("session_id", 1), ("idx", 1)], unique=True
|
| 147 |
+
)
|
| 148 |
+
await self.db.session_events.create_index(
|
| 149 |
+
[("session_id", 1), ("seq", 1)], unique=True
|
| 150 |
+
)
|
| 151 |
+
await self.db.session_trace_messages.create_index(
|
| 152 |
+
[("session_id", 1), ("seq", 1)], unique=True
|
| 153 |
+
)
|
| 154 |
+
await self.db.session_trace_messages.create_index([("created_at", -1)])
|
| 155 |
+
|
| 156 |
+
def _ready(self) -> bool:
|
| 157 |
+
return bool(self.enabled and self.db is not None)
|
| 158 |
+
|
| 159 |
+
async def upsert_session(
|
| 160 |
+
self,
|
| 161 |
+
*,
|
| 162 |
+
session_id: str,
|
| 163 |
+
user_id: str,
|
| 164 |
+
model: str,
|
| 165 |
+
title: str | None = None,
|
| 166 |
+
surface: str = "frontend",
|
| 167 |
+
created_at: datetime | None = None,
|
| 168 |
+
runtime_state: str = "idle",
|
| 169 |
+
status: str = "active",
|
| 170 |
+
message_count: int = 0,
|
| 171 |
+
turn_count: int = 0,
|
| 172 |
+
pending_approval: list[dict[str, Any]] | None = None,
|
| 173 |
+
claude_counted: bool = False,
|
| 174 |
+
notification_destinations: list[str] | None = None,
|
| 175 |
+
) -> None:
|
| 176 |
+
if not self._ready():
|
| 177 |
+
return
|
| 178 |
+
now = _now()
|
| 179 |
+
await self.db.sessions.update_one(
|
| 180 |
+
{"_id": session_id},
|
| 181 |
+
{
|
| 182 |
+
"$setOnInsert": {
|
| 183 |
+
"_id": session_id,
|
| 184 |
+
"session_id": session_id,
|
| 185 |
+
"user_id": user_id,
|
| 186 |
+
"surface": surface,
|
| 187 |
+
"created_at": created_at or now,
|
| 188 |
+
"schema_version": SCHEMA_VERSION,
|
| 189 |
+
"visibility": "live",
|
| 190 |
+
},
|
| 191 |
+
"$set": {
|
| 192 |
+
"title": title,
|
| 193 |
+
"model": model,
|
| 194 |
+
"status": status,
|
| 195 |
+
"runtime_state": runtime_state,
|
| 196 |
+
"updated_at": now,
|
| 197 |
+
"last_active_at": now,
|
| 198 |
+
"message_count": message_count,
|
| 199 |
+
"turn_count": turn_count,
|
| 200 |
+
"pending_approval": pending_approval or [],
|
| 201 |
+
"claude_counted": claude_counted,
|
| 202 |
+
"notification_destinations": notification_destinations or [],
|
| 203 |
+
},
|
| 204 |
+
},
|
| 205 |
+
upsert=True,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
async def save_snapshot(
|
| 209 |
+
self,
|
| 210 |
+
*,
|
| 211 |
+
session_id: str,
|
| 212 |
+
user_id: str,
|
| 213 |
+
model: str,
|
| 214 |
+
messages: list[dict[str, Any]],
|
| 215 |
+
title: str | None = None,
|
| 216 |
+
runtime_state: str = "idle",
|
| 217 |
+
status: str = "active",
|
| 218 |
+
turn_count: int = 0,
|
| 219 |
+
pending_approval: list[dict[str, Any]] | None = None,
|
| 220 |
+
claude_counted: bool = False,
|
| 221 |
+
created_at: datetime | None = None,
|
| 222 |
+
notification_destinations: list[str] | None = None,
|
| 223 |
+
) -> None:
|
| 224 |
+
if not self._ready():
|
| 225 |
+
return
|
| 226 |
+
now = _now()
|
| 227 |
+
await self.upsert_session(
|
| 228 |
+
session_id=session_id,
|
| 229 |
+
user_id=user_id,
|
| 230 |
+
model=model,
|
| 231 |
+
title=title,
|
| 232 |
+
created_at=created_at,
|
| 233 |
+
runtime_state=runtime_state,
|
| 234 |
+
status=status,
|
| 235 |
+
message_count=len(messages),
|
| 236 |
+
turn_count=turn_count,
|
| 237 |
+
pending_approval=pending_approval,
|
| 238 |
+
claude_counted=claude_counted,
|
| 239 |
+
notification_destinations=notification_destinations,
|
| 240 |
+
)
|
| 241 |
+
ops: list[Any] = []
|
| 242 |
+
for idx, raw in enumerate(messages):
|
| 243 |
+
ops.append(
|
| 244 |
+
UpdateOne(
|
| 245 |
+
{"_id": _doc_id(session_id, idx)},
|
| 246 |
+
{
|
| 247 |
+
"$set": {
|
| 248 |
+
"session_id": session_id,
|
| 249 |
+
"idx": idx,
|
| 250 |
+
"message": _safe_message_doc(raw),
|
| 251 |
+
"updated_at": now,
|
| 252 |
+
},
|
| 253 |
+
"$setOnInsert": {"created_at": now},
|
| 254 |
+
},
|
| 255 |
+
upsert=True,
|
| 256 |
+
)
|
| 257 |
+
)
|
| 258 |
+
ops.append(DeleteMany({"session_id": session_id, "idx": {"$gte": len(messages)}}))
|
| 259 |
+
try:
|
| 260 |
+
if ops:
|
| 261 |
+
await self.db.session_messages.bulk_write(ops, ordered=False)
|
| 262 |
+
except PyMongoError as e:
|
| 263 |
+
logger.warning("Failed to persist session %s snapshot: %s", session_id, e)
|
| 264 |
+
|
| 265 |
+
async def load_session(
|
| 266 |
+
self, session_id: str, *, include_deleted: bool = False
|
| 267 |
+
) -> dict[str, Any] | None:
|
| 268 |
+
if not self._ready():
|
| 269 |
+
return None
|
| 270 |
+
meta = await self.db.sessions.find_one({"_id": session_id})
|
| 271 |
+
if not meta:
|
| 272 |
+
return None
|
| 273 |
+
if meta.get("visibility") == "deleted" and not include_deleted:
|
| 274 |
+
return None
|
| 275 |
+
cursor = self.db.session_messages.find({"session_id": session_id}).sort("idx", 1)
|
| 276 |
+
messages = [row.get("message") async for row in cursor]
|
| 277 |
+
return {"metadata": meta, "messages": messages}
|
| 278 |
+
|
| 279 |
+
async def list_sessions(
|
| 280 |
+
self, user_id: str, *, include_deleted: bool = False
|
| 281 |
+
) -> list[dict[str, Any]]:
|
| 282 |
+
if not self._ready():
|
| 283 |
+
return []
|
| 284 |
+
query: dict[str, Any] = {"user_id": user_id}
|
| 285 |
+
if user_id == "dev":
|
| 286 |
+
query = {}
|
| 287 |
+
if not include_deleted:
|
| 288 |
+
query["visibility"] = {"$ne": "deleted"}
|
| 289 |
+
cursor = self.db.sessions.find(query).sort("updated_at", -1)
|
| 290 |
+
return [row async for row in cursor]
|
| 291 |
+
|
| 292 |
+
async def soft_delete_session(self, session_id: str) -> None:
|
| 293 |
+
if not self._ready():
|
| 294 |
+
return
|
| 295 |
+
await self.db.sessions.update_one(
|
| 296 |
+
{"_id": session_id},
|
| 297 |
+
{
|
| 298 |
+
"$set": {
|
| 299 |
+
"visibility": "deleted",
|
| 300 |
+
"runtime_state": "idle",
|
| 301 |
+
"updated_at": _now(),
|
| 302 |
+
}
|
| 303 |
+
},
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
async def update_session_fields(self, session_id: str, **fields: Any) -> None:
|
| 307 |
+
if not self._ready() or not fields:
|
| 308 |
+
return
|
| 309 |
+
fields["updated_at"] = _now()
|
| 310 |
+
await self.db.sessions.update_one({"_id": session_id}, {"$set": fields})
|
| 311 |
+
|
| 312 |
+
async def _next_seq(self, counter_id: str) -> int:
|
| 313 |
+
doc = await self.db.counters.find_one_and_update(
|
| 314 |
+
{"_id": counter_id},
|
| 315 |
+
{"$inc": {"seq": 1}},
|
| 316 |
+
upsert=True,
|
| 317 |
+
return_document=ReturnDocument.AFTER,
|
| 318 |
+
)
|
| 319 |
+
return int(doc["seq"])
|
| 320 |
+
|
| 321 |
+
async def append_event(
|
| 322 |
+
self, session_id: str, event_type: str, data: dict[str, Any] | None
|
| 323 |
+
) -> int | None:
|
| 324 |
+
if not self._ready():
|
| 325 |
+
return None
|
| 326 |
+
try:
|
| 327 |
+
seq = await self._next_seq(f"event:{session_id}")
|
| 328 |
+
await self.db.session_events.insert_one(
|
| 329 |
+
{
|
| 330 |
+
"_id": _doc_id(session_id, seq),
|
| 331 |
+
"session_id": session_id,
|
| 332 |
+
"seq": seq,
|
| 333 |
+
"event_type": event_type,
|
| 334 |
+
"data": data or {},
|
| 335 |
+
"created_at": _now(),
|
| 336 |
+
}
|
| 337 |
+
)
|
| 338 |
+
return seq
|
| 339 |
+
except PyMongoError as e:
|
| 340 |
+
logger.debug("Failed to append event for %s: %s", session_id, e)
|
| 341 |
+
return None
|
| 342 |
+
|
| 343 |
+
async def load_events_after(self, session_id: str, after_seq: int = 0) -> list[dict[str, Any]]:
|
| 344 |
+
if not self._ready():
|
| 345 |
+
return []
|
| 346 |
+
cursor = self.db.session_events.find(
|
| 347 |
+
{"session_id": session_id, "seq": {"$gt": int(after_seq or 0)}}
|
| 348 |
+
).sort("seq", 1)
|
| 349 |
+
return [row async for row in cursor]
|
| 350 |
+
|
| 351 |
+
async def append_trace_message(
|
| 352 |
+
self, session_id: str, message: dict[str, Any], source: str = "message"
|
| 353 |
+
) -> int | None:
|
| 354 |
+
if not self._ready():
|
| 355 |
+
return None
|
| 356 |
+
try:
|
| 357 |
+
seq = await self._next_seq(f"trace:{session_id}")
|
| 358 |
+
await self.db.session_trace_messages.insert_one(
|
| 359 |
+
{
|
| 360 |
+
"_id": _doc_id(session_id, seq),
|
| 361 |
+
"session_id": session_id,
|
| 362 |
+
"seq": seq,
|
| 363 |
+
"role": message.get("role"),
|
| 364 |
+
"message": _safe_message_doc(message),
|
| 365 |
+
"source": source,
|
| 366 |
+
"created_at": _now(),
|
| 367 |
+
}
|
| 368 |
+
)
|
| 369 |
+
return seq
|
| 370 |
+
except PyMongoError as e:
|
| 371 |
+
logger.debug("Failed to append trace message for %s: %s", session_id, e)
|
| 372 |
+
return None
|
| 373 |
+
|
| 374 |
+
async def get_quota(self, user_id: str, day: str) -> int | None:
|
| 375 |
+
if not self._ready():
|
| 376 |
+
return None
|
| 377 |
+
doc = await self.db.claude_quotas.find_one({"_id": f"{user_id}:{day}"})
|
| 378 |
+
return int(doc.get("count", 0)) if doc else 0
|
| 379 |
+
|
| 380 |
+
async def try_increment_quota(self, user_id: str, day: str, cap: int) -> int | None:
|
| 381 |
+
if not self._ready():
|
| 382 |
+
return None
|
| 383 |
+
key = f"{user_id}:{day}"
|
| 384 |
+
now = _now()
|
| 385 |
+
try:
|
| 386 |
+
await self.db.claude_quotas.insert_one(
|
| 387 |
+
{
|
| 388 |
+
"_id": key,
|
| 389 |
+
"user_id": user_id,
|
| 390 |
+
"day": day,
|
| 391 |
+
"count": 1,
|
| 392 |
+
"updated_at": now,
|
| 393 |
+
}
|
| 394 |
+
)
|
| 395 |
+
return 1
|
| 396 |
+
except DuplicateKeyError:
|
| 397 |
+
pass
|
| 398 |
+
doc = await self.db.claude_quotas.find_one_and_update(
|
| 399 |
+
{"_id": key, "count": {"$lt": cap}},
|
| 400 |
+
{"$inc": {"count": 1}, "$set": {"updated_at": now}},
|
| 401 |
+
return_document=ReturnDocument.AFTER,
|
| 402 |
+
)
|
| 403 |
+
return int(doc["count"]) if doc else None
|
| 404 |
+
|
| 405 |
+
async def refund_quota(self, user_id: str, day: str) -> None:
|
| 406 |
+
if not self._ready():
|
| 407 |
+
return
|
| 408 |
+
await self.db.claude_quotas.update_one(
|
| 409 |
+
{"_id": f"{user_id}:{day}", "count": {"$gt": 0}},
|
| 410 |
+
{"$inc": {"count": -1}, "$set": {"updated_at": _now()}},
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
_store: NoopSessionStore | MongoSessionStore | None = None
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def get_session_store() -> NoopSessionStore | MongoSessionStore:
|
| 418 |
+
global _store
|
| 419 |
+
if _store is None:
|
| 420 |
+
uri = os.environ.get("MONGODB_URI")
|
| 421 |
+
db_name = os.environ.get("MONGODB_DB", "ml-intern")
|
| 422 |
+
_store = MongoSessionStore(uri, db_name) if uri else NoopSessionStore()
|
| 423 |
+
return _store
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _reset_store_for_tests(store: NoopSessionStore | MongoSessionStore | None = None) -> None:
|
| 427 |
+
global _store
|
| 428 |
+
_store = store
|
|
@@ -6,6 +6,11 @@ from contextlib import asynccontextmanager
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
from dotenv import load_dotenv
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from fastapi import FastAPI
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
|
@@ -13,9 +18,6 @@ from routes.agent import router as agent_router
|
|
| 13 |
from routes.auth import router as auth_router
|
| 14 |
from session_manager import session_manager
|
| 15 |
|
| 16 |
-
# Load .env from project root (parent directory)
|
| 17 |
-
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 18 |
-
|
| 19 |
# Configure logging
|
| 20 |
logging.basicConfig(
|
| 21 |
level=logging.INFO,
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
# Load .env before importing routes/session_manager so persistence and quota
|
| 11 |
+
# modules see local Mongo settings during startup.
|
| 12 |
+
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 13 |
+
|
| 14 |
from fastapi import FastAPI
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 18 |
from routes.auth import router as auth_router
|
| 19 |
from session_manager import session_manager
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
# Configure logging
|
| 22 |
logging.basicConfig(
|
| 23 |
level=logging.INFO,
|
|
@@ -87,6 +87,7 @@ class SessionInfo(BaseModel):
|
|
| 87 |
user_id: str = "dev"
|
| 88 |
pending_approval: list[PendingApprovalTool] | None = None
|
| 89 |
model: str | None = None
|
|
|
|
| 90 |
notification_destinations: list[str] = Field(default_factory=list)
|
| 91 |
|
| 92 |
|
|
|
|
| 87 |
user_id: str = "dev"
|
| 88 |
pending_approval: list[PendingApprovalTool] | None = None
|
| 89 |
model: str | None = None
|
| 90 |
+
title: str | None = None
|
| 91 |
notification_destinations: list[str] = Field(default_factory=list)
|
| 92 |
|
| 93 |
|
|
@@ -120,9 +120,9 @@ async def _enforce_claude_quota(
|
|
| 120 |
if not _is_anthropic_model(model_name):
|
| 121 |
return
|
| 122 |
user_id = user["user_id"]
|
| 123 |
-
used = await user_quotas.get_claude_used_today(user_id)
|
| 124 |
cap = user_quotas.daily_cap_for(user.get("plan"))
|
| 125 |
-
|
|
|
|
| 126 |
raise HTTPException(
|
| 127 |
status_code=429,
|
| 128 |
detail={
|
|
@@ -135,8 +135,8 @@ async def _enforce_claude_quota(
|
|
| 135 |
),
|
| 136 |
},
|
| 137 |
)
|
| 138 |
-
await user_quotas.increment_claude(user_id)
|
| 139 |
agent_session.claude_counted = True
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
async def _enforce_jobs_access_for_approvals(
|
|
@@ -241,13 +241,23 @@ async def _enforce_jobs_access_for_approvals(
|
|
| 241 |
)
|
| 242 |
|
| 243 |
|
| 244 |
-
def _check_session_access(
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 249 |
-
if
|
| 250 |
raise HTTPException(status_code=403, detail="Access denied to this session")
|
|
|
|
| 251 |
|
| 252 |
|
| 253 |
@router.get("/health", response_model=HealthResponse)
|
|
@@ -369,11 +379,21 @@ async def generate_title(
|
|
| 369 |
title = title.translate(_TITLE_STRIP_CHARS).strip()
|
| 370 |
if len(title) > 50:
|
| 371 |
title = title[:50].rstrip() + "…"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
return {"title": title}
|
| 373 |
except Exception as e:
|
| 374 |
logger.warning(f"Title generation failed: {e}")
|
| 375 |
fallback = request.text.strip()
|
| 376 |
title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
return {"title": title}
|
| 378 |
|
| 379 |
|
|
@@ -477,7 +497,7 @@ async def get_session(
|
|
| 477 |
session_id: str, user: dict = Depends(get_current_user)
|
| 478 |
) -> SessionInfo:
|
| 479 |
"""Get session information. Only accessible by the session owner."""
|
| 480 |
-
_check_session_access(session_id, user)
|
| 481 |
info = session_manager.get_session_info(session_id)
|
| 482 |
return SessionInfo(**info)
|
| 483 |
|
|
@@ -498,7 +518,7 @@ async def set_session_model(
|
|
| 498 |
Switching TO an Anthropic model requires HF org membership (PR #63);
|
| 499 |
free-model switches are unrestricted.
|
| 500 |
"""
|
| 501 |
-
_check_session_access(session_id, user)
|
| 502 |
model_id = body.get("model")
|
| 503 |
if not model_id:
|
| 504 |
raise HTTPException(status_code=400, detail="Missing 'model' field")
|
|
@@ -506,10 +526,9 @@ async def set_session_model(
|
|
| 506 |
if model_id not in valid_ids:
|
| 507 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
|
| 508 |
await _require_hf_for_anthropic(request, model_id)
|
| 509 |
-
agent_session = session_manager.sessions.get(session_id)
|
| 510 |
if not agent_session:
|
| 511 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 512 |
-
|
| 513 |
logger.info(
|
| 514 |
f"Session {session_id} model → {model_id} "
|
| 515 |
f"(by {user.get('username', 'unknown')})"
|
|
@@ -524,13 +543,14 @@ async def set_session_notifications(
|
|
| 524 |
user: dict = Depends(get_current_user),
|
| 525 |
) -> dict:
|
| 526 |
"""Replace the session's auto-notification destinations."""
|
| 527 |
-
_check_session_access(session_id, user)
|
| 528 |
try:
|
| 529 |
destinations = session_manager.set_notification_destinations(
|
| 530 |
session_id, body.destinations
|
| 531 |
)
|
| 532 |
except ValueError as e:
|
| 533 |
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
| 534 |
return {
|
| 535 |
"session_id": session_id,
|
| 536 |
"notification_destinations": destinations,
|
|
@@ -568,7 +588,7 @@ async def get_jobs_access_info(request: Request, user: dict = Depends(get_curren
|
|
| 568 |
@router.get("/sessions", response_model=list[SessionInfo])
|
| 569 |
async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
|
| 570 |
"""List sessions belonging to the authenticated user."""
|
| 571 |
-
sessions = session_manager.list_sessions(user_id=user["user_id"])
|
| 572 |
return [SessionInfo(**s) for s in sessions]
|
| 573 |
|
| 574 |
|
|
@@ -577,7 +597,7 @@ async def delete_session(
|
|
| 577 |
session_id: str, user: dict = Depends(get_current_user)
|
| 578 |
) -> dict:
|
| 579 |
"""Delete a session. Only accessible by the session owner."""
|
| 580 |
-
_check_session_access(session_id, user)
|
| 581 |
success = await session_manager.delete_session(session_id)
|
| 582 |
if not success:
|
| 583 |
raise HTTPException(status_code=404, detail="Session not found")
|
|
@@ -589,10 +609,8 @@ async def submit_input(
|
|
| 589 |
request: SubmitRequest, user: dict = Depends(get_current_user)
|
| 590 |
) -> dict:
|
| 591 |
"""Submit user input to a session. Only accessible by the session owner."""
|
| 592 |
-
_check_session_access(request.session_id, user)
|
| 593 |
-
|
| 594 |
-
if agent_session is not None:
|
| 595 |
-
await _enforce_claude_quota(user, agent_session)
|
| 596 |
success = await session_manager.submit_user_input(request.session_id, request.text)
|
| 597 |
if not success:
|
| 598 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -604,10 +622,7 @@ async def submit_approval(
|
|
| 604 |
request: ApprovalRequest, user: dict = Depends(get_current_user)
|
| 605 |
) -> dict:
|
| 606 |
"""Submit tool approvals to a session. Only accessible by the session owner."""
|
| 607 |
-
_check_session_access(request.session_id, user)
|
| 608 |
-
agent_session = session_manager.sessions.get(request.session_id)
|
| 609 |
-
if agent_session is None:
|
| 610 |
-
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
| 611 |
approvals = [
|
| 612 |
{
|
| 613 |
"tool_call_id": a.tool_call_id,
|
|
@@ -632,9 +647,7 @@ async def chat_sse(
|
|
| 632 |
user: dict = Depends(get_current_user),
|
| 633 |
) -> StreamingResponse:
|
| 634 |
"""SSE endpoint: submit input or approval, then stream events until turn ends."""
|
| 635 |
-
_check_session_access(session_id, user)
|
| 636 |
-
|
| 637 |
-
agent_session = session_manager.sessions.get(session_id)
|
| 638 |
if not agent_session or not agent_session.is_active:
|
| 639 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
| 640 |
|
|
@@ -700,10 +713,7 @@ async def record_pro_click(
|
|
| 700 |
user: dict = Depends(get_current_user),
|
| 701 |
) -> dict:
|
| 702 |
"""Record a click on a Pro upgrade CTA shown from inside a session."""
|
| 703 |
-
_check_session_access(session_id, user)
|
| 704 |
-
agent_session = session_manager.sessions.get(session_id)
|
| 705 |
-
if not agent_session:
|
| 706 |
-
raise HTTPException(status_code=404, detail="Session not found")
|
| 707 |
|
| 708 |
from agent.core import telemetry
|
| 709 |
await telemetry.record_pro_cta_click(
|
|
@@ -725,12 +735,53 @@ _TERMINAL_EVENTS = {"turn_complete", "approval_required", "error", "interrupted"
|
|
| 725 |
_SSE_KEEPALIVE_SECONDS = 15
|
| 726 |
|
| 727 |
|
| 728 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
"""Build a StreamingResponse that drains *event_queue* as SSE,
|
| 730 |
sending keepalive comments every 15 s to prevent proxy timeouts."""
|
| 731 |
|
| 732 |
async def event_generator():
|
| 733 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
while True:
|
| 735 |
try:
|
| 736 |
msg = await asyncio.wait_for(
|
|
@@ -741,7 +792,7 @@ def _sse_response(broadcaster, event_queue, sub_id) -> StreamingResponse:
|
|
| 741 |
yield ": keepalive\n\n"
|
| 742 |
continue
|
| 743 |
event_type = msg.get("event_type", "")
|
| 744 |
-
yield
|
| 745 |
if event_type in _TERMINAL_EVENTS:
|
| 746 |
break
|
| 747 |
finally:
|
|
@@ -761,6 +812,7 @@ def _sse_response(broadcaster, event_queue, sub_id) -> StreamingResponse:
|
|
| 761 |
@router.get("/events/{session_id}")
|
| 762 |
async def subscribe_events(
|
| 763 |
session_id: str,
|
|
|
|
| 764 |
user: dict = Depends(get_current_user),
|
| 765 |
) -> StreamingResponse:
|
| 766 |
"""Subscribe to events for a running session without submitting new input.
|
|
@@ -768,15 +820,21 @@ async def subscribe_events(
|
|
| 768 |
Used by the frontend to re-attach after a connection drop (e.g. screen
|
| 769 |
sleep). Returns 404 if the session isn't active or isn't processing.
|
| 770 |
"""
|
| 771 |
-
_check_session_access(session_id, user)
|
| 772 |
-
|
| 773 |
-
agent_session = session_manager.sessions.get(session_id)
|
| 774 |
if not agent_session or not agent_session.is_active:
|
| 775 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
| 776 |
|
|
|
|
|
|
|
| 777 |
broadcaster = agent_session.broadcaster
|
| 778 |
sub_id, event_queue = broadcaster.subscribe()
|
| 779 |
-
return _sse_response(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
|
| 781 |
|
| 782 |
@router.post("/interrupt/{session_id}")
|
|
@@ -784,7 +842,7 @@ async def interrupt_session(
|
|
| 784 |
session_id: str, user: dict = Depends(get_current_user)
|
| 785 |
) -> dict:
|
| 786 |
"""Interrupt the current operation in a session."""
|
| 787 |
-
_check_session_access(session_id, user)
|
| 788 |
success = await session_manager.interrupt(session_id)
|
| 789 |
if not success:
|
| 790 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -796,17 +854,16 @@ async def get_session_messages(
|
|
| 796 |
session_id: str, user: dict = Depends(get_current_user)
|
| 797 |
) -> list[dict]:
|
| 798 |
"""Return the session's message history from memory."""
|
| 799 |
-
_check_session_access(session_id, user)
|
| 800 |
-
agent_session = session_manager.sessions.get(session_id)
|
| 801 |
if not agent_session or not agent_session.is_active:
|
| 802 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
| 803 |
-
return [msg.model_dump() for msg in agent_session.session.context_manager.items]
|
| 804 |
|
| 805 |
|
| 806 |
@router.post("/undo/{session_id}")
|
| 807 |
async def undo_session(session_id: str, user: dict = Depends(get_current_user)) -> dict:
|
| 808 |
"""Undo the last turn in a session."""
|
| 809 |
-
_check_session_access(session_id, user)
|
| 810 |
success = await session_manager.undo(session_id)
|
| 811 |
if not success:
|
| 812 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -818,7 +875,7 @@ async def truncate_session(
|
|
| 818 |
session_id: str, body: TruncateRequest, user: dict = Depends(get_current_user)
|
| 819 |
) -> dict:
|
| 820 |
"""Truncate conversation to before a specific user message."""
|
| 821 |
-
_check_session_access(session_id, user)
|
| 822 |
success = await session_manager.truncate(session_id, body.user_message_index)
|
| 823 |
if not success:
|
| 824 |
raise HTTPException(status_code=404, detail="Session not found, inactive, or message index out of range")
|
|
@@ -830,7 +887,7 @@ async def compact_session(
|
|
| 830 |
session_id: str, user: dict = Depends(get_current_user)
|
| 831 |
) -> dict:
|
| 832 |
"""Compact the context in a session."""
|
| 833 |
-
_check_session_access(session_id, user)
|
| 834 |
success = await session_manager.compact(session_id)
|
| 835 |
if not success:
|
| 836 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -842,7 +899,7 @@ async def shutdown_session(
|
|
| 842 |
session_id: str, user: dict = Depends(get_current_user)
|
| 843 |
) -> dict:
|
| 844 |
"""Shutdown a session."""
|
| 845 |
-
_check_session_access(session_id, user)
|
| 846 |
success = await session_manager.shutdown_session(session_id)
|
| 847 |
if not success:
|
| 848 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -860,10 +917,7 @@ async def submit_feedback(
|
|
| 860 |
turn_index?: int, comment?: str, message_id?: str}
|
| 861 |
Appended as a `feedback` event and saved with the session trajectory.
|
| 862 |
"""
|
| 863 |
-
_check_session_access(session_id, user)
|
| 864 |
-
agent_session = session_manager.sessions.get(session_id)
|
| 865 |
-
if not agent_session:
|
| 866 |
-
raise HTTPException(status_code=404, detail="Session not found")
|
| 867 |
|
| 868 |
rating = body.get("rating")
|
| 869 |
if rating not in {"up", "down", "outcome_success", "outcome_fail"}:
|
|
|
|
| 120 |
if not _is_anthropic_model(model_name):
|
| 121 |
return
|
| 122 |
user_id = user["user_id"]
|
|
|
|
| 123 |
cap = user_quotas.daily_cap_for(user.get("plan"))
|
| 124 |
+
new_count = await user_quotas.try_increment_claude(user_id, cap)
|
| 125 |
+
if new_count is None:
|
| 126 |
raise HTTPException(
|
| 127 |
status_code=429,
|
| 128 |
detail={
|
|
|
|
| 135 |
),
|
| 136 |
},
|
| 137 |
)
|
|
|
|
| 138 |
agent_session.claude_counted = True
|
| 139 |
+
await session_manager.persist_session_snapshot(agent_session)
|
| 140 |
|
| 141 |
|
| 142 |
async def _enforce_jobs_access_for_approvals(
|
|
|
|
| 241 |
)
|
| 242 |
|
| 243 |
|
| 244 |
+
async def _check_session_access(
|
| 245 |
+
session_id: str,
|
| 246 |
+
user: dict[str, Any],
|
| 247 |
+
request: Request | None = None,
|
| 248 |
+
) -> AgentSession:
|
| 249 |
+
"""Verify and lazily load the user's session. Raises 403 or 404."""
|
| 250 |
+
hf_token = resolve_hf_request_token(request) if request is not None else user.get("hf_token")
|
| 251 |
+
agent_session = await session_manager.ensure_session_loaded(
|
| 252 |
+
session_id,
|
| 253 |
+
user["user_id"],
|
| 254 |
+
hf_token=hf_token,
|
| 255 |
+
)
|
| 256 |
+
if not agent_session:
|
| 257 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 258 |
+
if user["user_id"] != "dev" and agent_session.user_id not in {user["user_id"], "dev"}:
|
| 259 |
raise HTTPException(status_code=403, detail="Access denied to this session")
|
| 260 |
+
return agent_session
|
| 261 |
|
| 262 |
|
| 263 |
@router.get("/health", response_model=HealthResponse)
|
|
|
|
| 379 |
title = title.translate(_TITLE_STRIP_CHARS).strip()
|
| 380 |
if len(title) > 50:
|
| 381 |
title = title[:50].rstrip() + "…"
|
| 382 |
+
try:
|
| 383 |
+
await _check_session_access(request.session_id, user)
|
| 384 |
+
await session_manager.update_session_title(request.session_id, title)
|
| 385 |
+
except Exception:
|
| 386 |
+
logger.debug("Skipping title persistence for missing session %s", request.session_id)
|
| 387 |
return {"title": title}
|
| 388 |
except Exception as e:
|
| 389 |
logger.warning(f"Title generation failed: {e}")
|
| 390 |
fallback = request.text.strip()
|
| 391 |
title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
|
| 392 |
+
try:
|
| 393 |
+
await _check_session_access(request.session_id, user)
|
| 394 |
+
await session_manager.update_session_title(request.session_id, title)
|
| 395 |
+
except Exception:
|
| 396 |
+
logger.debug("Skipping fallback title persistence for missing session %s", request.session_id)
|
| 397 |
return {"title": title}
|
| 398 |
|
| 399 |
|
|
|
|
| 497 |
session_id: str, user: dict = Depends(get_current_user)
|
| 498 |
) -> SessionInfo:
|
| 499 |
"""Get session information. Only accessible by the session owner."""
|
| 500 |
+
await _check_session_access(session_id, user)
|
| 501 |
info = session_manager.get_session_info(session_id)
|
| 502 |
return SessionInfo(**info)
|
| 503 |
|
|
|
|
| 518 |
Switching TO an Anthropic model requires HF org membership (PR #63);
|
| 519 |
free-model switches are unrestricted.
|
| 520 |
"""
|
| 521 |
+
agent_session = await _check_session_access(session_id, user, request)
|
| 522 |
model_id = body.get("model")
|
| 523 |
if not model_id:
|
| 524 |
raise HTTPException(status_code=400, detail="Missing 'model' field")
|
|
|
|
| 526 |
if model_id not in valid_ids:
|
| 527 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
|
| 528 |
await _require_hf_for_anthropic(request, model_id)
|
|
|
|
| 529 |
if not agent_session:
|
| 530 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 531 |
+
await session_manager.update_session_model(session_id, model_id)
|
| 532 |
logger.info(
|
| 533 |
f"Session {session_id} model → {model_id} "
|
| 534 |
f"(by {user.get('username', 'unknown')})"
|
|
|
|
| 543 |
user: dict = Depends(get_current_user),
|
| 544 |
) -> dict:
|
| 545 |
"""Replace the session's auto-notification destinations."""
|
| 546 |
+
agent_session = await _check_session_access(session_id, user)
|
| 547 |
try:
|
| 548 |
destinations = session_manager.set_notification_destinations(
|
| 549 |
session_id, body.destinations
|
| 550 |
)
|
| 551 |
except ValueError as e:
|
| 552 |
raise HTTPException(status_code=400, detail=str(e))
|
| 553 |
+
await session_manager.persist_session_snapshot(agent_session)
|
| 554 |
return {
|
| 555 |
"session_id": session_id,
|
| 556 |
"notification_destinations": destinations,
|
|
|
|
| 588 |
@router.get("/sessions", response_model=list[SessionInfo])
|
| 589 |
async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
|
| 590 |
"""List sessions belonging to the authenticated user."""
|
| 591 |
+
sessions = await session_manager.list_sessions(user_id=user["user_id"])
|
| 592 |
return [SessionInfo(**s) for s in sessions]
|
| 593 |
|
| 594 |
|
|
|
|
| 597 |
session_id: str, user: dict = Depends(get_current_user)
|
| 598 |
) -> dict:
|
| 599 |
"""Delete a session. Only accessible by the session owner."""
|
| 600 |
+
await _check_session_access(session_id, user)
|
| 601 |
success = await session_manager.delete_session(session_id)
|
| 602 |
if not success:
|
| 603 |
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
| 609 |
request: SubmitRequest, user: dict = Depends(get_current_user)
|
| 610 |
) -> dict:
|
| 611 |
"""Submit user input to a session. Only accessible by the session owner."""
|
| 612 |
+
agent_session = await _check_session_access(request.session_id, user)
|
| 613 |
+
await _enforce_claude_quota(user, agent_session)
|
|
|
|
|
|
|
| 614 |
success = await session_manager.submit_user_input(request.session_id, request.text)
|
| 615 |
if not success:
|
| 616 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 622 |
request: ApprovalRequest, user: dict = Depends(get_current_user)
|
| 623 |
) -> dict:
|
| 624 |
"""Submit tool approvals to a session. Only accessible by the session owner."""
|
| 625 |
+
agent_session = await _check_session_access(request.session_id, user)
|
|
|
|
|
|
|
|
|
|
| 626 |
approvals = [
|
| 627 |
{
|
| 628 |
"tool_call_id": a.tool_call_id,
|
|
|
|
| 647 |
user: dict = Depends(get_current_user),
|
| 648 |
) -> StreamingResponse:
|
| 649 |
"""SSE endpoint: submit input or approval, then stream events until turn ends."""
|
| 650 |
+
agent_session = await _check_session_access(session_id, user, request)
|
|
|
|
|
|
|
| 651 |
if not agent_session or not agent_session.is_active:
|
| 652 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
| 653 |
|
|
|
|
| 713 |
user: dict = Depends(get_current_user),
|
| 714 |
) -> dict:
|
| 715 |
"""Record a click on a Pro upgrade CTA shown from inside a session."""
|
| 716 |
+
agent_session = await _check_session_access(session_id, user)
|
|
|
|
|
|
|
|
|
|
| 717 |
|
| 718 |
from agent.core import telemetry
|
| 719 |
await telemetry.record_pro_cta_click(
|
|
|
|
| 735 |
_SSE_KEEPALIVE_SECONDS = 15
|
| 736 |
|
| 737 |
|
| 738 |
+
def _last_event_seq(request: Request) -> int:
|
| 739 |
+
raw = request.headers.get("last-event-id") or request.query_params.get("after") or "0"
|
| 740 |
+
try:
|
| 741 |
+
return max(0, int(raw))
|
| 742 |
+
except (TypeError, ValueError):
|
| 743 |
+
return 0
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def _format_sse(msg: dict[str, Any]) -> str:
|
| 747 |
+
seq = msg.get("seq")
|
| 748 |
+
body = {"event_type": msg.get("event_type"), "data": msg.get("data") or {}}
|
| 749 |
+
if seq is not None:
|
| 750 |
+
body["seq"] = seq
|
| 751 |
+
return f"id: {seq}\ndata: {json.dumps(body)}\n\n"
|
| 752 |
+
return f"data: {json.dumps(body)}\n\n"
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
def _event_doc_to_msg(doc: dict[str, Any]) -> dict[str, Any]:
|
| 756 |
+
return {
|
| 757 |
+
"event_type": doc.get("event_type"),
|
| 758 |
+
"data": doc.get("data") or {},
|
| 759 |
+
"seq": doc.get("seq"),
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def _sse_response(
|
| 764 |
+
broadcaster,
|
| 765 |
+
event_queue,
|
| 766 |
+
sub_id,
|
| 767 |
+
*,
|
| 768 |
+
replay_events: list[dict[str, Any]] | None = None,
|
| 769 |
+
after_seq: int = 0,
|
| 770 |
+
) -> StreamingResponse:
|
| 771 |
"""Build a StreamingResponse that drains *event_queue* as SSE,
|
| 772 |
sending keepalive comments every 15 s to prevent proxy timeouts."""
|
| 773 |
|
| 774 |
async def event_generator():
|
| 775 |
try:
|
| 776 |
+
for doc in replay_events or []:
|
| 777 |
+
msg = _event_doc_to_msg(doc)
|
| 778 |
+
seq = msg.get("seq")
|
| 779 |
+
if isinstance(seq, int) and seq <= after_seq:
|
| 780 |
+
continue
|
| 781 |
+
yield _format_sse(msg)
|
| 782 |
+
if msg.get("event_type", "") in _TERMINAL_EVENTS:
|
| 783 |
+
return
|
| 784 |
+
|
| 785 |
while True:
|
| 786 |
try:
|
| 787 |
msg = await asyncio.wait_for(
|
|
|
|
| 792 |
yield ": keepalive\n\n"
|
| 793 |
continue
|
| 794 |
event_type = msg.get("event_type", "")
|
| 795 |
+
yield _format_sse(msg)
|
| 796 |
if event_type in _TERMINAL_EVENTS:
|
| 797 |
break
|
| 798 |
finally:
|
|
|
|
| 812 |
@router.get("/events/{session_id}")
|
| 813 |
async def subscribe_events(
|
| 814 |
session_id: str,
|
| 815 |
+
request: Request,
|
| 816 |
user: dict = Depends(get_current_user),
|
| 817 |
) -> StreamingResponse:
|
| 818 |
"""Subscribe to events for a running session without submitting new input.
|
|
|
|
| 820 |
Used by the frontend to re-attach after a connection drop (e.g. screen
|
| 821 |
sleep). Returns 404 if the session isn't active or isn't processing.
|
| 822 |
"""
|
| 823 |
+
agent_session = await _check_session_access(session_id, user, request)
|
|
|
|
|
|
|
| 824 |
if not agent_session or not agent_session.is_active:
|
| 825 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
| 826 |
|
| 827 |
+
after_seq = _last_event_seq(request)
|
| 828 |
+
replay_events = await session_manager._store().load_events_after(session_id, after_seq)
|
| 829 |
broadcaster = agent_session.broadcaster
|
| 830 |
sub_id, event_queue = broadcaster.subscribe()
|
| 831 |
+
return _sse_response(
|
| 832 |
+
broadcaster,
|
| 833 |
+
event_queue,
|
| 834 |
+
sub_id,
|
| 835 |
+
replay_events=replay_events,
|
| 836 |
+
after_seq=after_seq,
|
| 837 |
+
)
|
| 838 |
|
| 839 |
|
| 840 |
@router.post("/interrupt/{session_id}")
|
|
|
|
| 842 |
session_id: str, user: dict = Depends(get_current_user)
|
| 843 |
) -> dict:
|
| 844 |
"""Interrupt the current operation in a session."""
|
| 845 |
+
await _check_session_access(session_id, user)
|
| 846 |
success = await session_manager.interrupt(session_id)
|
| 847 |
if not success:
|
| 848 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 854 |
session_id: str, user: dict = Depends(get_current_user)
|
| 855 |
) -> list[dict]:
|
| 856 |
"""Return the session's message history from memory."""
|
| 857 |
+
agent_session = await _check_session_access(session_id, user)
|
|
|
|
| 858 |
if not agent_session or not agent_session.is_active:
|
| 859 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
| 860 |
+
return [msg.model_dump(mode="json") for msg in agent_session.session.context_manager.items]
|
| 861 |
|
| 862 |
|
| 863 |
@router.post("/undo/{session_id}")
|
| 864 |
async def undo_session(session_id: str, user: dict = Depends(get_current_user)) -> dict:
|
| 865 |
"""Undo the last turn in a session."""
|
| 866 |
+
await _check_session_access(session_id, user)
|
| 867 |
success = await session_manager.undo(session_id)
|
| 868 |
if not success:
|
| 869 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 875 |
session_id: str, body: TruncateRequest, user: dict = Depends(get_current_user)
|
| 876 |
) -> dict:
|
| 877 |
"""Truncate conversation to before a specific user message."""
|
| 878 |
+
await _check_session_access(session_id, user)
|
| 879 |
success = await session_manager.truncate(session_id, body.user_message_index)
|
| 880 |
if not success:
|
| 881 |
raise HTTPException(status_code=404, detail="Session not found, inactive, or message index out of range")
|
|
|
|
| 887 |
session_id: str, user: dict = Depends(get_current_user)
|
| 888 |
) -> dict:
|
| 889 |
"""Compact the context in a session."""
|
| 890 |
+
await _check_session_access(session_id, user)
|
| 891 |
success = await session_manager.compact(session_id)
|
| 892 |
if not success:
|
| 893 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 899 |
session_id: str, user: dict = Depends(get_current_user)
|
| 900 |
) -> dict:
|
| 901 |
"""Shutdown a session."""
|
| 902 |
+
await _check_session_access(session_id, user)
|
| 903 |
success = await session_manager.shutdown_session(session_id)
|
| 904 |
if not success:
|
| 905 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 917 |
turn_index?: int, comment?: str, message_id?: str}
|
| 918 |
Appended as a `feedback` event and saved with the session trajectory.
|
| 919 |
"""
|
| 920 |
+
agent_session = await _check_session_access(session_id, user)
|
|
|
|
|
|
|
|
|
|
| 921 |
|
| 922 |
rating = body.get("rating")
|
| 923 |
if rating not in {"up", "down", "outcome_success", "outcome_fail"}:
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Session manager for handling multiple concurrent agent sessions."""
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
| 4 |
import logging
|
| 5 |
import uuid
|
| 6 |
from dataclasses import dataclass, field
|
|
@@ -12,6 +13,7 @@ from agent.config import load_config
|
|
| 12 |
from agent.core.agent_loop import process_submission
|
| 13 |
from agent.messaging.gateway import NotificationGateway
|
| 14 |
from agent.core.session import Event, OpType, Session
|
|
|
|
| 15 |
from agent.core.tools import ToolRouter
|
| 16 |
|
| 17 |
# Get project root (parent of backend directory)
|
|
@@ -42,9 +44,8 @@ logger = logging.getLogger(__name__)
|
|
| 42 |
class EventBroadcaster:
|
| 43 |
"""Reads from the agent's event queue and fans out to SSE subscribers.
|
| 44 |
|
| 45 |
-
Events that arrive when no subscribers are listening are discarded
|
| 46 |
-
|
| 47 |
-
scenario that would need buffered replay.
|
| 48 |
"""
|
| 49 |
|
| 50 |
def __init__(self, event_queue: asyncio.Queue):
|
|
@@ -68,7 +69,7 @@ class EventBroadcaster:
|
|
| 68 |
while True:
|
| 69 |
try:
|
| 70 |
event: Event = await self._source.get()
|
| 71 |
-
msg = {"event_type": event.event_type, "data": event.data}
|
| 72 |
for q in self._subscribers.values():
|
| 73 |
await q.put(msg)
|
| 74 |
except asyncio.CancelledError:
|
|
@@ -92,6 +93,7 @@ class AgentSession:
|
|
| 92 |
is_active: bool = True
|
| 93 |
is_processing: bool = False # True while a submission is being executed
|
| 94 |
broadcaster: Any = None
|
|
|
|
| 95 |
# True once this session has been counted against the user's daily
|
| 96 |
# Claude quota. Guards double-counting when the user re-selects an
|
| 97 |
# Anthropic model mid-session.
|
|
@@ -123,14 +125,24 @@ class SessionManager:
|
|
| 123 |
self.messaging_gateway = NotificationGateway(self.config.messaging)
|
| 124 |
self.sessions: dict[str, AgentSession] = {}
|
| 125 |
self._lock = asyncio.Lock()
|
|
|
|
| 126 |
|
| 127 |
async def start(self) -> None:
|
| 128 |
"""Start shared background resources."""
|
|
|
|
|
|
|
| 129 |
await self.messaging_gateway.start()
|
| 130 |
|
| 131 |
async def close(self) -> None:
|
| 132 |
"""Flush and close shared background resources."""
|
| 133 |
await self.messaging_gateway.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
def _count_user_sessions(self, user_id: str) -> int:
|
| 136 |
"""Count active sessions owned by a specific user."""
|
|
@@ -140,6 +152,314 @@ class SessionManager:
|
|
| 140 |
if s.user_id == user_id and s.is_active
|
| 141 |
)
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
async def create_session(
|
| 144 |
self,
|
| 145 |
user_id: str = "dev",
|
|
@@ -188,31 +508,14 @@ class SessionManager:
|
|
| 188 |
event_queue: asyncio.Queue = asyncio.Queue()
|
| 189 |
|
| 190 |
# Run blocking constructors in a thread to keep the event loop responsive.
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
# tab A picking GLM doesn't flip tab B off Claude.
|
| 200 |
-
session_config = self.config.model_copy(deep=True)
|
| 201 |
-
if model:
|
| 202 |
-
session_config.model_name = model
|
| 203 |
-
session = Session(
|
| 204 |
-
event_queue, config=session_config, tool_router=tool_router,
|
| 205 |
-
hf_token=hf_token,
|
| 206 |
-
user_id=user_id,
|
| 207 |
-
notification_gateway=self.messaging_gateway,
|
| 208 |
-
notification_destinations=[],
|
| 209 |
-
session_id=session_id,
|
| 210 |
-
)
|
| 211 |
-
t1 = _time.monotonic()
|
| 212 |
-
logger.info(f"Session initialized in {t1 - t0:.2f}s")
|
| 213 |
-
return tool_router, session
|
| 214 |
-
|
| 215 |
-
tool_router, session = await asyncio.to_thread(_create_session_sync)
|
| 216 |
|
| 217 |
# Create wrapper
|
| 218 |
agent_session = AgentSession(
|
|
@@ -224,14 +527,12 @@ class SessionManager:
|
|
| 224 |
hf_token=hf_token,
|
| 225 |
)
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
task = asyncio.create_task(
|
| 232 |
-
self._run_session(session_id, submission_queue, event_queue, tool_router)
|
| 233 |
)
|
| 234 |
-
|
| 235 |
|
| 236 |
logger.info(f"Created session {session_id} for user {user_id}")
|
| 237 |
return session_id
|
|
@@ -297,6 +598,7 @@ class SessionManager:
|
|
| 297 |
),
|
| 298 |
)
|
| 299 |
session.context_manager.items.append(seed)
|
|
|
|
| 300 |
return len(parsed)
|
| 301 |
|
| 302 |
@staticmethod
|
|
@@ -367,6 +669,7 @@ class SessionManager:
|
|
| 367 |
should_continue = await process_submission(session, submission)
|
| 368 |
finally:
|
| 369 |
agent_session.is_processing = False
|
|
|
|
| 370 |
if not should_continue:
|
| 371 |
break
|
| 372 |
except asyncio.TimeoutError:
|
|
@@ -401,6 +704,11 @@ class SessionManager:
|
|
| 401 |
async with self._lock:
|
| 402 |
if session_id in self.sessions:
|
| 403 |
self.sessions[session_id].is_active = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
logger.info(f"Session {session_id} ended")
|
| 406 |
|
|
@@ -450,7 +758,10 @@ class SessionManager:
|
|
| 450 |
agent_session = self.sessions.get(session_id)
|
| 451 |
if not agent_session or not agent_session.is_active:
|
| 452 |
return False
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
async def compact(self, session_id: str) -> bool:
|
| 456 |
"""Compact context in a session."""
|
|
@@ -475,12 +786,15 @@ class SessionManager:
|
|
| 475 |
return success
|
| 476 |
|
| 477 |
async def delete_session(self, session_id: str) -> bool:
|
| 478 |
-
"""
|
| 479 |
async with self._lock:
|
| 480 |
agent_session = self.sessions.pop(session_id, None)
|
| 481 |
|
| 482 |
if not agent_session:
|
| 483 |
-
|
|
|
|
|
|
|
|
|
|
| 484 |
|
| 485 |
# Clean up sandbox Space before cancelling the task
|
| 486 |
await self._cleanup_sandbox(agent_session.session)
|
|
@@ -495,6 +809,21 @@ class SessionManager:
|
|
| 495 |
|
| 496 |
return True
|
| 497 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
def get_session_owner(self, session_id: str) -> str | None:
|
| 499 |
"""Get the user_id that owns a session, or None if session doesn't exist."""
|
| 500 |
agent_session = self.sessions.get(session_id)
|
|
@@ -522,22 +851,7 @@ class SessionManager:
|
|
| 522 |
if not agent_session:
|
| 523 |
return None
|
| 524 |
|
| 525 |
-
|
| 526 |
-
pending_approval = None
|
| 527 |
-
pa = agent_session.session.pending_approval
|
| 528 |
-
if pa and pa.get("tool_calls"):
|
| 529 |
-
pending_approval = []
|
| 530 |
-
for tc in pa["tool_calls"]:
|
| 531 |
-
import json
|
| 532 |
-
try:
|
| 533 |
-
args = json.loads(tc.function.arguments)
|
| 534 |
-
except (json.JSONDecodeError, AttributeError):
|
| 535 |
-
args = {}
|
| 536 |
-
pending_approval.append({
|
| 537 |
-
"tool": tc.function.name,
|
| 538 |
-
"tool_call_id": tc.id,
|
| 539 |
-
"arguments": args,
|
| 540 |
-
})
|
| 541 |
|
| 542 |
return {
|
| 543 |
"session_id": session_id,
|
|
@@ -548,6 +862,7 @@ class SessionManager:
|
|
| 548 |
"user_id": agent_session.user_id,
|
| 549 |
"pending_approval": pending_approval,
|
| 550 |
"model": agent_session.session.config.model_name,
|
|
|
|
| 551 |
"notification_destinations": list(
|
| 552 |
agent_session.session.notification_destinations
|
| 553 |
),
|
|
@@ -581,14 +896,46 @@ class SessionManager:
|
|
| 581 |
agent_session.session.set_notification_destinations(normalized)
|
| 582 |
return normalized
|
| 583 |
|
| 584 |
-
def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
|
| 585 |
"""List sessions, optionally filtered by user.
|
| 586 |
|
| 587 |
Args:
|
| 588 |
user_id: If provided, only return sessions owned by this user.
|
| 589 |
If "dev", return all sessions (dev mode).
|
| 590 |
"""
|
| 591 |
-
results = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
for sid in self.sessions:
|
| 593 |
info = self.get_session_info(sid)
|
| 594 |
if not info:
|
|
|
|
| 1 |
"""Session manager for handling multiple concurrent agent sessions."""
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
import json
|
| 5 |
import logging
|
| 6 |
import uuid
|
| 7 |
from dataclasses import dataclass, field
|
|
|
|
| 13 |
from agent.core.agent_loop import process_submission
|
| 14 |
from agent.messaging.gateway import NotificationGateway
|
| 15 |
from agent.core.session import Event, OpType, Session
|
| 16 |
+
from agent.core.session_persistence import get_session_store
|
| 17 |
from agent.core.tools import ToolRouter
|
| 18 |
|
| 19 |
# Get project root (parent of backend directory)
|
|
|
|
| 44 |
class EventBroadcaster:
|
| 45 |
"""Reads from the agent's event queue and fans out to SSE subscribers.
|
| 46 |
|
| 47 |
+
Events that arrive when no subscribers are listening are discarded by
|
| 48 |
+
this in-memory fanout. Durable replay is handled by session_persistence.
|
|
|
|
| 49 |
"""
|
| 50 |
|
| 51 |
def __init__(self, event_queue: asyncio.Queue):
|
|
|
|
| 69 |
while True:
|
| 70 |
try:
|
| 71 |
event: Event = await self._source.get()
|
| 72 |
+
msg = {"event_type": event.event_type, "data": event.data, "seq": event.seq}
|
| 73 |
for q in self._subscribers.values():
|
| 74 |
await q.put(msg)
|
| 75 |
except asyncio.CancelledError:
|
|
|
|
| 93 |
is_active: bool = True
|
| 94 |
is_processing: bool = False # True while a submission is being executed
|
| 95 |
broadcaster: Any = None
|
| 96 |
+
title: str | None = None
|
| 97 |
# True once this session has been counted against the user's daily
|
| 98 |
# Claude quota. Guards double-counting when the user re-selects an
|
| 99 |
# Anthropic model mid-session.
|
|
|
|
| 125 |
self.messaging_gateway = NotificationGateway(self.config.messaging)
|
| 126 |
self.sessions: dict[str, AgentSession] = {}
|
| 127 |
self._lock = asyncio.Lock()
|
| 128 |
+
self.persistence_store = None
|
| 129 |
|
| 130 |
async def start(self) -> None:
|
| 131 |
"""Start shared background resources."""
|
| 132 |
+
self.persistence_store = get_session_store()
|
| 133 |
+
await self.persistence_store.init()
|
| 134 |
await self.messaging_gateway.start()
|
| 135 |
|
| 136 |
async def close(self) -> None:
|
| 137 |
"""Flush and close shared background resources."""
|
| 138 |
await self.messaging_gateway.close()
|
| 139 |
+
if self.persistence_store is not None:
|
| 140 |
+
await self.persistence_store.close()
|
| 141 |
+
|
| 142 |
+
def _store(self):
|
| 143 |
+
if self.persistence_store is None:
|
| 144 |
+
self.persistence_store = get_session_store()
|
| 145 |
+
return self.persistence_store
|
| 146 |
|
| 147 |
def _count_user_sessions(self, user_id: str) -> int:
|
| 148 |
"""Count active sessions owned by a specific user."""
|
|
|
|
| 152 |
if s.user_id == user_id and s.is_active
|
| 153 |
)
|
| 154 |
|
| 155 |
+
def _create_session_sync(
|
| 156 |
+
self,
|
| 157 |
+
*,
|
| 158 |
+
session_id: str,
|
| 159 |
+
user_id: str,
|
| 160 |
+
hf_token: str | None,
|
| 161 |
+
model: str | None,
|
| 162 |
+
event_queue: asyncio.Queue,
|
| 163 |
+
notification_destinations: list[str] | None = None,
|
| 164 |
+
) -> tuple[ToolRouter, Session]:
|
| 165 |
+
"""Build blocking per-session resources in a worker thread."""
|
| 166 |
+
import time as _time
|
| 167 |
+
|
| 168 |
+
t0 = _time.monotonic()
|
| 169 |
+
tool_router = ToolRouter(self.config.mcpServers, hf_token=hf_token)
|
| 170 |
+
# Deep-copy config so each session's model switches independently —
|
| 171 |
+
# tab A picking GLM doesn't flip tab B off Claude.
|
| 172 |
+
session_config = self.config.model_copy(deep=True)
|
| 173 |
+
if model:
|
| 174 |
+
session_config.model_name = model
|
| 175 |
+
session = Session(
|
| 176 |
+
event_queue=event_queue,
|
| 177 |
+
config=session_config,
|
| 178 |
+
tool_router=tool_router,
|
| 179 |
+
hf_token=hf_token,
|
| 180 |
+
user_id=user_id,
|
| 181 |
+
notification_gateway=self.messaging_gateway,
|
| 182 |
+
notification_destinations=notification_destinations or [],
|
| 183 |
+
session_id=session_id,
|
| 184 |
+
persistence_store=self._store(),
|
| 185 |
+
)
|
| 186 |
+
t1 = _time.monotonic()
|
| 187 |
+
logger.info("Session initialized in %.2fs", t1 - t0)
|
| 188 |
+
return tool_router, session
|
| 189 |
+
|
| 190 |
+
def _serialize_messages(self, session: Session) -> list[dict[str, Any]]:
|
| 191 |
+
return [
|
| 192 |
+
msg.model_dump(mode="json")
|
| 193 |
+
for msg in session.context_manager.items
|
| 194 |
+
]
|
| 195 |
+
|
| 196 |
+
def _serialize_pending_approval(self, session: Session) -> list[dict[str, Any]]:
|
| 197 |
+
pending = session.pending_approval or {}
|
| 198 |
+
tool_calls = pending.get("tool_calls") or []
|
| 199 |
+
serialized: list[dict[str, Any]] = []
|
| 200 |
+
for tc in tool_calls:
|
| 201 |
+
if hasattr(tc, "model_dump"):
|
| 202 |
+
serialized.append(tc.model_dump(mode="json"))
|
| 203 |
+
elif isinstance(tc, dict):
|
| 204 |
+
serialized.append(tc)
|
| 205 |
+
return serialized
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
def _pending_tools_for_api(session: Session) -> list[dict[str, Any]] | None:
|
| 209 |
+
pending = session.pending_approval or {}
|
| 210 |
+
tool_calls = pending.get("tool_calls") or []
|
| 211 |
+
if not tool_calls:
|
| 212 |
+
return None
|
| 213 |
+
result: list[dict[str, Any]] = []
|
| 214 |
+
for tc in tool_calls:
|
| 215 |
+
try:
|
| 216 |
+
args = json.loads(tc.function.arguments)
|
| 217 |
+
except (json.JSONDecodeError, AttributeError, TypeError):
|
| 218 |
+
args = {}
|
| 219 |
+
result.append(
|
| 220 |
+
{
|
| 221 |
+
"tool": getattr(tc.function, "name", None),
|
| 222 |
+
"tool_call_id": getattr(tc, "id", None),
|
| 223 |
+
"arguments": args,
|
| 224 |
+
}
|
| 225 |
+
)
|
| 226 |
+
return result
|
| 227 |
+
|
| 228 |
+
def _restore_pending_approval(
|
| 229 |
+
self, session: Session, pending_approval: list[dict[str, Any]] | None
|
| 230 |
+
) -> None:
|
| 231 |
+
if not pending_approval:
|
| 232 |
+
session.pending_approval = None
|
| 233 |
+
return
|
| 234 |
+
from litellm import ChatCompletionMessageToolCall as ToolCall
|
| 235 |
+
|
| 236 |
+
restored = []
|
| 237 |
+
for raw in pending_approval:
|
| 238 |
+
try:
|
| 239 |
+
if "function" in raw:
|
| 240 |
+
restored.append(ToolCall(**raw))
|
| 241 |
+
else:
|
| 242 |
+
restored.append(
|
| 243 |
+
ToolCall(
|
| 244 |
+
id=raw["tool_call_id"],
|
| 245 |
+
type="function",
|
| 246 |
+
function={
|
| 247 |
+
"name": raw["tool"],
|
| 248 |
+
"arguments": json.dumps(raw.get("arguments") or {}),
|
| 249 |
+
},
|
| 250 |
+
)
|
| 251 |
+
)
|
| 252 |
+
except Exception as e:
|
| 253 |
+
logger.warning("Dropping malformed pending approval: %s", e)
|
| 254 |
+
session.pending_approval = {"tool_calls": restored} if restored else None
|
| 255 |
+
|
| 256 |
+
@staticmethod
|
| 257 |
+
def _pending_docs_for_api(
|
| 258 |
+
pending_approval: list[dict[str, Any]] | None,
|
| 259 |
+
) -> list[dict[str, Any]] | None:
|
| 260 |
+
if not pending_approval:
|
| 261 |
+
return None
|
| 262 |
+
result: list[dict[str, Any]] = []
|
| 263 |
+
for raw in pending_approval:
|
| 264 |
+
if "function" in raw:
|
| 265 |
+
function = raw.get("function") or {}
|
| 266 |
+
try:
|
| 267 |
+
args = json.loads(function.get("arguments") or "{}")
|
| 268 |
+
except (json.JSONDecodeError, TypeError):
|
| 269 |
+
args = {}
|
| 270 |
+
result.append(
|
| 271 |
+
{
|
| 272 |
+
"tool": function.get("name"),
|
| 273 |
+
"tool_call_id": raw.get("id"),
|
| 274 |
+
"arguments": args,
|
| 275 |
+
}
|
| 276 |
+
)
|
| 277 |
+
elif {"tool", "tool_call_id"}.issubset(raw):
|
| 278 |
+
result.append(
|
| 279 |
+
{
|
| 280 |
+
"tool": raw.get("tool"),
|
| 281 |
+
"tool_call_id": raw.get("tool_call_id"),
|
| 282 |
+
"arguments": raw.get("arguments") or {},
|
| 283 |
+
}
|
| 284 |
+
)
|
| 285 |
+
return result or None
|
| 286 |
+
|
| 287 |
+
@staticmethod
|
| 288 |
+
def _runtime_state(agent_session: AgentSession) -> str:
|
| 289 |
+
if agent_session.session.pending_approval:
|
| 290 |
+
return "waiting_approval"
|
| 291 |
+
if agent_session.is_processing:
|
| 292 |
+
return "processing"
|
| 293 |
+
if not agent_session.is_active:
|
| 294 |
+
return "ended"
|
| 295 |
+
return "idle"
|
| 296 |
+
|
| 297 |
+
async def _start_agent_session(
|
| 298 |
+
self,
|
| 299 |
+
*,
|
| 300 |
+
agent_session: AgentSession,
|
| 301 |
+
event_queue: asyncio.Queue,
|
| 302 |
+
tool_router: ToolRouter,
|
| 303 |
+
) -> AgentSession:
|
| 304 |
+
async with self._lock:
|
| 305 |
+
existing = self.sessions.get(agent_session.session_id)
|
| 306 |
+
if existing:
|
| 307 |
+
return existing
|
| 308 |
+
self.sessions[agent_session.session_id] = agent_session
|
| 309 |
+
|
| 310 |
+
task = asyncio.create_task(
|
| 311 |
+
self._run_session(
|
| 312 |
+
agent_session.session_id,
|
| 313 |
+
agent_session.submission_queue,
|
| 314 |
+
event_queue,
|
| 315 |
+
tool_router,
|
| 316 |
+
)
|
| 317 |
+
)
|
| 318 |
+
agent_session.task = task
|
| 319 |
+
return agent_session
|
| 320 |
+
|
| 321 |
+
@staticmethod
|
| 322 |
+
def _can_access_session(agent_session: AgentSession, user_id: str) -> bool:
|
| 323 |
+
return (
|
| 324 |
+
user_id == "dev"
|
| 325 |
+
or agent_session.user_id == "dev"
|
| 326 |
+
or agent_session.user_id == user_id
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
@staticmethod
|
| 330 |
+
def _update_hf_token(agent_session: AgentSession, hf_token: str | None) -> None:
|
| 331 |
+
if not hf_token:
|
| 332 |
+
return
|
| 333 |
+
agent_session.hf_token = hf_token
|
| 334 |
+
agent_session.session.hf_token = hf_token
|
| 335 |
+
|
| 336 |
+
async def persist_session_snapshot(
|
| 337 |
+
self,
|
| 338 |
+
agent_session: AgentSession,
|
| 339 |
+
*,
|
| 340 |
+
runtime_state: str | None = None,
|
| 341 |
+
status: str = "active",
|
| 342 |
+
) -> None:
|
| 343 |
+
"""Persist the current runtime context snapshot."""
|
| 344 |
+
store = self._store()
|
| 345 |
+
if not getattr(store, "enabled", False):
|
| 346 |
+
return
|
| 347 |
+
try:
|
| 348 |
+
await store.save_snapshot(
|
| 349 |
+
session_id=agent_session.session_id,
|
| 350 |
+
user_id=agent_session.user_id,
|
| 351 |
+
model=agent_session.session.config.model_name,
|
| 352 |
+
title=agent_session.title,
|
| 353 |
+
messages=self._serialize_messages(agent_session.session),
|
| 354 |
+
runtime_state=runtime_state or self._runtime_state(agent_session),
|
| 355 |
+
status=status,
|
| 356 |
+
turn_count=agent_session.session.turn_count,
|
| 357 |
+
pending_approval=self._serialize_pending_approval(agent_session.session),
|
| 358 |
+
claude_counted=agent_session.claude_counted,
|
| 359 |
+
created_at=agent_session.created_at,
|
| 360 |
+
notification_destinations=list(
|
| 361 |
+
agent_session.session.notification_destinations
|
| 362 |
+
),
|
| 363 |
+
)
|
| 364 |
+
except Exception as e:
|
| 365 |
+
logger.warning(
|
| 366 |
+
"Failed to persist snapshot for %s: %s",
|
| 367 |
+
agent_session.session_id,
|
| 368 |
+
e,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
async def ensure_session_loaded(
|
| 372 |
+
self,
|
| 373 |
+
session_id: str,
|
| 374 |
+
user_id: str,
|
| 375 |
+
hf_token: str | None = None,
|
| 376 |
+
) -> AgentSession | None:
|
| 377 |
+
"""Return a live runtime session, lazily restoring it from Mongo."""
|
| 378 |
+
async with self._lock:
|
| 379 |
+
existing = self.sessions.get(session_id)
|
| 380 |
+
if existing:
|
| 381 |
+
if self._can_access_session(existing, user_id):
|
| 382 |
+
self._update_hf_token(existing, hf_token)
|
| 383 |
+
return existing
|
| 384 |
+
return None
|
| 385 |
+
|
| 386 |
+
store = self._store()
|
| 387 |
+
loaded = await store.load_session(session_id)
|
| 388 |
+
if not loaded:
|
| 389 |
+
return None
|
| 390 |
+
|
| 391 |
+
async with self._lock:
|
| 392 |
+
existing = self.sessions.get(session_id)
|
| 393 |
+
if existing:
|
| 394 |
+
if self._can_access_session(existing, user_id):
|
| 395 |
+
self._update_hf_token(existing, hf_token)
|
| 396 |
+
return existing
|
| 397 |
+
return None
|
| 398 |
+
|
| 399 |
+
meta = loaded.get("metadata") or {}
|
| 400 |
+
owner = str(meta.get("user_id") or "")
|
| 401 |
+
if user_id != "dev" and owner != "dev" and owner != user_id:
|
| 402 |
+
return None
|
| 403 |
+
|
| 404 |
+
from litellm import Message
|
| 405 |
+
|
| 406 |
+
model = meta.get("model") or self.config.model_name
|
| 407 |
+
event_queue: asyncio.Queue = asyncio.Queue()
|
| 408 |
+
submission_queue: asyncio.Queue = asyncio.Queue()
|
| 409 |
+
tool_router, session = await asyncio.to_thread(
|
| 410 |
+
self._create_session_sync,
|
| 411 |
+
session_id=session_id,
|
| 412 |
+
user_id=owner or user_id,
|
| 413 |
+
hf_token=hf_token,
|
| 414 |
+
model=model,
|
| 415 |
+
event_queue=event_queue,
|
| 416 |
+
notification_destinations=meta.get("notification_destinations") or [],
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
restored_messages: list[Message] = []
|
| 420 |
+
for raw in loaded.get("messages") or []:
|
| 421 |
+
if not isinstance(raw, dict) or raw.get("role") == "system":
|
| 422 |
+
continue
|
| 423 |
+
try:
|
| 424 |
+
restored_messages.append(Message.model_validate(raw))
|
| 425 |
+
except Exception as e:
|
| 426 |
+
logger.warning("Dropping malformed restored message: %s", e)
|
| 427 |
+
if restored_messages:
|
| 428 |
+
# Keep the freshly-rendered system prompt, then attach the durable
|
| 429 |
+
# non-system context so tools/date/user context stay current.
|
| 430 |
+
session.context_manager.items = [session.context_manager.items[0], *restored_messages]
|
| 431 |
+
|
| 432 |
+
self._restore_pending_approval(session, meta.get("pending_approval") or [])
|
| 433 |
+
session.turn_count = int(meta.get("turn_count") or 0)
|
| 434 |
+
|
| 435 |
+
created_at = meta.get("created_at")
|
| 436 |
+
if not isinstance(created_at, datetime):
|
| 437 |
+
created_at = datetime.utcnow()
|
| 438 |
+
|
| 439 |
+
agent_session = AgentSession(
|
| 440 |
+
session_id=session_id,
|
| 441 |
+
session=session,
|
| 442 |
+
tool_router=tool_router,
|
| 443 |
+
submission_queue=submission_queue,
|
| 444 |
+
user_id=owner or user_id,
|
| 445 |
+
hf_token=hf_token,
|
| 446 |
+
created_at=created_at,
|
| 447 |
+
is_active=True,
|
| 448 |
+
is_processing=False,
|
| 449 |
+
claude_counted=bool(meta.get("claude_counted")),
|
| 450 |
+
title=meta.get("title"),
|
| 451 |
+
)
|
| 452 |
+
started = await self._start_agent_session(
|
| 453 |
+
agent_session=agent_session,
|
| 454 |
+
event_queue=event_queue,
|
| 455 |
+
tool_router=tool_router,
|
| 456 |
+
)
|
| 457 |
+
if started is not agent_session:
|
| 458 |
+
self._update_hf_token(started, hf_token)
|
| 459 |
+
return started
|
| 460 |
+
logger.info("Restored session %s for user %s", session_id, owner or user_id)
|
| 461 |
+
return agent_session
|
| 462 |
+
|
| 463 |
async def create_session(
|
| 464 |
self,
|
| 465 |
user_id: str = "dev",
|
|
|
|
| 508 |
event_queue: asyncio.Queue = asyncio.Queue()
|
| 509 |
|
| 510 |
# Run blocking constructors in a thread to keep the event loop responsive.
|
| 511 |
+
tool_router, session = await asyncio.to_thread(
|
| 512 |
+
self._create_session_sync,
|
| 513 |
+
session_id=session_id,
|
| 514 |
+
user_id=user_id,
|
| 515 |
+
hf_token=hf_token,
|
| 516 |
+
model=model,
|
| 517 |
+
event_queue=event_queue,
|
| 518 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
# Create wrapper
|
| 521 |
agent_session = AgentSession(
|
|
|
|
| 527 |
hf_token=hf_token,
|
| 528 |
)
|
| 529 |
|
| 530 |
+
await self._start_agent_session(
|
| 531 |
+
agent_session=agent_session,
|
| 532 |
+
event_queue=event_queue,
|
| 533 |
+
tool_router=tool_router,
|
|
|
|
|
|
|
| 534 |
)
|
| 535 |
+
await self.persist_session_snapshot(agent_session, runtime_state="idle")
|
| 536 |
|
| 537 |
logger.info(f"Created session {session_id} for user {user_id}")
|
| 538 |
return session_id
|
|
|
|
| 598 |
),
|
| 599 |
)
|
| 600 |
session.context_manager.items.append(seed)
|
| 601 |
+
await self.persist_session_snapshot(agent_session, runtime_state="idle")
|
| 602 |
return len(parsed)
|
| 603 |
|
| 604 |
@staticmethod
|
|
|
|
| 669 |
should_continue = await process_submission(session, submission)
|
| 670 |
finally:
|
| 671 |
agent_session.is_processing = False
|
| 672 |
+
await self.persist_session_snapshot(agent_session)
|
| 673 |
if not should_continue:
|
| 674 |
break
|
| 675 |
except asyncio.TimeoutError:
|
|
|
|
| 704 |
async with self._lock:
|
| 705 |
if session_id in self.sessions:
|
| 706 |
self.sessions[session_id].is_active = False
|
| 707 |
+
await self.persist_session_snapshot(
|
| 708 |
+
self.sessions[session_id],
|
| 709 |
+
runtime_state="ended",
|
| 710 |
+
status="ended",
|
| 711 |
+
)
|
| 712 |
|
| 713 |
logger.info(f"Session {session_id} ended")
|
| 714 |
|
|
|
|
| 758 |
agent_session = self.sessions.get(session_id)
|
| 759 |
if not agent_session or not agent_session.is_active:
|
| 760 |
return False
|
| 761 |
+
success = agent_session.session.context_manager.truncate_to_user_message(user_message_index)
|
| 762 |
+
if success:
|
| 763 |
+
await self.persist_session_snapshot(agent_session, runtime_state="idle")
|
| 764 |
+
return success
|
| 765 |
|
| 766 |
async def compact(self, session_id: str) -> bool:
|
| 767 |
"""Compact context in a session."""
|
|
|
|
| 786 |
return success
|
| 787 |
|
| 788 |
async def delete_session(self, session_id: str) -> bool:
|
| 789 |
+
"""Soft-delete a session and stop its runtime resources."""
|
| 790 |
async with self._lock:
|
| 791 |
agent_session = self.sessions.pop(session_id, None)
|
| 792 |
|
| 793 |
if not agent_session:
|
| 794 |
+
await self._store().soft_delete_session(session_id)
|
| 795 |
+
return True
|
| 796 |
+
|
| 797 |
+
await self._store().soft_delete_session(session_id)
|
| 798 |
|
| 799 |
# Clean up sandbox Space before cancelling the task
|
| 800 |
await self._cleanup_sandbox(agent_session.session)
|
|
|
|
| 809 |
|
| 810 |
return True
|
| 811 |
|
| 812 |
+
async def update_session_title(self, session_id: str, title: str | None) -> None:
|
| 813 |
+
"""Persist a user-visible title for sidebar rehydration."""
|
| 814 |
+
agent_session = self.sessions.get(session_id)
|
| 815 |
+
if agent_session:
|
| 816 |
+
agent_session.title = title
|
| 817 |
+
await self._store().update_session_fields(session_id, title=title)
|
| 818 |
+
|
| 819 |
+
async def update_session_model(self, session_id: str, model_id: str) -> bool:
|
| 820 |
+
agent_session = self.sessions.get(session_id)
|
| 821 |
+
if not agent_session or not agent_session.is_active:
|
| 822 |
+
return False
|
| 823 |
+
agent_session.session.update_model(model_id)
|
| 824 |
+
await self.persist_session_snapshot(agent_session, runtime_state="idle")
|
| 825 |
+
return True
|
| 826 |
+
|
| 827 |
def get_session_owner(self, session_id: str) -> str | None:
|
| 828 |
"""Get the user_id that owns a session, or None if session doesn't exist."""
|
| 829 |
agent_session = self.sessions.get(session_id)
|
|
|
|
| 851 |
if not agent_session:
|
| 852 |
return None
|
| 853 |
|
| 854 |
+
pending_approval = self._pending_tools_for_api(agent_session.session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
|
| 856 |
return {
|
| 857 |
"session_id": session_id,
|
|
|
|
| 862 |
"user_id": agent_session.user_id,
|
| 863 |
"pending_approval": pending_approval,
|
| 864 |
"model": agent_session.session.config.model_name,
|
| 865 |
+
"title": agent_session.title,
|
| 866 |
"notification_destinations": list(
|
| 867 |
agent_session.session.notification_destinations
|
| 868 |
),
|
|
|
|
| 896 |
agent_session.session.set_notification_destinations(normalized)
|
| 897 |
return normalized
|
| 898 |
|
| 899 |
+
async def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
|
| 900 |
"""List sessions, optionally filtered by user.
|
| 901 |
|
| 902 |
Args:
|
| 903 |
user_id: If provided, only return sessions owned by this user.
|
| 904 |
If "dev", return all sessions (dev mode).
|
| 905 |
"""
|
| 906 |
+
results: list[dict[str, Any]] = []
|
| 907 |
+
store = self._store()
|
| 908 |
+
if getattr(store, "enabled", False):
|
| 909 |
+
for row in await store.list_sessions(user_id or "dev"):
|
| 910 |
+
sid = row.get("session_id") or row.get("_id")
|
| 911 |
+
if not sid:
|
| 912 |
+
continue
|
| 913 |
+
runtime_info = self.get_session_info(str(sid))
|
| 914 |
+
if runtime_info:
|
| 915 |
+
results.append(runtime_info)
|
| 916 |
+
continue
|
| 917 |
+
created_at = row.get("created_at")
|
| 918 |
+
if isinstance(created_at, datetime):
|
| 919 |
+
created_at_str = created_at.isoformat()
|
| 920 |
+
else:
|
| 921 |
+
created_at_str = str(created_at or datetime.utcnow().isoformat())
|
| 922 |
+
pending = self._pending_docs_for_api(row.get("pending_approval") or [])
|
| 923 |
+
results.append(
|
| 924 |
+
{
|
| 925 |
+
"session_id": str(sid),
|
| 926 |
+
"created_at": created_at_str,
|
| 927 |
+
"is_active": row.get("status") != "ended",
|
| 928 |
+
"is_processing": row.get("runtime_state") == "processing",
|
| 929 |
+
"message_count": int(row.get("message_count") or 0),
|
| 930 |
+
"user_id": row.get("user_id") or "dev",
|
| 931 |
+
"pending_approval": pending or None,
|
| 932 |
+
"model": row.get("model"),
|
| 933 |
+
"title": row.get("title"),
|
| 934 |
+
"notification_destinations": row.get("notification_destinations") or [],
|
| 935 |
+
}
|
| 936 |
+
)
|
| 937 |
+
return results
|
| 938 |
+
|
| 939 |
for sid in self.sessions:
|
| 940 |
info = self.get_session_info(sid)
|
| 941 |
if not info:
|
|
@@ -1,9 +1,8 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
Tracks per-user Claude session starts against a daily cap derived from the
|
| 4 |
-
user's HF plan.
|
| 5 |
-
|
| 6 |
-
restart is much lower than running a DB).
|
| 7 |
|
| 8 |
Unit: session *creations*, not messages. A user who selects Claude in a new
|
| 9 |
session consumes one quota point; switching an existing Claude session to
|
|
@@ -18,6 +17,8 @@ import asyncio
|
|
| 18 |
import os
|
| 19 |
from datetime import UTC, datetime
|
| 20 |
|
|
|
|
|
|
|
| 21 |
CLAUDE_FREE_DAILY: int = int(os.environ.get("CLAUDE_FREE_DAILY", "1"))
|
| 22 |
CLAUDE_PRO_DAILY: int = int(os.environ.get("CLAUDE_PRO_DAILY", "20"))
|
| 23 |
|
|
@@ -37,6 +38,11 @@ def daily_cap_for(plan: str | None) -> int:
|
|
| 37 |
|
| 38 |
async def get_claude_used_today(user_id: str) -> int:
|
| 39 |
"""Return today's Claude session count for the user (0 if none / stale day)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
async with _lock:
|
| 41 |
entry = _claude_counts.get(user_id)
|
| 42 |
if entry is None:
|
|
@@ -51,11 +57,37 @@ async def get_claude_used_today(user_id: str) -> int:
|
|
| 51 |
|
| 52 |
async def increment_claude(user_id: str) -> int:
|
| 53 |
"""Bump today's Claude session count for the user. Returns the new value."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
async with _lock:
|
| 55 |
today = _today()
|
| 56 |
day, count = _claude_counts.get(user_id, (today, 0))
|
| 57 |
if day != today:
|
| 58 |
count = 0
|
|
|
|
|
|
|
| 59 |
count += 1
|
| 60 |
_claude_counts[user_id] = (today, count)
|
| 61 |
return count
|
|
@@ -63,6 +95,11 @@ async def increment_claude(user_id: str) -> int:
|
|
| 63 |
|
| 64 |
async def refund_claude(user_id: str) -> None:
|
| 65 |
"""Decrement today's count — used when session creation fails after a successful gate."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
async with _lock:
|
| 67 |
entry = _claude_counts.get(user_id)
|
| 68 |
if entry is None:
|
|
@@ -81,3 +118,4 @@ async def refund_claude(user_id: str) -> None:
|
|
| 81 |
def _reset_for_tests() -> None:
|
| 82 |
"""Test-only: clear the in-memory store."""
|
| 83 |
_claude_counts.clear()
|
|
|
|
|
|
| 1 |
+
"""Daily quota for Claude session creations.
|
| 2 |
|
| 3 |
Tracks per-user Claude session starts against a daily cap derived from the
|
| 4 |
+
user's HF plan. MongoDB is the source of truth when configured; the
|
| 5 |
+
in-process dict remains the fallback for local/dev/test runs.
|
|
|
|
| 6 |
|
| 7 |
Unit: session *creations*, not messages. A user who selects Claude in a new
|
| 8 |
session consumes one quota point; switching an existing Claude session to
|
|
|
|
| 17 |
import os
|
| 18 |
from datetime import UTC, datetime
|
| 19 |
|
| 20 |
+
from agent.core.session_persistence import NoopSessionStore, get_session_store, _reset_store_for_tests
|
| 21 |
+
|
| 22 |
CLAUDE_FREE_DAILY: int = int(os.environ.get("CLAUDE_FREE_DAILY", "1"))
|
| 23 |
CLAUDE_PRO_DAILY: int = int(os.environ.get("CLAUDE_PRO_DAILY", "20"))
|
| 24 |
|
|
|
|
| 38 |
|
| 39 |
async def get_claude_used_today(user_id: str) -> int:
|
| 40 |
"""Return today's Claude session count for the user (0 if none / stale day)."""
|
| 41 |
+
store = get_session_store()
|
| 42 |
+
if getattr(store, "enabled", False):
|
| 43 |
+
db_count = await store.get_quota(user_id, _today())
|
| 44 |
+
return db_count or 0
|
| 45 |
+
|
| 46 |
async with _lock:
|
| 47 |
entry = _claude_counts.get(user_id)
|
| 48 |
if entry is None:
|
|
|
|
| 57 |
|
| 58 |
async def increment_claude(user_id: str) -> int:
|
| 59 |
"""Bump today's Claude session count for the user. Returns the new value."""
|
| 60 |
+
store = get_session_store()
|
| 61 |
+
if getattr(store, "enabled", False):
|
| 62 |
+
db_count = await store.try_increment_quota(user_id, _today(), cap=10**9)
|
| 63 |
+
return db_count or 0
|
| 64 |
+
|
| 65 |
+
async with _lock:
|
| 66 |
+
today = _today()
|
| 67 |
+
day, count = _claude_counts.get(user_id, (today, 0))
|
| 68 |
+
if day != today:
|
| 69 |
+
count = 0
|
| 70 |
+
count += 1
|
| 71 |
+
_claude_counts[user_id] = (today, count)
|
| 72 |
+
return count
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def try_increment_claude(user_id: str, cap: int) -> int | None:
|
| 76 |
+
"""Atomically bump today's count if below *cap*.
|
| 77 |
+
|
| 78 |
+
Returns the new count, or None when the user is already at the cap.
|
| 79 |
+
"""
|
| 80 |
+
store = get_session_store()
|
| 81 |
+
if getattr(store, "enabled", False):
|
| 82 |
+
return await store.try_increment_quota(user_id, _today(), cap)
|
| 83 |
+
|
| 84 |
async with _lock:
|
| 85 |
today = _today()
|
| 86 |
day, count = _claude_counts.get(user_id, (today, 0))
|
| 87 |
if day != today:
|
| 88 |
count = 0
|
| 89 |
+
if count >= cap:
|
| 90 |
+
return None
|
| 91 |
count += 1
|
| 92 |
_claude_counts[user_id] = (today, count)
|
| 93 |
return count
|
|
|
|
| 95 |
|
| 96 |
async def refund_claude(user_id: str) -> None:
|
| 97 |
"""Decrement today's count — used when session creation fails after a successful gate."""
|
| 98 |
+
store = get_session_store()
|
| 99 |
+
if getattr(store, "enabled", False):
|
| 100 |
+
await store.refund_quota(user_id, _today())
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
async with _lock:
|
| 104 |
entry = _claude_counts.get(user_id)
|
| 105 |
if entry is None:
|
|
|
|
| 118 |
def _reset_for_tests() -> None:
|
| 119 |
"""Test-only: clear the in-memory store."""
|
| 120 |
_claude_counts.clear()
|
| 121 |
+
_reset_store_for_tests(NoopSessionStore())
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useCallback, useState } from 'react';
|
| 2 |
import {
|
| 3 |
Alert,
|
| 4 |
Box,
|
|
@@ -25,13 +25,30 @@ interface SessionSidebarProps {
|
|
| 25 |
}
|
| 26 |
|
| 27 |
export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
| 28 |
-
const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
|
| 29 |
useSessionStore();
|
| 30 |
const { setPlan, clearPanel } =
|
| 31 |
useAgentStore();
|
| 32 |
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
| 33 |
const [capacityError, setCapacityError] = useState<string | null>(null);
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
// -- Handlers -----------------------------------------------------------
|
| 36 |
|
| 37 |
const handleNewSession = useCallback(async () => {
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useState } from 'react';
|
| 2 |
import {
|
| 3 |
Alert,
|
| 4 |
Box,
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
| 28 |
+
const { sessions, activeSessionId, createSession, deleteSession, switchSession, mergeServerSessions } =
|
| 29 |
useSessionStore();
|
| 30 |
const { setPlan, clearPanel } =
|
| 31 |
useAgentStore();
|
| 32 |
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
| 33 |
const [capacityError, setCapacityError] = useState<string | null>(null);
|
| 34 |
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
let cancelled = false;
|
| 37 |
+
(async () => {
|
| 38 |
+
try {
|
| 39 |
+
const response = await apiFetch('/api/sessions');
|
| 40 |
+
if (!response.ok) return;
|
| 41 |
+
const data = await response.json();
|
| 42 |
+
if (!cancelled && Array.isArray(data)) {
|
| 43 |
+
mergeServerSessions(data);
|
| 44 |
+
}
|
| 45 |
+
} catch {
|
| 46 |
+
/* local sidebar metadata is still usable */
|
| 47 |
+
}
|
| 48 |
+
})();
|
| 49 |
+
return () => { cancelled = true; };
|
| 50 |
+
}, [mergeServerSessions]);
|
| 51 |
+
|
| 52 |
// -- Handlers -----------------------------------------------------------
|
| 53 |
|
| 54 |
const handleNewSession = useCallback(async () => {
|
|
@@ -371,7 +371,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 371 |
} catch {
|
| 372 |
return null;
|
| 373 |
}
|
| 374 |
-
}, [sessionId, setNeedsAttention]);
|
| 375 |
|
| 376 |
// -- useChat from Vercel AI SDK -----------------------------------------
|
| 377 |
const chat = useChat({
|
|
@@ -621,7 +621,10 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 621 |
/** Read the event stream from GET /api/events and forward to side-channel. */
|
| 622 |
const consumeEventStream = async (signal: AbortSignal) => {
|
| 623 |
try {
|
| 624 |
-
const
|
|
|
|
|
|
|
|
|
|
| 625 |
headers: { 'Accept': 'text/event-stream' },
|
| 626 |
signal,
|
| 627 |
});
|
|
@@ -629,6 +632,71 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 629 |
|
| 630 |
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
| 631 |
let buf = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
while (true) {
|
| 633 |
const { value, done } = await reader.read();
|
| 634 |
if (done || signal.aborted) break;
|
|
@@ -636,59 +704,21 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 636 |
const lines = buf.split('\n');
|
| 637 |
buf = lines.pop() || '';
|
| 638 |
for (const line of lines) {
|
| 639 |
-
const trimmed = line.
|
| 640 |
-
if (
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
event.data?.tool as string,
|
| 655 |
-
event.data?.tool_call_id as string,
|
| 656 |
-
event.data?.output as string,
|
| 657 |
-
event.data?.success as boolean,
|
| 658 |
-
);
|
| 659 |
-
} else if (et === 'tool_state_change') {
|
| 660 |
-
const state = event.data?.state as string;
|
| 661 |
-
const toolName = event.data?.tool as string;
|
| 662 |
-
if (state === 'running' && toolName) sideChannel.onToolRunning(toolName);
|
| 663 |
-
} else if (et === 'turn_complete' || et === 'error' || et === 'interrupted') {
|
| 664 |
-
sideChannel.onProcessingDone();
|
| 665 |
-
stopReconnect();
|
| 666 |
-
// Final hydration to get the complete message state
|
| 667 |
-
const result = await hydrateMessages();
|
| 668 |
-
if (result) {
|
| 669 |
-
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
|
| 670 |
-
if (uiMsgs.length > 0) {
|
| 671 |
-
chat.setMessages(uiMsgs);
|
| 672 |
-
saveMessages(sessionId, uiMsgs);
|
| 673 |
-
}
|
| 674 |
-
}
|
| 675 |
-
return;
|
| 676 |
-
} else if (et === 'approval_required') {
|
| 677 |
-
sideChannel.onApprovalRequired(
|
| 678 |
-
(event.data?.tools || []) as Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>,
|
| 679 |
-
);
|
| 680 |
-
stopReconnect();
|
| 681 |
-
const result = await hydrateMessages();
|
| 682 |
-
if (result) {
|
| 683 |
-
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
|
| 684 |
-
if (uiMsgs.length > 0) {
|
| 685 |
-
chat.setMessages(uiMsgs);
|
| 686 |
-
saveMessages(sessionId, uiMsgs);
|
| 687 |
-
}
|
| 688 |
-
}
|
| 689 |
-
return;
|
| 690 |
-
}
|
| 691 |
-
} catch { /* ignore parse errors */ }
|
| 692 |
}
|
| 693 |
}
|
| 694 |
} catch {
|
|
|
|
| 371 |
} catch {
|
| 372 |
return null;
|
| 373 |
}
|
| 374 |
+
}, [sessionId, setNeedsAttention, updateSession]);
|
| 375 |
|
| 376 |
// -- useChat from Vercel AI SDK -----------------------------------------
|
| 377 |
const chat = useChat({
|
|
|
|
| 621 |
/** Read the event stream from GET /api/events and forward to side-channel. */
|
| 622 |
const consumeEventStream = async (signal: AbortSignal) => {
|
| 623 |
try {
|
| 624 |
+
const lastEventKey = `hf-agent-last-event:${sessionId}`;
|
| 625 |
+
const lastSeq = localStorage.getItem(lastEventKey);
|
| 626 |
+
const qs = lastSeq ? `?after=${encodeURIComponent(lastSeq)}` : '';
|
| 627 |
+
const res = await apiFetch(`/api/events/${sessionId}${qs}`, {
|
| 628 |
headers: { 'Accept': 'text/event-stream' },
|
| 629 |
signal,
|
| 630 |
});
|
|
|
|
| 632 |
|
| 633 |
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
| 634 |
let buf = '';
|
| 635 |
+
let eventId: string | null = null;
|
| 636 |
+
let eventData = '';
|
| 637 |
+
const dispatch = async () => {
|
| 638 |
+
if (!eventData.trim()) {
|
| 639 |
+
eventId = null;
|
| 640 |
+
eventData = '';
|
| 641 |
+
return false;
|
| 642 |
+
}
|
| 643 |
+
const event = JSON.parse(eventData.trim());
|
| 644 |
+
const seq = event.seq ?? (eventId ? Number(eventId) : undefined);
|
| 645 |
+
if (Number.isFinite(seq)) {
|
| 646 |
+
localStorage.setItem(lastEventKey, String(seq));
|
| 647 |
+
}
|
| 648 |
+
eventId = null;
|
| 649 |
+
eventData = '';
|
| 650 |
+
// Forward to side-channel for real-time UI updates
|
| 651 |
+
const et = event.event_type as string;
|
| 652 |
+
if (et === 'processing') sideChannel.onProcessing();
|
| 653 |
+
else if (et === 'assistant_chunk') sideChannel.onStreaming();
|
| 654 |
+
else if (et === 'tool_call') {
|
| 655 |
+
const t = event.data?.tool as string;
|
| 656 |
+
const d = event.data?.arguments?.description as string | undefined;
|
| 657 |
+
sideChannel.onToolRunning(t, d);
|
| 658 |
+
sideChannel.onToolCallPanel(t, (event.data?.arguments || {}) as Record<string, unknown>);
|
| 659 |
+
} else if (et === 'tool_output') {
|
| 660 |
+
sideChannel.onToolOutputPanel(
|
| 661 |
+
event.data?.tool as string,
|
| 662 |
+
event.data?.tool_call_id as string,
|
| 663 |
+
event.data?.output as string,
|
| 664 |
+
event.data?.success as boolean,
|
| 665 |
+
);
|
| 666 |
+
} else if (et === 'tool_state_change') {
|
| 667 |
+
const state = event.data?.state as string;
|
| 668 |
+
const toolName = event.data?.tool as string;
|
| 669 |
+
if (state === 'running' && toolName) sideChannel.onToolRunning(toolName);
|
| 670 |
+
} else if (et === 'turn_complete' || et === 'error' || et === 'interrupted') {
|
| 671 |
+
sideChannel.onProcessingDone();
|
| 672 |
+
stopReconnect();
|
| 673 |
+
// Final hydration to get the complete message state
|
| 674 |
+
const result = await hydrateMessages();
|
| 675 |
+
if (result) {
|
| 676 |
+
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
|
| 677 |
+
if (uiMsgs.length > 0) {
|
| 678 |
+
chat.setMessages(uiMsgs);
|
| 679 |
+
saveMessages(sessionId, uiMsgs);
|
| 680 |
+
}
|
| 681 |
+
}
|
| 682 |
+
return true;
|
| 683 |
+
} else if (et === 'approval_required') {
|
| 684 |
+
sideChannel.onApprovalRequired(
|
| 685 |
+
(event.data?.tools || []) as Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>,
|
| 686 |
+
);
|
| 687 |
+
stopReconnect();
|
| 688 |
+
const result = await hydrateMessages();
|
| 689 |
+
if (result) {
|
| 690 |
+
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
|
| 691 |
+
if (uiMsgs.length > 0) {
|
| 692 |
+
chat.setMessages(uiMsgs);
|
| 693 |
+
saveMessages(sessionId, uiMsgs);
|
| 694 |
+
}
|
| 695 |
+
}
|
| 696 |
+
return true;
|
| 697 |
+
}
|
| 698 |
+
return false;
|
| 699 |
+
};
|
| 700 |
while (true) {
|
| 701 |
const { value, done } = await reader.read();
|
| 702 |
if (done || signal.aborted) break;
|
|
|
|
| 704 |
const lines = buf.split('\n');
|
| 705 |
buf = lines.pop() || '';
|
| 706 |
for (const line of lines) {
|
| 707 |
+
const trimmed = line.replace(/\r$/, '');
|
| 708 |
+
if (trimmed === '') {
|
| 709 |
+
try {
|
| 710 |
+
if (await dispatch()) return;
|
| 711 |
+
} catch { /* ignore parse errors */ }
|
| 712 |
+
continue;
|
| 713 |
+
}
|
| 714 |
+
if (trimmed.startsWith(':')) continue;
|
| 715 |
+
if (trimmed.startsWith('id:')) {
|
| 716 |
+
eventId = trimmed.slice(3).trim();
|
| 717 |
+
continue;
|
| 718 |
+
}
|
| 719 |
+
if (trimmed.startsWith('data:')) {
|
| 720 |
+
eventData += trimmed.slice(5).trimStart() + '\n';
|
| 721 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
}
|
| 723 |
}
|
| 724 |
} catch {
|
|
@@ -42,35 +42,66 @@ function nextPartId(prefix: string): string {
|
|
| 42 |
return `${prefix}-${Date.now()}-${++partIdCounter}`;
|
| 43 |
}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
/** Parse an SSE text stream into AgentEvent objects. */
|
| 46 |
-
function createSSEParserStream(): TransformStream<string, AgentEvent> {
|
| 47 |
let buffer = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
return new TransformStream<string, AgentEvent>({
|
| 49 |
transform(chunk, controller) {
|
| 50 |
buffer += chunk;
|
| 51 |
const lines = buffer.split('\n');
|
| 52 |
// Keep the last (possibly incomplete) line in the buffer
|
| 53 |
buffer = lines.pop() || '';
|
| 54 |
-
for (const
|
| 55 |
-
const
|
| 56 |
-
if (
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
}
|
| 65 |
},
|
| 66 |
flush(controller) {
|
| 67 |
-
|
| 68 |
-
if (
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
} catch { /* ignore incomplete */ }
|
| 73 |
}
|
|
|
|
| 74 |
},
|
| 75 |
});
|
| 76 |
}
|
|
@@ -426,7 +457,7 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
|
|
| 426 |
// Pipe: response bytes → text → SSE events → UIMessageChunks
|
| 427 |
return response.body
|
| 428 |
.pipeThrough(new TextDecoderStream())
|
| 429 |
-
.pipeThrough(createSSEParserStream())
|
| 430 |
.pipeThrough(createEventToChunkStream(this.sideChannel));
|
| 431 |
}
|
| 432 |
|
|
@@ -441,7 +472,9 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
|
|
| 441 |
if (!info.is_processing) return null;
|
| 442 |
|
| 443 |
// Session is mid-turn — subscribe to its event broadcast.
|
| 444 |
-
const
|
|
|
|
|
|
|
| 445 |
headers: { 'Accept': 'text/event-stream' },
|
| 446 |
});
|
| 447 |
if (!response.ok || !response.body) return null;
|
|
@@ -450,7 +483,7 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
|
|
| 450 |
|
| 451 |
return response.body
|
| 452 |
.pipeThrough(new TextDecoderStream())
|
| 453 |
-
.pipeThrough(createSSEParserStream())
|
| 454 |
.pipeThrough(createEventToChunkStream(this.sideChannel));
|
| 455 |
} catch {
|
| 456 |
return null;
|
|
|
|
| 42 |
return `${prefix}-${Date.now()}-${++partIdCounter}`;
|
| 43 |
}
|
| 44 |
|
| 45 |
+
function lastEventKey(sessionId: string): string {
|
| 46 |
+
return `hf-agent-last-event:${sessionId}`;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
/** Parse an SSE text stream into AgentEvent objects. */
|
| 50 |
+
function createSSEParserStream(sessionId: string): TransformStream<string, AgentEvent> {
|
| 51 |
let buffer = '';
|
| 52 |
+
let eventId: string | null = null;
|
| 53 |
+
let data = '';
|
| 54 |
+
|
| 55 |
+
const dispatch = (controller: TransformStreamDefaultController<AgentEvent>) => {
|
| 56 |
+
if (!data.trim()) {
|
| 57 |
+
eventId = null;
|
| 58 |
+
data = '';
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
try {
|
| 62 |
+
const json = JSON.parse(data.trim()) as AgentEvent;
|
| 63 |
+
const seq = json.seq ?? (eventId ? Number(eventId) : undefined);
|
| 64 |
+
if (Number.isFinite(seq)) {
|
| 65 |
+
json.seq = seq;
|
| 66 |
+
localStorage.setItem(lastEventKey(sessionId), String(seq));
|
| 67 |
+
}
|
| 68 |
+
controller.enqueue(json);
|
| 69 |
+
} catch {
|
| 70 |
+
logger.warn('SSE parse error:', data.trim());
|
| 71 |
+
} finally {
|
| 72 |
+
eventId = null;
|
| 73 |
+
data = '';
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
return new TransformStream<string, AgentEvent>({
|
| 78 |
transform(chunk, controller) {
|
| 79 |
buffer += chunk;
|
| 80 |
const lines = buffer.split('\n');
|
| 81 |
// Keep the last (possibly incomplete) line in the buffer
|
| 82 |
buffer = lines.pop() || '';
|
| 83 |
+
for (const rawLine of lines) {
|
| 84 |
+
const line = rawLine.replace(/\r$/, '');
|
| 85 |
+
if (line === '') {
|
| 86 |
+
dispatch(controller);
|
| 87 |
+
continue;
|
| 88 |
+
}
|
| 89 |
+
if (line.startsWith(':')) continue;
|
| 90 |
+
if (line.startsWith('id:')) {
|
| 91 |
+
eventId = line.slice(3).trim();
|
| 92 |
+
} else if (line.startsWith('data:')) {
|
| 93 |
+
data += line.slice(5).trimStart() + '\n';
|
| 94 |
}
|
| 95 |
}
|
| 96 |
},
|
| 97 |
flush(controller) {
|
| 98 |
+
const line = buffer.replace(/\r$/, '');
|
| 99 |
+
if (line.startsWith('id:')) {
|
| 100 |
+
eventId = line.slice(3).trim();
|
| 101 |
+
} else if (line.startsWith('data:')) {
|
| 102 |
+
data += line.slice(5).trimStart() + '\n';
|
|
|
|
| 103 |
}
|
| 104 |
+
dispatch(controller);
|
| 105 |
},
|
| 106 |
});
|
| 107 |
}
|
|
|
|
| 457 |
// Pipe: response bytes → text → SSE events → UIMessageChunks
|
| 458 |
return response.body
|
| 459 |
.pipeThrough(new TextDecoderStream())
|
| 460 |
+
.pipeThrough(createSSEParserStream(sessionId))
|
| 461 |
.pipeThrough(createEventToChunkStream(this.sideChannel));
|
| 462 |
}
|
| 463 |
|
|
|
|
| 472 |
if (!info.is_processing) return null;
|
| 473 |
|
| 474 |
// Session is mid-turn — subscribe to its event broadcast.
|
| 475 |
+
const lastSeq = localStorage.getItem(lastEventKey(this.sessionId));
|
| 476 |
+
const qs = lastSeq ? `?after=${encodeURIComponent(lastSeq)}` : '';
|
| 477 |
+
const response = await apiFetch(`/api/events/${this.sessionId}${qs}`, {
|
| 478 |
headers: { 'Accept': 'text/event-stream' },
|
| 479 |
});
|
| 480 |
if (!response.ok || !response.body) return null;
|
|
|
|
| 483 |
|
| 484 |
return response.body
|
| 485 |
.pipeThrough(new TextDecoderStream())
|
| 486 |
+
.pipeThrough(createSSEParserStream(this.sessionId))
|
| 487 |
.pipeThrough(createEventToChunkStream(this.sideChannel));
|
| 488 |
} catch {
|
| 489 |
return null;
|
|
@@ -20,6 +20,14 @@ interface SessionStore {
|
|
| 20 |
markExpired: (id: string) => void;
|
| 21 |
/** Clear the expired flag (used after restore-with-summary succeeds). */
|
| 22 |
clearExpired: (id: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
/** Atomically swap a session's id in the list + both localStorage caches.
|
| 24 |
* Used when we rehydrate an expired session into a freshly-created backend
|
| 25 |
* session — preserves title, timestamps, and messages. */
|
|
@@ -76,6 +84,45 @@ export const useSessionStore = create<SessionStore>()(
|
|
| 76 |
}));
|
| 77 |
},
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
renameSession: (oldId: string, newId: string) => {
|
| 80 |
if (oldId === newId) return;
|
| 81 |
moveMessages(oldId, newId);
|
|
|
|
| 20 |
markExpired: (id: string) => void;
|
| 21 |
/** Clear the expired flag (used after restore-with-summary succeeds). */
|
| 22 |
clearExpired: (id: string) => void;
|
| 23 |
+
/** Merge durable server-side sessions into local sidebar metadata. */
|
| 24 |
+
mergeServerSessions: (sessions: Array<{
|
| 25 |
+
session_id: string;
|
| 26 |
+
title?: string | null;
|
| 27 |
+
created_at: string;
|
| 28 |
+
is_active?: boolean;
|
| 29 |
+
pending_approval?: unknown[] | null;
|
| 30 |
+
}>) => void;
|
| 31 |
/** Atomically swap a session's id in the list + both localStorage caches.
|
| 32 |
* Used when we rehydrate an expired session into a freshly-created backend
|
| 33 |
* session — preserves title, timestamps, and messages. */
|
|
|
|
| 84 |
}));
|
| 85 |
},
|
| 86 |
|
| 87 |
+
mergeServerSessions: (serverSessions) => {
|
| 88 |
+
set((state) => {
|
| 89 |
+
const byId = new Map(state.sessions.map((s) => [s.id, s]));
|
| 90 |
+
const merged = [...state.sessions];
|
| 91 |
+
for (const server of serverSessions) {
|
| 92 |
+
const id = server.session_id;
|
| 93 |
+
if (!id) continue;
|
| 94 |
+
const existing = byId.get(id);
|
| 95 |
+
if (existing) {
|
| 96 |
+
const updated = {
|
| 97 |
+
...existing,
|
| 98 |
+
title: server.title || existing.title,
|
| 99 |
+
isActive: server.is_active ?? existing.isActive,
|
| 100 |
+
needsAttention: Boolean(server.pending_approval?.length) || existing.needsAttention,
|
| 101 |
+
expired: false,
|
| 102 |
+
};
|
| 103 |
+
const idx = merged.findIndex((s) => s.id === id);
|
| 104 |
+
if (idx >= 0) merged[idx] = updated;
|
| 105 |
+
byId.set(id, updated);
|
| 106 |
+
continue;
|
| 107 |
+
}
|
| 108 |
+
const newSession: SessionMeta = {
|
| 109 |
+
id,
|
| 110 |
+
title: server.title || `Chat ${merged.length + 1}`,
|
| 111 |
+
createdAt: server.created_at || new Date().toISOString(),
|
| 112 |
+
isActive: server.is_active ?? true,
|
| 113 |
+
needsAttention: Boolean(server.pending_approval?.length),
|
| 114 |
+
expired: false,
|
| 115 |
+
};
|
| 116 |
+
merged.push(newSession);
|
| 117 |
+
byId.set(id, newSession);
|
| 118 |
+
}
|
| 119 |
+
return {
|
| 120 |
+
sessions: merged,
|
| 121 |
+
activeSessionId: state.activeSessionId || merged[merged.length - 1]?.id || null,
|
| 122 |
+
};
|
| 123 |
+
});
|
| 124 |
+
},
|
| 125 |
+
|
| 126 |
renameSession: (oldId: string, newId: string) => {
|
| 127 |
if (oldId === newId) return;
|
| 128 |
moveMessages(oldId, newId);
|
|
@@ -24,6 +24,7 @@ export type EventType =
|
|
| 24 |
export interface AgentEvent {
|
| 25 |
event_type: EventType;
|
| 26 |
data?: Record<string, unknown>;
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
export interface ReadyEventData {
|
|
|
|
| 24 |
export interface AgentEvent {
|
| 25 |
event_type: EventType;
|
| 26 |
data?: Record<string, unknown>;
|
| 27 |
+
seq?: number;
|
| 28 |
}
|
| 29 |
|
| 30 |
export interface ReadyEventData {
|
|
@@ -27,6 +27,7 @@ dependencies = [
|
|
| 27 |
"httpx>=0.27.0",
|
| 28 |
"websockets>=13.0",
|
| 29 |
"apscheduler>=3.10,<4",
|
|
|
|
| 30 |
]
|
| 31 |
|
| 32 |
[project.optional-dependencies]
|
|
|
|
| 27 |
"httpx>=0.27.0",
|
| 28 |
"websockets>=13.0",
|
| 29 |
"apscheduler>=3.10,<4",
|
| 30 |
+
"pymongo>=4.17.0",
|
| 31 |
]
|
| 32 |
|
| 33 |
[project.optional-dependencies]
|
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for server-side session persistence restore/access."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime, UTC
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from types import SimpleNamespace
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
_BACKEND_DIR = Path(__file__).resolve().parent.parent.parent / "backend"
|
| 15 |
+
if str(_BACKEND_DIR) not in sys.path:
|
| 16 |
+
sys.path.insert(0, str(_BACKEND_DIR))
|
| 17 |
+
|
| 18 |
+
from agent.core.session_persistence import NoopSessionStore # noqa: E402
|
| 19 |
+
from session_manager import AgentSession, SessionManager # noqa: E402
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class FakeRuntimeSession:
|
| 23 |
+
def __init__(self, *, hf_token: str | None = None, model: str = "test-model"):
|
| 24 |
+
self.hf_token = hf_token
|
| 25 |
+
self.context_manager = SimpleNamespace(items=[])
|
| 26 |
+
self.pending_approval = None
|
| 27 |
+
self.turn_count = 0
|
| 28 |
+
self.config = SimpleNamespace(model_name=model)
|
| 29 |
+
self.notification_destinations = []
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class RestoreStore(NoopSessionStore):
|
| 33 |
+
enabled = True
|
| 34 |
+
|
| 35 |
+
def __init__(
|
| 36 |
+
self,
|
| 37 |
+
*,
|
| 38 |
+
metadata: dict[str, Any] | None = None,
|
| 39 |
+
messages: list[dict[str, Any]] | None = None,
|
| 40 |
+
delay: float = 0,
|
| 41 |
+
) -> None:
|
| 42 |
+
self.metadata = metadata or {
|
| 43 |
+
"session_id": "persisted-session",
|
| 44 |
+
"user_id": "owner",
|
| 45 |
+
"model": "test-model",
|
| 46 |
+
"created_at": datetime.now(UTC),
|
| 47 |
+
}
|
| 48 |
+
self.messages = messages or []
|
| 49 |
+
self.delay = delay
|
| 50 |
+
self.load_calls = 0
|
| 51 |
+
|
| 52 |
+
async def load_session(self, session_id: str, **_: Any) -> dict[str, Any] | None:
|
| 53 |
+
self.load_calls += 1
|
| 54 |
+
if self.delay:
|
| 55 |
+
await asyncio.sleep(self.delay)
|
| 56 |
+
metadata = dict(self.metadata)
|
| 57 |
+
metadata.setdefault("session_id", session_id)
|
| 58 |
+
metadata.setdefault("_id", session_id)
|
| 59 |
+
return {"metadata": metadata, "messages": self.messages}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _manager_with_store(store: NoopSessionStore) -> SessionManager:
|
| 63 |
+
manager = object.__new__(SessionManager)
|
| 64 |
+
manager.config = SimpleNamespace(model_name="test-model")
|
| 65 |
+
manager.sessions = {}
|
| 66 |
+
manager._lock = asyncio.Lock()
|
| 67 |
+
manager.persistence_store = store
|
| 68 |
+
return manager
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _runtime_agent_session(
|
| 72 |
+
session_id: str,
|
| 73 |
+
*,
|
| 74 |
+
user_id: str = "owner",
|
| 75 |
+
hf_token: str | None = "owner-token",
|
| 76 |
+
) -> AgentSession:
|
| 77 |
+
runtime_session = FakeRuntimeSession(hf_token=hf_token)
|
| 78 |
+
return AgentSession(
|
| 79 |
+
session_id=session_id,
|
| 80 |
+
session=runtime_session, # type: ignore[arg-type]
|
| 81 |
+
tool_router=object(), # type: ignore[arg-type]
|
| 82 |
+
submission_queue=asyncio.Queue(),
|
| 83 |
+
user_id=user_id,
|
| 84 |
+
hf_token=hf_token,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _install_fake_runtime(manager: SessionManager) -> asyncio.Event:
|
| 89 |
+
stop = asyncio.Event()
|
| 90 |
+
manager.run_calls = 0 # type: ignore[attr-defined]
|
| 91 |
+
|
| 92 |
+
def fake_create_session_sync(**kwargs: Any):
|
| 93 |
+
return object(), FakeRuntimeSession(
|
| 94 |
+
hf_token=kwargs.get("hf_token"),
|
| 95 |
+
model=kwargs.get("model") or "test-model",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
async def fake_run_session(*_: Any) -> None:
|
| 99 |
+
manager.run_calls += 1 # type: ignore[attr-defined]
|
| 100 |
+
await stop.wait()
|
| 101 |
+
|
| 102 |
+
manager._create_session_sync = fake_create_session_sync # type: ignore[method-assign]
|
| 103 |
+
manager._run_session = fake_run_session # type: ignore[method-assign]
|
| 104 |
+
return stop
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def _cancel_runtime_tasks(manager: SessionManager) -> None:
|
| 108 |
+
tasks = [
|
| 109 |
+
agent_session.task
|
| 110 |
+
for agent_session in manager.sessions.values()
|
| 111 |
+
if agent_session.task and not agent_session.task.done()
|
| 112 |
+
]
|
| 113 |
+
for task in tasks:
|
| 114 |
+
task.cancel()
|
| 115 |
+
if tasks:
|
| 116 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@pytest.mark.asyncio
|
| 120 |
+
async def test_existing_session_rejects_cross_user_token_overwrite():
|
| 121 |
+
manager = _manager_with_store(NoopSessionStore())
|
| 122 |
+
existing = _runtime_agent_session("s1", user_id="victim", hf_token="victim-token")
|
| 123 |
+
manager.sessions["s1"] = existing
|
| 124 |
+
|
| 125 |
+
result = await manager.ensure_session_loaded(
|
| 126 |
+
"s1", user_id="attacker", hf_token="attacker-token"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
assert result is None
|
| 130 |
+
assert existing.hf_token == "victim-token"
|
| 131 |
+
assert existing.session.hf_token == "victim-token"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@pytest.mark.asyncio
|
| 135 |
+
async def test_existing_session_updates_token_after_access_check():
|
| 136 |
+
manager = _manager_with_store(NoopSessionStore())
|
| 137 |
+
existing = _runtime_agent_session("s1", user_id="owner", hf_token="old-token")
|
| 138 |
+
manager.sessions["s1"] = existing
|
| 139 |
+
|
| 140 |
+
result = await manager.ensure_session_loaded(
|
| 141 |
+
"s1", user_id="owner", hf_token="new-token"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
assert result is existing
|
| 145 |
+
assert existing.hf_token == "new-token"
|
| 146 |
+
assert existing.session.hf_token == "new-token"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@pytest.mark.asyncio
|
| 150 |
+
async def test_concurrent_lazy_restore_starts_only_one_agent_task():
|
| 151 |
+
store = RestoreStore(delay=0.01)
|
| 152 |
+
manager = _manager_with_store(store)
|
| 153 |
+
stop = _install_fake_runtime(manager)
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
first, second = await asyncio.gather(
|
| 157 |
+
manager.ensure_session_loaded("persisted-session", user_id="owner"),
|
| 158 |
+
manager.ensure_session_loaded("persisted-session", user_id="owner"),
|
| 159 |
+
)
|
| 160 |
+
await asyncio.sleep(0)
|
| 161 |
+
|
| 162 |
+
assert first is second
|
| 163 |
+
assert list(manager.sessions) == ["persisted-session"]
|
| 164 |
+
assert manager.run_calls == 1 # type: ignore[attr-defined]
|
| 165 |
+
assert not stop.is_set()
|
| 166 |
+
finally:
|
| 167 |
+
stop.set()
|
| 168 |
+
await _cancel_runtime_tasks(manager)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@pytest.mark.asyncio
|
| 172 |
+
async def test_lazy_restore_preserves_pending_approval_tool_calls():
|
| 173 |
+
store = RestoreStore(
|
| 174 |
+
metadata={
|
| 175 |
+
"session_id": "approval-session",
|
| 176 |
+
"user_id": "owner",
|
| 177 |
+
"model": "test-model",
|
| 178 |
+
"pending_approval": [
|
| 179 |
+
{
|
| 180 |
+
"id": "call_123",
|
| 181 |
+
"type": "function",
|
| 182 |
+
"function": {
|
| 183 |
+
"name": "create_file",
|
| 184 |
+
"arguments": '{"path":"app.py"}',
|
| 185 |
+
},
|
| 186 |
+
}
|
| 187 |
+
],
|
| 188 |
+
}
|
| 189 |
+
)
|
| 190 |
+
manager = _manager_with_store(store)
|
| 191 |
+
stop = _install_fake_runtime(manager)
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
restored = await manager.ensure_session_loaded("approval-session", user_id="owner")
|
| 195 |
+
|
| 196 |
+
assert restored is not None
|
| 197 |
+
tool_calls = restored.session.pending_approval["tool_calls"]
|
| 198 |
+
assert len(tool_calls) == 1
|
| 199 |
+
assert tool_calls[0].id == "call_123"
|
| 200 |
+
assert tool_calls[0].function.name == "create_file"
|
| 201 |
+
assert tool_calls[0].function.arguments == '{"path":"app.py"}'
|
| 202 |
+
finally:
|
| 203 |
+
stop.set()
|
| 204 |
+
await _cancel_runtime_tasks(manager)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@pytest.mark.asyncio
|
| 208 |
+
async def test_list_sessions_dev_uses_store_dev_visibility():
|
| 209 |
+
class ListStore(NoopSessionStore):
|
| 210 |
+
enabled = True
|
| 211 |
+
|
| 212 |
+
def __init__(self) -> None:
|
| 213 |
+
self.seen_user_id: str | None = None
|
| 214 |
+
|
| 215 |
+
async def list_sessions(self, user_id: str, **_: Any) -> list[dict[str, Any]]:
|
| 216 |
+
self.seen_user_id = user_id
|
| 217 |
+
if user_id == "dev":
|
| 218 |
+
return [
|
| 219 |
+
{
|
| 220 |
+
"session_id": "s1",
|
| 221 |
+
"user_id": "alice",
|
| 222 |
+
"model": "m",
|
| 223 |
+
"created_at": datetime.now(UTC),
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"session_id": "s2",
|
| 227 |
+
"user_id": "bob",
|
| 228 |
+
"model": "m",
|
| 229 |
+
"created_at": datetime.now(UTC),
|
| 230 |
+
},
|
| 231 |
+
]
|
| 232 |
+
return []
|
| 233 |
+
|
| 234 |
+
store = ListStore()
|
| 235 |
+
manager = _manager_with_store(store)
|
| 236 |
+
|
| 237 |
+
sessions = await manager.list_sessions(user_id="dev")
|
| 238 |
+
|
| 239 |
+
assert store.seen_user_id == "dev"
|
| 240 |
+
assert {session["session_id"] for session in sessions} == {"s1", "s2"}
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the optional durable session store abstraction."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from agent.core.session_persistence import NoopSessionStore, _safe_message_doc
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.mark.asyncio
|
| 9 |
+
async def test_noop_store_keeps_local_cli_and_tests_db_free():
|
| 10 |
+
store = NoopSessionStore()
|
| 11 |
+
|
| 12 |
+
await store.init()
|
| 13 |
+
await store.upsert_session(session_id="s1", user_id="u1", model="m")
|
| 14 |
+
await store.save_snapshot(
|
| 15 |
+
session_id="s1",
|
| 16 |
+
user_id="u1",
|
| 17 |
+
model="m",
|
| 18 |
+
messages=[{"role": "user", "content": "hello"}],
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
assert await store.load_session("s1") is None
|
| 22 |
+
assert await store.list_sessions("u1") == []
|
| 23 |
+
assert await store.append_event("s1", "processing", {}) is None
|
| 24 |
+
assert await store.try_increment_quota("u1", "2099-01-01", 1) is None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_unsafe_message_payload_is_replaced_with_marker():
|
| 28 |
+
marker = _safe_message_doc({"role": "assistant", "content": object()})
|
| 29 |
+
|
| 30 |
+
assert marker["role"] == "tool"
|
| 31 |
+
assert marker["ml_intern_persistence_error"] == "message_too_large_or_invalid"
|
|
@@ -15,6 +15,7 @@ if str(_BACKEND_DIR) not in sys.path:
|
|
| 15 |
sys.path.insert(0, str(_BACKEND_DIR))
|
| 16 |
|
| 17 |
import user_quotas # noqa: E402
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
@pytest.fixture(autouse=True)
|
|
@@ -74,6 +75,33 @@ async def test_concurrent_increments_under_lock_do_not_lose_writes():
|
|
| 74 |
assert await user_quotas.get_claude_used_today("race") == 50
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
@pytest.mark.asyncio
|
| 78 |
async def test_refund_decrements_and_drops_entry_at_zero():
|
| 79 |
await user_quotas.increment_claude("u1")
|
|
|
|
| 15 |
sys.path.insert(0, str(_BACKEND_DIR))
|
| 16 |
|
| 17 |
import user_quotas # noqa: E402
|
| 18 |
+
from agent.core.session_persistence import NoopSessionStore, _reset_store_for_tests # noqa: E402
|
| 19 |
|
| 20 |
|
| 21 |
@pytest.fixture(autouse=True)
|
|
|
|
| 75 |
assert await user_quotas.get_claude_used_today("race") == 50
|
| 76 |
|
| 77 |
|
| 78 |
+
@pytest.mark.asyncio
|
| 79 |
+
async def test_try_increment_returns_none_at_cap():
|
| 80 |
+
assert await user_quotas.try_increment_claude("freebie", 1) == 1
|
| 81 |
+
assert await user_quotas.try_increment_claude("freebie", 1) is None
|
| 82 |
+
assert await user_quotas.get_claude_used_today("freebie") == 1
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@pytest.mark.asyncio
|
| 86 |
+
async def test_try_increment_delegates_cap_to_enabled_store():
|
| 87 |
+
class StoreAtCap(NoopSessionStore):
|
| 88 |
+
enabled = True
|
| 89 |
+
|
| 90 |
+
async def try_increment_quota(self, user_id: str, day: str, cap: int):
|
| 91 |
+
assert user_id == "mongo-user"
|
| 92 |
+
assert cap == 1
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
async def get_quota(self, user_id: str, day: str):
|
| 96 |
+
return 1
|
| 97 |
+
|
| 98 |
+
_reset_store_for_tests(StoreAtCap())
|
| 99 |
+
|
| 100 |
+
assert await user_quotas.try_increment_claude("mongo-user", 1) is None
|
| 101 |
+
assert await user_quotas.get_claude_used_today("mongo-user") == 1
|
| 102 |
+
assert "mongo-user" not in user_quotas._claude_counts
|
| 103 |
+
|
| 104 |
+
|
| 105 |
@pytest.mark.asyncio
|
| 106 |
async def test_refund_decrements_and_drops_entry_at_zero():
|
| 107 |
await user_quotas.increment_claude("u1")
|
|
@@ -1786,6 +1786,7 @@ dependencies = [
|
|
| 1786 |
{ name = "nbformat" },
|
| 1787 |
{ name = "prompt-toolkit" },
|
| 1788 |
{ name = "pydantic" },
|
|
|
|
| 1789 |
{ name = "python-dotenv" },
|
| 1790 |
{ name = "requests" },
|
| 1791 |
{ name = "rich" },
|
|
@@ -1833,6 +1834,7 @@ requires-dist = [
|
|
| 1833 |
{ name = "pandas", marker = "extra == 'eval'", specifier = ">=2.3.3" },
|
| 1834 |
{ name = "prompt-toolkit", specifier = ">=3.0.0" },
|
| 1835 |
{ name = "pydantic", specifier = ">=2.12.3" },
|
|
|
|
| 1836 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
|
| 1837 |
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" },
|
| 1838 |
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
|
@@ -2769,6 +2771,67 @@ crypto = [
|
|
| 2769 |
{ name = "cryptography" },
|
| 2770 |
]
|
| 2771 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2772 |
[[package]]
|
| 2773 |
name = "pyperclip"
|
| 2774 |
version = "1.11.0"
|
|
|
|
| 1786 |
{ name = "nbformat" },
|
| 1787 |
{ name = "prompt-toolkit" },
|
| 1788 |
{ name = "pydantic" },
|
| 1789 |
+
{ name = "pymongo" },
|
| 1790 |
{ name = "python-dotenv" },
|
| 1791 |
{ name = "requests" },
|
| 1792 |
{ name = "rich" },
|
|
|
|
| 1834 |
{ name = "pandas", marker = "extra == 'eval'", specifier = ">=2.3.3" },
|
| 1835 |
{ name = "prompt-toolkit", specifier = ">=3.0.0" },
|
| 1836 |
{ name = "pydantic", specifier = ">=2.12.3" },
|
| 1837 |
+
{ name = "pymongo", specifier = ">=4.17.0" },
|
| 1838 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
|
| 1839 |
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" },
|
| 1840 |
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
|
|
|
| 2771 |
{ name = "cryptography" },
|
| 2772 |
]
|
| 2773 |
|
| 2774 |
+
[[package]]
|
| 2775 |
+
name = "pymongo"
|
| 2776 |
+
version = "4.17.0"
|
| 2777 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2778 |
+
dependencies = [
|
| 2779 |
+
{ name = "dnspython" },
|
| 2780 |
+
]
|
| 2781 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" }
|
| 2782 |
+
wheels = [
|
| 2783 |
+
{ url = "https://files.pythonhosted.org/packages/c4/e2/336d86f221cf1b56b2ed9330d4a3b98f9f38f0b37829ae9a9184617d5419/pymongo-4.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4141e6c6a339789b2974efa00ecd9409101672d77a0e3ee2cc3839eedf8ec4df", size = 874668, upload-time = "2026-04-20T16:37:41.39Z" },
|
| 2784 |
+
{ url = "https://files.pythonhosted.org/packages/34/8e/75d3c6c935d187ab59c61e9c15d9aab3f274b563eaf1706e8cae5f508dec/pymongo-4.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e68c76b84e0c132d9dbf9307f12ff8185702328187a87b9aca8c941303873433", size = 875294, upload-time = "2026-04-20T16:37:43.432Z" },
|
| 2785 |
+
{ url = "https://files.pythonhosted.org/packages/5f/ec/62e855744489dbcd54fd778aae4d80fa4c4819e8fb228ca0cf6f21a03997/pymongo-4.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba2195d4f386f839a52a23ea1cfd60ffaaba78a3d7841db51b7e433001139918", size = 1496233, upload-time = "2026-04-20T16:37:45.518Z" },
|
| 2786 |
+
{ url = "https://files.pythonhosted.org/packages/82/e8/93e4e5e5ce8fdf8929dabeefe24aafa5ce046028eed0dfa8eeb936e72c49/pymongo-4.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446ff4bfcb6ec2a2e50998c860986a1e992136f998b7f53e7a717fb8aa5a0b9", size = 1522927, upload-time = "2026-04-20T16:37:47.492Z" },
|
| 2787 |
+
{ url = "https://files.pythonhosted.org/packages/f7/ca/425dc1d21e0f17bdea0072fc463f662f7fa06d2852af52975c9eced3c07c/pymongo-4.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a0d5ac205728c86e0a02192f1aa5f865b0d7d51f8df6101c01a69a7fc620d72", size = 1583468, upload-time = "2026-04-20T16:37:49.221Z" },
|
| 2788 |
+
{ url = "https://files.pythonhosted.org/packages/b3/9d/f08b07eeffda1a43c1759f0fa625e88ae12360996eb56d42aad832fa7dff/pymongo-4.17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:485c8a8eaa4c739f00a331fc73757898ee7c092c214a79e63866ff76aaf282ff", size = 1572787, upload-time = "2026-04-20T16:37:51.061Z" },
|
| 2789 |
+
{ url = "https://files.pythonhosted.org/packages/e9/c2/6855a07aafa7b894929af23675b6fb9634800ce43122b76a62f6eeb8da2a/pymongo-4.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2dfcc795f5b9fedbe179a11fdf6051581479d196582a3fe819a92a00e9b9969", size = 1526184, upload-time = "2026-04-20T16:37:53.358Z" },
|
| 2790 |
+
{ url = "https://files.pythonhosted.org/packages/4e/05/c952bac7db71c1942ea3559fcd308b49754cc5004b455935fb4000d1f37b/pymongo-4.17.0-cp311-cp311-win32.whl", hash = "sha256:c2292144505fb12156b981bd440f3dc994a883da06ac726c0c8692ccdbc1c510", size = 852621, upload-time = "2026-04-20T16:37:55.28Z" },
|
| 2791 |
+
{ url = "https://files.pythonhosted.org/packages/11/c0/c04da9f4c0c6252404598f4e394b862a58a9e866822a70ae261c8a018fdf/pymongo-4.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e190827834fce70ecdf9d46796c6dbc0ce08ea87dc2ff5bc6f3f5579b605cb9", size = 867852, upload-time = "2026-04-20T16:37:57.233Z" },
|
| 2792 |
+
{ url = "https://files.pythonhosted.org/packages/1d/b2/c7b4870fbeef471e947d3e014676f5910d02e0197074d692ebcf24ec049a/pymongo-4.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:a8f9c40a09bb7d4b9fc8b1da65ecf6efa79bda5cb2756f39d9b6940fac1d19ae", size = 855019, upload-time = "2026-04-20T16:37:58.983Z" },
|
| 2793 |
+
{ url = "https://files.pythonhosted.org/packages/98/90/60bcb508840135d5ee46b51b1a950f548338aa8145a8366dbe6639ae51ac/pymongo-4.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53ffa94b2340dbf6b055e09a0090618c60482c158ecfc9565642fc996bf0944", size = 930529, upload-time = "2026-04-20T16:38:00.936Z" },
|
| 2794 |
+
{ url = "https://files.pythonhosted.org/packages/a6/e9/313840f1e52c6dfac47f704428cbfbce59956ebe7633bffc92b03f74f0ad/pymongo-4.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe0de9d0f6791abce3471230b32b4817bf89d27b1182b6a550e1ec0fa72aa9a", size = 930665, upload-time = "2026-04-20T16:38:02.915Z" },
|
| 2795 |
+
{ url = "https://files.pythonhosted.org/packages/78/35/9d3565ea45b1606f635c1e2cd2563c28d66caafdc50f7ad7d979fcd1b363/pymongo-4.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e537e95514dae1aaa718f481ec03151a0f0394bcd05f1322896d8fc1330cb729", size = 1762369, upload-time = "2026-04-20T16:38:05.375Z" },
|
| 2796 |
+
{ url = "https://files.pythonhosted.org/packages/95/ee/149b0d4b1a11c38bff6f14c23d5814c9b0843fd6dc38ad40596bdb1a62d2/pymongo-4.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37a8385c29881b43eab31f584100fa0eaddedd5607adf010147ba1810118be90", size = 1798044, upload-time = "2026-04-20T16:38:07.195Z" },
|
| 2797 |
+
{ url = "https://files.pythonhosted.org/packages/7b/d4/4cee4a7b8d8f6f0550ef6cd2fea42455c5ed619a220cb6ba4fb40d6a5bc8/pymongo-4.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3ee3d241ed77a4fc99ce3cff3b289c3ebce37f61fdd7349d3592c23b82c8784", size = 1878567, upload-time = "2026-04-20T16:38:09.121Z" },
|
| 2798 |
+
{ url = "https://files.pythonhosted.org/packages/45/ef/7fe366c84952619ee2f69973566c214775e083dd4df465751912153e4b72/pymongo-4.17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9eb5d63a3c518cb0804ed678f5e2b875af032d89a7cf57a57360322cf6a4d222", size = 1864881, upload-time = "2026-04-20T16:38:10.896Z" },
|
| 2799 |
+
{ url = "https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e97e03fa13327c87e3fdc5656acd01e71817f0c1dc3221cd8f30de136bf4ec3", size = 1800349, upload-time = "2026-04-20T16:38:13.589Z" },
|
| 2800 |
+
{ url = "https://files.pythonhosted.org/packages/b8/69/dafcf04f66e130ddd91aeb92e7a692480eda46dcd04ec1dbe82c06619e10/pymongo-4.17.0-cp312-cp312-win32.whl", hash = "sha256:6877214bff5f06f6884a9fc8d9016a4a7a5f51f537f5c51ac3a576f93e7dfb32", size = 900518, upload-time = "2026-04-20T16:38:15.541Z" },
|
| 2801 |
+
{ url = "https://files.pythonhosted.org/packages/11/35/5c9262a459f988b4eb2605f70815240b77a0d4131136c4326d18f1822b89/pymongo-4.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9828485f72f63c7d802e0ec41f71906f633c2692621ab3af55ca990186b091b1", size = 920335, upload-time = "2026-04-20T16:38:17.665Z" },
|
| 2802 |
+
{ url = "https://files.pythonhosted.org/packages/8d/da/e9c7265ee176faccf4e52c4797837e794d93569a1046f6b19a4acc36e5ad/pymongo-4.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1195370a77baf003b59b10e91ecc4706297197f0dd9d29c840cc556dc08f7cee", size = 903289, upload-time = "2026-04-20T16:38:19.33Z" },
|
| 2803 |
+
{ url = "https://files.pythonhosted.org/packages/2a/6b/c1206879708b94e82fcd8b9653440ec271f79a3674d122192df383047f5a/pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15", size = 985829, upload-time = "2026-04-20T16:38:21.031Z" },
|
| 2804 |
+
{ url = "https://files.pythonhosted.org/packages/cb/cf/bb044ed85160e5c40f568c7c4f4e8ea16f40764ff5d302e5befbe8f6f814/pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be", size = 985899, upload-time = "2026-04-20T16:38:23.409Z" },
|
| 2805 |
+
{ url = "https://files.pythonhosted.org/packages/74/0a/f6dfd5ea3901e5d6888da8de8ba728971a1d447debab681cfc56f90d1208/pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5", size = 2028569, upload-time = "2026-04-20T16:38:25.343Z" },
|
| 2806 |
+
{ url = "https://files.pythonhosted.org/packages/4a/c5/081f59a1c02ae8c0dc73ae58e563838c44eec81aeafa7d0b93a637841c9b/pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9", size = 2072916, upload-time = "2026-04-20T16:38:27.291Z" },
|
| 2807 |
+
{ url = "https://files.pythonhosted.org/packages/31/42/6e41d434297ffe8b30d9c3717916591a4a7be9075a0dcc2fafdfaaaa62ed/pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2", size = 2173234, upload-time = "2026-04-20T16:38:29.474Z" },
|
| 2808 |
+
{ url = "https://files.pythonhosted.org/packages/3d/cf/1e4a7db352ef9485831c7268dfe8402f0117b32a9ad54b16e810699e3617/pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e", size = 2156784, upload-time = "2026-04-20T16:38:32.134Z" },
|
| 2809 |
+
{ url = "https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6", size = 2074446, upload-time = "2026-04-20T16:38:34.004Z" },
|
| 2810 |
+
{ url = "https://files.pythonhosted.org/packages/37/48/33410b8819837ed370c738587306bdf060b59cef11823be212f4a07703c5/pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7", size = 948435, upload-time = "2026-04-20T16:38:35.922Z" },
|
| 2811 |
+
{ url = "https://files.pythonhosted.org/packages/6f/77/c0ed522f798a286b99acaa7914ed8d9c80ab091f97f57c59ffed72906e5e/pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38", size = 972847, upload-time = "2026-04-20T16:38:37.888Z" },
|
| 2812 |
+
{ url = "https://files.pythonhosted.org/packages/97/f0/c39480a2db385fde23861d0c8acda41cdaf1d43e46579db72c5c013a2e81/pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e", size = 951575, upload-time = "2026-04-20T16:38:40.544Z" },
|
| 2813 |
+
{ url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" },
|
| 2814 |
+
{ url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" },
|
| 2815 |
+
{ url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" },
|
| 2816 |
+
{ url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" },
|
| 2817 |
+
{ url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" },
|
| 2818 |
+
{ url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" },
|
| 2819 |
+
{ url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" },
|
| 2820 |
+
{ url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" },
|
| 2821 |
+
{ url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" },
|
| 2822 |
+
{ url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" },
|
| 2823 |
+
{ url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" },
|
| 2824 |
+
{ url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" },
|
| 2825 |
+
{ url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" },
|
| 2826 |
+
{ url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" },
|
| 2827 |
+
{ url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" },
|
| 2828 |
+
{ url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" },
|
| 2829 |
+
{ url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" },
|
| 2830 |
+
{ url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" },
|
| 2831 |
+
{ url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" },
|
| 2832 |
+
{ url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" },
|
| 2833 |
+
]
|
| 2834 |
+
|
| 2835 |
[[package]]
|
| 2836 |
name = "pyperclip"
|
| 2837 |
version = "1.11.0"
|