Cyber Catalyst Team commited on
Commit
d946914
·
1 Parent(s): 8d7c6d3

Implement compliant SessionStore API endpoints, completions concurrency limit, mirror_error alert, and nightly DB retention policy

Browse files
Files changed (1) hide show
  1. backend.py +195 -72
backend.py CHANGED
@@ -23,7 +23,8 @@ import time
23
  import re
24
  import collections
25
  from pathlib import Path
26
- from typing import AsyncIterator, Optional
 
27
 
28
  from fastapi import FastAPI, Request, Header, HTTPException
29
  from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
@@ -364,16 +365,16 @@ async def init_db():
364
  )
365
  async with db_pool.acquire() as conn:
366
  await conn.execute("""
367
- CREATE TABLE IF NOT EXISTS agent_sessions (
368
- id BIGSERIAL PRIMARY KEY,
369
- session_id TEXT NOT NULL,
370
- role TEXT NOT NULL,
371
- content TEXT,
372
- tool_calls JSONB,
373
- tool_call_id TEXT,
374
- created_at TIMESTAMPTZ DEFAULT NOW()
375
  );
376
- CREATE INDEX IF NOT EXISTS idx_agent_sessions_sid ON agent_sessions(session_id);
 
377
  """)
378
  except Exception as e:
379
  print(f"[DB] Warning: Could not initialize database: {e}")
@@ -382,42 +383,43 @@ async def init_db():
382
 
383
  async def save_message(session_id: str, role: str, content: str = None,
384
  tool_calls: list = None, tool_call_id: str = None):
385
- """Save a message to the session store."""
386
  if not db_pool:
387
  return
 
 
 
 
 
 
 
 
388
  try:
389
  async with db_pool.acquire() as conn:
390
  await conn.execute(
391
- "INSERT INTO agent_sessions (session_id, role, content, tool_calls, tool_call_id) VALUES ($1, $2, $3, $4, $5)",
392
- session_id, role, content,
393
- json.dumps(tool_calls) if tool_calls else None,
394
- tool_call_id,
 
395
  )
396
  except Exception as e:
397
  print(f"[DB] Warning: Could not save message: {e}")
398
 
399
 
400
  async def load_session(session_id: str) -> list:
401
- """Load conversation history from the session store."""
402
  if not db_pool:
403
  return []
404
  try:
405
  async with db_pool.acquire() as conn:
406
  rows = await conn.fetch(
407
- "SELECT role, content, tool_calls, tool_call_id FROM agent_sessions WHERE session_id = $1 ORDER BY id",
 
408
  session_id,
 
409
  )
410
- messages = []
411
- for row in rows:
412
- msg = {"role": row["role"]}
413
- if row["content"]:
414
- msg["content"] = row["content"]
415
- if row["tool_calls"]:
416
- msg["tool_calls"] = json.loads(row["tool_calls"])
417
- if row["tool_call_id"]:
418
- msg["tool_call_id"] = row["tool_call_id"]
419
- messages.append(msg)
420
- return messages
421
  except Exception as e:
422
  print(f"[DB] Warning: Could not load session: {e}")
423
  return []
@@ -621,7 +623,8 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
621
  if is_agentic:
622
  kwargs["tools"] = TOOLS
623
  kwargs["tool_choice"] = "auto"
624
- response = await nim_client.chat.completions.create(**kwargs)
 
625
  content = response.choices[0].message.content or ""
626
  await save_message(session_id, "assistant", content)
627
  ACTIVE_SESSIONS.discard(session_id)
@@ -640,50 +643,51 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
640
  # Streaming + agentic loop
641
  async def generate() -> AsyncIterator[str]:
642
  nonlocal final_messages
643
- try:
644
- for round_num in range(MAX_TOOL_ROUNDS + 1):
645
- kwargs = {"model": requested_model, "messages": final_messages, "stream": True}
646
- if is_agentic:
647
- kwargs["tools"] = TOOLS
648
- kwargs["tool_choice"] = "auto"
649
-
650
- # Collect streamed response
651
- full_content = ""
652
- tool_calls_raw = {} # index -> {id, name, arguments_str}
653
-
654
- async for chunk in await nim_client.chat.completions.create(**kwargs):
655
- choice = chunk.choices[0] if chunk.choices else None
656
- if not choice:
657
- continue
658
- delta = choice.delta
659
-
660
- # Stream text content to client
661
- if delta and delta.content:
662
- full_content += delta.content
663
- yield make_chunk(request_id, requested_model, delta.content)
664
-
665
- # Collect tool calls
666
- if delta and delta.tool_calls:
667
- for tc in delta.tool_calls:
668
- idx = tc.index
669
- if idx not in tool_calls_raw:
670
- tool_calls_raw[idx] = {
671
- "id": tc.id or f"call_{uuid.uuid4().hex[:8]}",
672
- "name": tc.function.name if tc.function and tc.function.name else "",
673
- "arguments": ""
674
- }
675
- if tc.function and tc.function.name:
676
- tool_calls_raw[idx]["name"] = tc.function.name
677
- if tc.id:
678
- tool_calls_raw[idx]["id"] = tc.id
679
- if tc.function and tc.function.arguments:
680
- tool_calls_raw[idx]["arguments"] += tc.function.arguments
681
 
682
- # Check for finish
683
- if choice.finish_reason == "stop":
684
- break
685
- if choice.finish_reason == "tool_calls":
686
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
 
688
  # If no tool calls, we're done
689
  if not tool_calls_raw:
@@ -1176,6 +1180,123 @@ Select a file from the sidebar explorer on the left to read its code contents in
1176
  </html>
1177
  """
1178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1179
  @app.get("/", response_class=HTMLResponse)
1180
  async def dashboard():
1181
  return HTMLResponse(content=DASHBOARD_HTML)
@@ -1346,6 +1467,8 @@ async def startup_event():
1346
  threading.Thread(target=run_watchdog, daemon=True).start()
1347
  # Start the db keep-alive loop on FastAPI event loop
1348
  asyncio.create_task(db_heartbeat_loop())
 
 
1349
 
1350
 
1351
  # ---------------------------------------------------------------------------
 
23
  import re
24
  import collections
25
  from pathlib import Path
26
+ from typing import AsyncIterator, Optional, List, Dict, Any
27
+ from pydantic import BaseModel
28
 
29
  from fastapi import FastAPI, Request, Header, HTTPException
30
  from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
 
365
  )
366
  async with db_pool.acquire() as conn:
367
  await conn.execute("""
368
+ CREATE TABLE IF NOT EXISTS agent_session_entries (
369
+ id BIGSERIAL PRIMARY KEY,
370
+ project_key TEXT NOT NULL,
371
+ session_id TEXT NOT NULL,
372
+ subpath TEXT,
373
+ entry JSONB NOT NULL,
374
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
 
375
  );
376
+ CREATE INDEX IF NOT EXISTS idx_session_key ON agent_session_entries (project_key, session_id, subpath, id);
377
+ CREATE INDEX IF NOT EXISTS idx_project_session ON agent_session_entries (project_key, session_id);
378
  """)
379
  except Exception as e:
380
  print(f"[DB] Warning: Could not initialize database: {e}")
 
383
 
384
  async def save_message(session_id: str, role: str, content: str = None,
385
  tool_calls: list = None, tool_call_id: str = None):
386
+ """Save a message to the session store using the unified schema."""
387
  if not db_pool:
388
  return
389
+ msg = {"role": role}
390
+ if content is not None:
391
+ msg["content"] = content
392
+ if tool_calls:
393
+ msg["tool_calls"] = tool_calls
394
+ if tool_call_id:
395
+ msg["tool_call_id"] = tool_call_id
396
+
397
  try:
398
  async with db_pool.acquire() as conn:
399
  await conn.execute(
400
+ "INSERT INTO agent_session_entries (project_key, session_id, subpath, entry) VALUES ($1, $2, $3, $4)",
401
+ "fastapi-completions",
402
+ session_id,
403
+ None,
404
+ json.dumps(msg)
405
  )
406
  except Exception as e:
407
  print(f"[DB] Warning: Could not save message: {e}")
408
 
409
 
410
  async def load_session(session_id: str) -> list:
411
+ """Load conversation history from the session store using the unified schema."""
412
  if not db_pool:
413
  return []
414
  try:
415
  async with db_pool.acquire() as conn:
416
  rows = await conn.fetch(
417
+ "SELECT entry FROM agent_session_entries WHERE project_key = $1 AND session_id = $2 AND subpath IS NOT DISTINCT FROM $3 ORDER BY id",
418
+ "fastapi-completions",
419
  session_id,
420
+ None
421
  )
422
+ return [json.loads(row["entry"]) for row in rows]
 
 
 
 
 
 
 
 
 
 
423
  except Exception as e:
424
  print(f"[DB] Warning: Could not load session: {e}")
425
  return []
 
623
  if is_agentic:
624
  kwargs["tools"] = TOOLS
625
  kwargs["tool_choice"] = "auto"
626
+ async with completions_semaphore:
627
+ response = await nim_client.chat.completions.create(**kwargs)
628
  content = response.choices[0].message.content or ""
629
  await save_message(session_id, "assistant", content)
630
  ACTIVE_SESSIONS.discard(session_id)
 
643
  # Streaming + agentic loop
644
  async def generate() -> AsyncIterator[str]:
645
  nonlocal final_messages
646
+ async with completions_semaphore:
647
+ try:
648
+ for round_num in range(MAX_TOOL_ROUNDS + 1):
649
+ kwargs = {"model": requested_model, "messages": final_messages, "stream": True}
650
+ if is_agentic:
651
+ kwargs["tools"] = TOOLS
652
+ kwargs["tool_choice"] = "auto"
653
+
654
+ # Collect streamed response
655
+ full_content = ""
656
+ tool_calls_raw = {} # index -> {id, name, arguments_str}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
 
658
+ async for chunk in await nim_client.chat.completions.create(**kwargs):
659
+ choice = chunk.choices[0] if chunk.choices else None
660
+ if not choice:
661
+ continue
662
+ delta = choice.delta
663
+
664
+ # Stream text content to client
665
+ if delta and delta.content:
666
+ full_content += delta.content
667
+ yield make_chunk(request_id, requested_model, delta.content)
668
+
669
+ # Collect tool calls
670
+ if delta and delta.tool_calls:
671
+ for tc in delta.tool_calls:
672
+ idx = tc.index
673
+ if idx not in tool_calls_raw:
674
+ tool_calls_raw[idx] = {
675
+ "id": tc.id or f"call_{uuid.uuid4().hex[:8]}",
676
+ "name": tc.function.name if tc.function and tc.function.name else "",
677
+ "arguments": ""
678
+ }
679
+ if tc.function and tc.function.name:
680
+ tool_calls_raw[idx]["name"] = tc.function.name
681
+ if tc.id:
682
+ tool_calls_raw[idx]["id"] = tc.id
683
+ if tc.function and tc.function.arguments:
684
+ tool_calls_raw[idx]["arguments"] += tc.function.arguments
685
+
686
+ # Check for finish
687
+ if choice.finish_reason == "stop":
688
+ break
689
+ if choice.finish_reason == "tool_calls":
690
+ break
691
 
692
  # If no tool calls, we're done
693
  if not tool_calls_raw:
 
1180
  </html>
1181
  """
1182
 
1183
+ # ---------------------------------------------------------------------------
1184
+ # Concurrency Settings
1185
+ # ---------------------------------------------------------------------------
1186
+ completions_semaphore = asyncio.Semaphore(2)
1187
+
1188
+
1189
+ # ---------------------------------------------------------------------------
1190
+ # SessionStore API (Claude Agent SDK / Claude Code Compatible)
1191
+ # ---------------------------------------------------------------------------
1192
+
1193
+ class SessionAppendRequest(BaseModel):
1194
+ project_key: str
1195
+ session_id: str
1196
+ subpath: Optional[str] = None
1197
+ entries: List[Dict[str, Any]]
1198
+
1199
+ @app.post("/api/sessions/append")
1200
+ async def append_session_entries(req: SessionAppendRequest, authorization: str = Header(None)):
1201
+ auth(authorization)
1202
+ if not db_pool:
1203
+ raise HTTPException(status_code=500, detail="Database not connected")
1204
+ try:
1205
+ # Check for mirror_error and log/alert if present
1206
+ for e in req.entries:
1207
+ if e.get("type") == "system" and e.get("subtype") == "mirror_error":
1208
+ log_activity(f"[ALERT] Claude Agent SDK reported mirror_error: {e.get('message')}")
1209
+
1210
+ rows = [(req.project_key, req.session_id, req.subpath, json.dumps(e)) for e in req.entries]
1211
+ async with db_pool.acquire() as conn:
1212
+ await conn.executemany(
1213
+ "INSERT INTO agent_session_entries (project_key, session_id, subpath, entry) VALUES ($1, $2, $3, $4)",
1214
+ rows
1215
+ )
1216
+ return {"status": "success"}
1217
+ except Exception as e:
1218
+ log_activity(f"[SessionStore Error] Failed append: {e}")
1219
+ raise HTTPException(status_code=500, detail=str(e))
1220
+
1221
+ class SessionLoadRequest(BaseModel):
1222
+ project_key: str
1223
+ session_id: str
1224
+ subpath: Optional[str] = None
1225
+
1226
+ @app.post("/api/sessions/load")
1227
+ async def load_session_entries(req: SessionLoadRequest, authorization: str = Header(None)):
1228
+ auth(authorization)
1229
+ if not db_pool:
1230
+ raise HTTPException(status_code=500, detail="Database not connected")
1231
+ try:
1232
+ async with db_pool.acquire() as conn:
1233
+ rows = await conn.fetch(
1234
+ "SELECT entry FROM agent_session_entries WHERE project_key=$1 AND session_id=$2 AND subpath IS NOT DISTINCT FROM $3 ORDER BY id",
1235
+ req.project_key, req.session_id, req.subpath
1236
+ )
1237
+ return {"entries": [json.loads(r["entry"]) for r in rows]}
1238
+ except Exception as e:
1239
+ log_activity(f"[SessionStore Error] Failed load: {e}")
1240
+ raise HTTPException(status_code=500, detail=str(e))
1241
+
1242
+ class SessionListRequest(BaseModel):
1243
+ project_key: str
1244
+
1245
+ @app.post("/api/sessions/list")
1246
+ async def list_sessions(req: SessionListRequest, authorization: str = Header(None)):
1247
+ auth(authorization)
1248
+ if not db_pool:
1249
+ raise HTTPException(status_code=500, detail="Database not connected")
1250
+ try:
1251
+ async with db_pool.acquire() as conn:
1252
+ rows = await conn.fetch(
1253
+ "SELECT DISTINCT session_id FROM agent_session_entries WHERE project_key=$1",
1254
+ req.project_key
1255
+ )
1256
+ return {"sessions": [r["session_id"] for r in rows]}
1257
+ except Exception as e:
1258
+ raise HTTPException(status_code=500, detail=str(e))
1259
+
1260
+ class SessionDeleteRequest(BaseModel):
1261
+ project_key: str
1262
+ session_id: str
1263
+
1264
+ @app.post("/api/sessions/delete")
1265
+ async def delete_session(req: SessionDeleteRequest, authorization: str = Header(None)):
1266
+ auth(authorization)
1267
+ if not db_pool:
1268
+ raise HTTPException(status_code=500, detail="Database not connected")
1269
+ try:
1270
+ async with db_pool.acquire() as conn:
1271
+ await conn.execute(
1272
+ "DELETE FROM agent_session_entries WHERE project_key=$1 AND session_id=$2",
1273
+ req.project_key, req.session_id
1274
+ )
1275
+ return {"status": "success"}
1276
+ except Exception as e:
1277
+ raise HTTPException(status_code=500, detail=str(e))
1278
+
1279
+
1280
+ # ---------------------------------------------------------------------------
1281
+ # Database Retention & Heartbeat loops
1282
+ # ---------------------------------------------------------------------------
1283
+
1284
+ async def db_cleanup_loop():
1285
+ log_activity("Database Retention Cleanup task started")
1286
+ while True:
1287
+ try:
1288
+ if db_pool:
1289
+ async with db_pool.acquire() as conn:
1290
+ # Purge session entries older than 30 days
1291
+ result = await conn.execute(
1292
+ "DELETE FROM agent_session_entries WHERE created_at < NOW() - INTERVAL '30 days'"
1293
+ )
1294
+ log_activity(f"[Cleanup] Nightly retention sweep complete. Status: {result}")
1295
+ except Exception as e:
1296
+ log_activity(f"[Cleanup Warning] Failed to run retention cleanup: {e}")
1297
+ await asyncio.sleep(86400) # Every 24 hours
1298
+
1299
+
1300
  @app.get("/", response_class=HTMLResponse)
1301
  async def dashboard():
1302
  return HTMLResponse(content=DASHBOARD_HTML)
 
1467
  threading.Thread(target=run_watchdog, daemon=True).start()
1468
  # Start the db keep-alive loop on FastAPI event loop
1469
  asyncio.create_task(db_heartbeat_loop())
1470
+ # Start the db nightly retention cleanup loop
1471
+ asyncio.create_task(db_cleanup_loop())
1472
 
1473
 
1474
  # ---------------------------------------------------------------------------