mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-06 21:22:23 +01:00
Squash Odysseus development history
This commit is contained in:
+302
-62
@@ -3,8 +3,10 @@ import re
|
||||
import html
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, Request
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, Request, Query
|
||||
import logging
|
||||
|
||||
from core.session_manager import SessionManager
|
||||
@@ -60,6 +62,114 @@ def _content_to_text(content) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _context_info_skill_inventory(
|
||||
skills_manager, owner: str | None, limit: int = 80
|
||||
) -> list[dict]:
|
||||
"""Compact skill metadata for TUI context/status/autocomplete.
|
||||
|
||||
This intentionally exposes only the skill index fields. Full SKILL.md
|
||||
bodies remain behind manage_skills/view so context_info cannot become a
|
||||
prompt/body dump path.
|
||||
"""
|
||||
if not skills_manager:
|
||||
return []
|
||||
try:
|
||||
indexed = skills_manager.index_for(owner=owner, active_toolsets=None)
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
loaded = skills_manager.load(owner=owner)
|
||||
except Exception:
|
||||
loaded = []
|
||||
paths_by_name = {
|
||||
str(skill.get("name") or ""): str(skill.get("path") or "").strip()
|
||||
for skill in loaded
|
||||
if isinstance(skill, dict)
|
||||
}
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for row in indexed:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
name = str(row.get("name") or "").strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
item = {"name": name}
|
||||
description = str(row.get("description") or "").strip()
|
||||
if description:
|
||||
item["description"] = description
|
||||
path = paths_by_name.get(name, "")
|
||||
if path:
|
||||
item["source"] = f"file: {path}"
|
||||
out.append(item)
|
||||
seen.add(name)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _context_info_tool_inventory(limit: int = 80) -> list[dict]:
|
||||
"""Compact built-in tool metadata for TUI context/status/autocomplete."""
|
||||
try:
|
||||
from src.tool_index import BUILTIN_TOOL_DESCRIPTIONS
|
||||
except Exception:
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for name, description in BUILTIN_TOOL_DESCRIPTIONS.items():
|
||||
clean_name = str(name or "").strip()
|
||||
if not clean_name:
|
||||
continue
|
||||
item = {"name": clean_name, "source": "backend"}
|
||||
clean_description = re.sub(r"\s+", " ", str(description or "")).strip()
|
||||
if clean_description:
|
||||
item["description"] = clean_description[:280]
|
||||
out.append(item)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _context_info_agents_md_inventory(workspace: str | None, limit: int = 8) -> list[dict]:
|
||||
"""Compact AGENTS.md path metadata for the active workspace.
|
||||
|
||||
Bodies intentionally stay on disk. The TUI can read a selected file only
|
||||
when the user asks for `/agent <path> --show`.
|
||||
"""
|
||||
try:
|
||||
from src.tool_execution import vet_workspace
|
||||
root = vet_workspace(workspace or "")
|
||||
except Exception:
|
||||
root = None
|
||||
if not root:
|
||||
return []
|
||||
|
||||
start = Path(root).resolve()
|
||||
candidates = []
|
||||
current = start
|
||||
while True:
|
||||
candidate = current / "AGENTS.md"
|
||||
if candidate.is_file():
|
||||
candidates.append(candidate)
|
||||
if current.parent == current:
|
||||
break
|
||||
current = current.parent
|
||||
if len(candidates) >= limit:
|
||||
break
|
||||
|
||||
# Codex-style precedence reads parent instructions before child overrides.
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in reversed(candidates):
|
||||
path = str(candidate)
|
||||
if path in seen:
|
||||
continue
|
||||
out.append({"path": path, "source": "workspace"})
|
||||
seen.add(path)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _message_role(message) -> str:
|
||||
if isinstance(message, ChatMessage):
|
||||
return message.role or ""
|
||||
@@ -157,20 +267,36 @@ def _reject_raw_endpoint_url_for_non_admin(
|
||||
raise HTTPException(403, "Choose a registered model endpoint")
|
||||
|
||||
|
||||
def _persist_session_headers(session_id: str, headers: dict | None) -> None:
|
||||
def _persist_session_headers(session_id: str, headers: dict | None) -> bool:
|
||||
"""Persist endpoint auth headers for DB-backed session metadata."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.headers = headers or {}
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
delays = (0.05, 0.15, 0.35)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(len(delays) + 1):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.headers = headers or {}
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
last_exc = exc
|
||||
if attempt >= len(delays):
|
||||
break
|
||||
if "database is locked" not in str(exc).lower():
|
||||
break
|
||||
time.sleep(delays[attempt])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.warning(
|
||||
"Failed to persist headers for session %s; continuing with in-memory headers: %s",
|
||||
session_id,
|
||||
last_exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
_HIDDEN_SYSTEM_SESSION_NAMES = {
|
||||
@@ -184,6 +310,16 @@ _HIDDEN_SYSTEM_SESSION_NAMES = {
|
||||
}
|
||||
|
||||
|
||||
def _is_hidden_session_name(name: str | None) -> bool:
|
||||
"""Return whether a session should be omitted from the sidebar list."""
|
||||
clean = (name or "").strip()
|
||||
return (
|
||||
clean in ("Nobody", "Incognito")
|
||||
or clean in _HIDDEN_SYSTEM_SESSION_NAMES
|
||||
or clean.startswith("SFT trace batch ")
|
||||
)
|
||||
|
||||
|
||||
def _pick_endpoint_for_sort(owner=None):
|
||||
"""Pick model endpoint for auto-sort LLM call — uses utility endpoint setting, falls back to default."""
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
@@ -210,6 +346,7 @@ def setup_session_routes(
|
||||
config: dict,
|
||||
webhook_manager=None,
|
||||
upload_handler=None,
|
||||
skills_manager=None,
|
||||
):
|
||||
"""Setup session routes with the provided manager and config"""
|
||||
|
||||
@@ -258,35 +395,22 @@ def setup_session_routes(
|
||||
except Exception:
|
||||
pass
|
||||
user_sessions = session_manager.get_sessions_for_user(user)
|
||||
# Fetch folder info from DB for each session
|
||||
# The sidebar must be backed by persisted DB rows. SessionManager only
|
||||
# hydrates a bounded recent cache at startup, so older-but-valid
|
||||
# conversations can disappear after refresh if this endpoint trusts
|
||||
# memory as the source of truth.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
folder_map = {}
|
||||
token_map = {}
|
||||
important_map = {}
|
||||
created_map = {}
|
||||
updated_map = {}
|
||||
last_msg_map = {}
|
||||
mode_map = {}
|
||||
msg_count_map = {}
|
||||
q = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False)
|
||||
q = (
|
||||
db.query(DbSession)
|
||||
.filter(DbSession.archived == False)
|
||||
.order_by(DbSession.is_important.desc(), DbSession.updated_at.desc())
|
||||
)
|
||||
q = owner_filter(q, DbSession, user)
|
||||
rows = q.all()
|
||||
for row in rows:
|
||||
folder_map[row.id] = row.folder
|
||||
token_map[row.id] = (row.total_input_tokens or 0) + (row.total_output_tokens or 0)
|
||||
important_map[row.id] = row.is_important or False
|
||||
created_map[row.id] = row.created_at.isoformat() if row.created_at else None
|
||||
updated_map[row.id] = row.updated_at.isoformat() if row.updated_at else None
|
||||
# Fall back to updated_at then created_at so sessions that
|
||||
# predate the column (or have no messages) still sort sanely.
|
||||
last_msg_map[row.id] = (
|
||||
row.last_message_at.isoformat() if row.last_message_at
|
||||
else (row.updated_at.isoformat() if row.updated_at
|
||||
else (row.created_at.isoformat() if row.created_at else None))
|
||||
)
|
||||
mode_map[row.id] = row.mode
|
||||
msg_count_map[row.id] = row.message_count or 0
|
||||
rows = [
|
||||
row for row in q.all()
|
||||
if not _is_hidden_session_name(row.name)
|
||||
]
|
||||
# Sessions with active documents that have content
|
||||
from sqlalchemy import func
|
||||
doc_session_ids = set(
|
||||
@@ -305,26 +429,58 @@ def setup_session_routes(
|
||||
GalleryImage, user)
|
||||
.distinct().all()
|
||||
)
|
||||
|
||||
# Resolve saved routes without waiting for the frontend model catalog.
|
||||
from core.database import ModelEndpoint
|
||||
from src.endpoint_resolver import build_chat_url, normalize_base
|
||||
endpoint_routes = {}
|
||||
endpoint_query = owner_filter(db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), ModelEndpoint, user)
|
||||
for endpoint in endpoint_query.all():
|
||||
route_url = build_chat_url(normalize_base(endpoint.base_url or '')).rstrip('/')
|
||||
endpoint_routes.setdefault(route_url, []).append(endpoint)
|
||||
sessions = []
|
||||
for s in rows:
|
||||
if (
|
||||
(s.message_count or 0) <= 0
|
||||
and s.id not in doc_session_ids
|
||||
and s.id not in img_session_ids
|
||||
and s.id not in user_sessions
|
||||
):
|
||||
continue
|
||||
# Fall back to updated_at then created_at so sessions that
|
||||
# predate the column (or have no messages) still sort sanely.
|
||||
last_message_at = (
|
||||
s.last_message_at.isoformat() if s.last_message_at
|
||||
else (s.updated_at.isoformat() if s.updated_at
|
||||
else (s.created_at.isoformat() if s.created_at else None))
|
||||
)
|
||||
matches = endpoint_routes.get((s.endpoint_url or '').rstrip('/'), [])
|
||||
selected_endpoint = matches[0] if len(matches) == 1 else None
|
||||
sessions.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"model": _public_model(s.name, s.model),
|
||||
"endpoint_url": s.endpoint_url,
|
||||
"endpoint_id": selected_endpoint.id if selected_endpoint else None,
|
||||
"endpoint_name": selected_endpoint.name if selected_endpoint else None,
|
||||
"rag": s.rag,
|
||||
"archived": s.archived,
|
||||
"folder": s.folder,
|
||||
"cwd": s.cwd,
|
||||
"total_tokens": (s.total_input_tokens or 0) + (s.total_output_tokens or 0),
|
||||
"total_cost_usd": s.total_cost_usd or 0.0,
|
||||
"is_important": s.is_important or False,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
"last_message_at": last_message_at,
|
||||
"has_documents": s.id in doc_session_ids,
|
||||
"has_images": s.id in img_session_ids,
|
||||
"mode": s.mode,
|
||||
"message_count": s.message_count or 0,
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
sessions = [{"id": s.id, "name": s.name, "model": _public_model(s.name, s.model),
|
||||
"endpoint_url": s.endpoint_url, "rag": s.rag,
|
||||
"archived": s.archived, "folder": folder_map.get(s.id),
|
||||
"total_tokens": token_map.get(s.id, 0),
|
||||
"is_important": important_map.get(s.id, False),
|
||||
"created_at": created_map.get(s.id),
|
||||
"updated_at": updated_map.get(s.id),
|
||||
"last_message_at": last_msg_map.get(s.id),
|
||||
"has_documents": s.id in doc_session_ids,
|
||||
"has_images": s.id in img_session_ids,
|
||||
"mode": mode_map.get(s.id),
|
||||
"message_count": msg_count_map.get(s.id, 0)}
|
||||
for s in user_sessions.values()
|
||||
if not s.archived
|
||||
and (s.name or "").strip() not in ("Nobody", "Incognito")
|
||||
and (s.name or "").strip() not in _HIDDEN_SYSTEM_SESSION_NAMES]
|
||||
|
||||
return sessions
|
||||
|
||||
@router.post("/session", response_model=SessionResponse)
|
||||
@@ -337,6 +493,7 @@ def setup_session_routes(
|
||||
skip_validation: str = Form(None),
|
||||
api_key: str = Form(""),
|
||||
endpoint_id: str = Form(""),
|
||||
cwd: str = Form(None),
|
||||
):
|
||||
skip_val = str(skip_validation).lower() == "true"
|
||||
user = effective_user(request)
|
||||
@@ -432,6 +589,7 @@ def setup_session_routes(
|
||||
model=model_to_use,
|
||||
rag=str(rag).lower() == "true" if rag else False,
|
||||
owner=user,
|
||||
cwd=cwd or None,
|
||||
)
|
||||
# Set auth headers for custom API-key endpoints
|
||||
resolved_key = request_api_key
|
||||
@@ -456,7 +614,8 @@ def setup_session_routes(
|
||||
name=session.name,
|
||||
model=model_to_use,
|
||||
rag=str(rag).lower() == "true" if rag else False,
|
||||
archived=False
|
||||
archived=False,
|
||||
cwd=session.cwd,
|
||||
)
|
||||
@router.patch("/session/{sid}")
|
||||
def rename_session(
|
||||
@@ -464,6 +623,7 @@ def setup_session_routes(
|
||||
name: str = Form(None), folder: str = Form(None),
|
||||
model: str = Form(None), endpoint_url: str = Form(None),
|
||||
endpoint_id: str = Form(None),
|
||||
cwd: str = Form(None),
|
||||
):
|
||||
_verify_session_owner(request, sid)
|
||||
try:
|
||||
@@ -486,6 +646,19 @@ def setup_session_routes(
|
||||
result["folder"] = folder if folder else None
|
||||
finally:
|
||||
db.close()
|
||||
if cwd is not None:
|
||||
clean_cwd = cwd.strip() or None
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
|
||||
if db_session:
|
||||
db_session.cwd = clean_cwd
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
session.cwd = clean_cwd
|
||||
result["cwd"] = clean_cwd
|
||||
finally:
|
||||
db.close()
|
||||
# Switch model/endpoint mid-session
|
||||
if model is not None and endpoint_url is not None:
|
||||
user = effective_user(request)
|
||||
@@ -597,6 +770,8 @@ def setup_session_routes(
|
||||
db.close()
|
||||
|
||||
if session_manager.delete_session(sid):
|
||||
from routes.chat_helpers import remove_session_sft_trace_rows
|
||||
remove_session_sft_trace_rows(effective_user(request), sid)
|
||||
deleted_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
@@ -621,6 +796,8 @@ def setup_session_routes(
|
||||
|
||||
# Delete the session and all its messages
|
||||
if session_manager.delete_session(sid):
|
||||
from routes.chat_helpers import remove_session_sft_trace_rows
|
||||
remove_session_sft_trace_rows(effective_user(request), sid)
|
||||
return {"status": "deleted"}
|
||||
else:
|
||||
raise HTTPException(404, "Session not found")
|
||||
@@ -1034,11 +1211,19 @@ def setup_session_routes(
|
||||
if not session_manager.replace_messages(session_id, new_history):
|
||||
raise HTTPException(500, "Failed to save compacted history")
|
||||
|
||||
# Rough token estimate of the compacted history so clients can
|
||||
# refresh their context-pressure display without waiting for the
|
||||
# next turn's metrics event.
|
||||
context_tokens_estimate = sum(
|
||||
len(_message_text(m) or "") // 4 + 8 for m in new_history
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"summarized": len(older),
|
||||
"kept": len(recent),
|
||||
"message_count": len(new_history),
|
||||
"context_tokens_estimate": context_tokens_estimate,
|
||||
}
|
||||
|
||||
@router.post("/sessions/auto-sort")
|
||||
@@ -1328,19 +1513,74 @@ def setup_session_routes(
|
||||
}
|
||||
|
||||
@router.get("/session/{session_id}/context_info")
|
||||
async def get_context_info(request: Request, session_id: str):
|
||||
async def get_context_info(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
cwd: str | None = Query(default=None),
|
||||
):
|
||||
"""Get the real context length for a session's model from the endpoint."""
|
||||
_verify_session_owner(request, session_id)
|
||||
owner = effective_user(request)
|
||||
session = session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(404, "Session not found")
|
||||
skills = _context_info_skill_inventory(skills_manager, owner=owner)
|
||||
tools = _context_info_tool_inventory()
|
||||
agents_md = _context_info_agents_md_inventory(cwd)
|
||||
# Workspace visibility: lets the TUI answer "can the backend actually
|
||||
# see this directory?" (mounted vs bridge-only) without probing.
|
||||
from src.workspace_paths import backend_workspace_path, workspace_mount_pairs
|
||||
|
||||
_raw_cwd = str(cwd or getattr(session, "cwd", "") or "").strip()
|
||||
_backend_cwd = backend_workspace_path(_raw_cwd)[:400] if _raw_cwd else ""
|
||||
# Server-side tool policy: non-admin owners silently lose the computer
|
||||
# tools (src/tool_security); surface that so the TUI can show it.
|
||||
try:
|
||||
from src.tool_security import blocked_tools_for_owner
|
||||
|
||||
_blocked = blocked_tools_for_owner(owner)
|
||||
except Exception:
|
||||
_blocked = set()
|
||||
_computer = {"bash", "python", "read_file", "write_file", "host_shell"}
|
||||
_policy = {
|
||||
"computer_tools": "restricted" if _computer & _blocked else "full",
|
||||
"reason": "non-admin owner" if _blocked else "single-user or admin",
|
||||
}
|
||||
_workspace = {
|
||||
"backend_path": _backend_cwd,
|
||||
"exists_in_backend": bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
|
||||
"mount_configured": bool(workspace_mount_pairs()),
|
||||
"via_mount": bool(_raw_cwd) and backend_workspace_path(_raw_cwd) != _raw_cwd,
|
||||
}
|
||||
if not session.endpoint_url or not session.model:
|
||||
return {"context_length": None}
|
||||
return {
|
||||
"context_length": None,
|
||||
"skills": skills,
|
||||
"tools": tools,
|
||||
"agents_md": agents_md,
|
||||
"workspace": _workspace,
|
||||
"tool_policy": _policy,
|
||||
}
|
||||
try:
|
||||
from src.model_context import get_context_length
|
||||
ctx = get_context_length(session.endpoint_url, session.model)
|
||||
return {"context_length": ctx, "model": session.model}
|
||||
return {
|
||||
"context_length": ctx,
|
||||
"model": session.model,
|
||||
"skills": skills,
|
||||
"tools": tools,
|
||||
"agents_md": agents_md,
|
||||
"workspace": _workspace,
|
||||
"tool_policy": _policy,
|
||||
}
|
||||
except Exception:
|
||||
return {"context_length": None}
|
||||
return {
|
||||
"context_length": None,
|
||||
"skills": skills,
|
||||
"tools": tools,
|
||||
"agents_md": agents_md,
|
||||
"workspace": _workspace,
|
||||
"tool_policy": _policy,
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
Reference in New Issue
Block a user