feat(security): apply audited PR #6738 remediations and test hardening

- Enforce vision probe SSRF safe local host validation, SafeProbeBackend TOCTOU elimination, and session owner authorization
- Authorize session titling endpoints against registered owner configuration
- Sanitize sensitive metadata logging from agent loop debug logs
- Add async non-blocking execution for spec router tools and thinking decisions
- Harden email cache invalidation fencing and thread-safe mutation
- Add regression test suites for legacy browser authority, spec contracts, spec router request scoping, and task MCP delivery
This commit is contained in:
Alexandre Teixeira
2026-10-27 17:25:25 +00:00
parent 3246ae5d9c
commit 0133de6f0e
43 changed files with 2187 additions and 673 deletions
+79 -21
View File
@@ -583,15 +583,25 @@ async def auto_name_session(session_manager, sess):
except Exception:
_is_spec_model = False
if _is_spec_model and endpoint_url:
_headers = getattr(sess, "headers", None)
if isinstance(_headers, str):
try:
_headers = json.loads(_headers)
except Exception:
_headers = None
try:
from src.database import SessionLocal
from src.endpoint_resolver import (build_chat_url, build_headers,
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
from src.owner_identity import auth_disabled
owner = getattr(sess, "owner", None)
if not owner and not auth_disabled():
raise ValueError("Session has no owner")
db = SessionLocal()
try:
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
title_url = build_chat_url(base)
title_headers = build_headers(key, base)
finally:
db.close()
title = await llm_call_async(
endpoint_url,
title_url,
model_name,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
@@ -599,7 +609,7 @@ async def auto_name_session(session_manager, sess):
],
temperature=0.3,
max_tokens=32,
headers=_headers or None,
headers=title_headers,
timeout=20,
thinking_mode="off",
)
@@ -625,6 +635,14 @@ async def auto_name_session(session_manager, sess):
return
owner = getattr(sess, "owner", None)
from src.owner_identity import auth_disabled
if not owner and not auth_disabled():
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
return
from src.task_endpoint import resolve_task_endpoint
t_url, t_model, t_headers = resolve_task_endpoint(
sess.endpoint_url, sess.model, sess.headers, owner=owner
)
@@ -632,24 +650,64 @@ async def auto_name_session(session_manager, sess):
logger.debug("[auto-name] No model provided, skipping")
return
sess_url = getattr(sess, "endpoint_url", None)
if t_url and sess_url and t_url == sess_url:
from src.database import SessionLocal
from src.endpoint_resolver import (
build_chat_url,
build_headers,
resolve_endpoint,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
db = SessionLocal()
try:
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
t_url = build_chat_url(base)
t_headers = build_headers(key, base)
except Exception as e:
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
t_url, t_model, t_headers = None, None, None
for prefix in ("utility", "default"):
try:
u, m, h = resolve_endpoint(prefix, owner=owner)
if u and m:
t_url, t_model, t_headers = u, m, h
break
except Exception:
pass
finally:
db.close()
if not t_url or not t_model:
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
return
# max_tokens big enough that reasoning models (Minimax M2,
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
# plus the actual title — 200 used to clip them mid-reasoning
# so strip_think left an empty string and no rename happened.
# Timeout matches: 60s gives slow local reasoners room to finish.
title = await llm_call_async(
t_url,
t_model,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
)
try:
title = await llm_call_async(
t_url,
t_model,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
)
except Exception as e:
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
title = ""
title = title.strip().strip('"\'').strip()
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
+6 -1
View File
@@ -331,6 +331,7 @@ def _is_external_discovery_request(text: str) -> bool:
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
def _mentions_pdf_url(value: str) -> bool:
@@ -2693,11 +2694,15 @@ def setup_chat_routes(
# Scoped photo_editor agent context: the Quick Edit popup (and any
# image-edit chat turn) may carry the open image-editor session id and a
# canvas snapshot so the editor tool family can drive it in-process.
editor_session = (
# The id is quoted into a system message, so accept only an
# identifier; any other value is treated as no open editor.
editor_session = str(
form_data.get("editor_session")
or form_data.get("editor_session_id")
or ""
).strip()
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
editor_session = ""
photo_editor_requested = (
str(form_data.get("photo_editor") or "").strip().lower()
in {"1", "true", "yes", "on"}
+73 -67
View File
@@ -1836,6 +1836,9 @@ def setup_email_routes():
return v[1]
_LIST_STALE = {}
_LIST_CACHE_LOCK = _threading.RLock()
# Invalidation fences reads already in flight, including background refresh.
_LIST_CACHE_GENERATION = [0]
def _list_stale_get(key):
return _LIST_STALE.get(key)
@@ -1849,13 +1852,12 @@ def setup_email_routes():
_LIST_REFRESHING = set()
def _list_cache_put(key, value):
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
# Also snapshot the last-good response for stale-while-revalidate.
_list_stale_put(key, value)
# Cap size
if len(_LIST_CACHE) > 64:
for k in list(_LIST_CACHE.keys())[:-32]:
_LIST_CACHE.pop(k, None)
with _LIST_CACHE_LOCK:
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
_list_stale_put(key, value)
if len(_LIST_CACHE) > 64:
for k in list(_LIST_CACHE.keys())[:-32]:
_LIST_CACHE.pop(k, None)
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
"""Refresh a list snapshot off the event loop, at most once per key.
@@ -1863,15 +1865,20 @@ def setup_email_routes():
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
the TTL cache and the stale snapshot so the next list view is warm.
"""
if cache_key in _LIST_REFRESHING:
return
_LIST_REFRESHING.add(cache_key)
with _LIST_CACHE_LOCK:
if cache_key in _LIST_REFRESHING:
return
_LIST_REFRESHING.add(cache_key)
generation = _LIST_CACHE_GENERATION[0]
def _run():
try:
refreshed = _list_emails_sync(*sync_args)
if refreshed and not refreshed.get("error"):
_list_cache_put(cache_key, refreshed)
with _LIST_CACHE_LOCK:
if generation != _LIST_CACHE_GENERATION[0]:
return
_list_cache_put(cache_key, refreshed)
try:
_schedule_recent_email_warm(
refreshed.get("emails") or [], folder, account_id, owner)
@@ -1880,7 +1887,8 @@ def setup_email_routes():
except Exception as exc: # pragma: no cover - background best effort
logger.debug("email list background refresh failed: %s", exc)
finally:
_LIST_REFRESHING.discard(cache_key)
with _LIST_CACHE_LOCK:
_LIST_REFRESHING.discard(cache_key)
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
@@ -1917,37 +1925,25 @@ def setup_email_routes():
hook(account_id, folder)
except Exception:
pass
if account_id is None and folder is None:
_LIST_CACHE.clear()
return
for k in list(_LIST_CACHE.keys()):
k_acct = k[0] if len(k) > 0 else ""
k_folder = k[1] if len(k) > 1 else ""
if (account_id is None or k_acct == (account_id or "")) and \
(folder is None or k_folder == folder):
_LIST_CACHE.pop(k, None)
with _LIST_CACHE_LOCK:
_LIST_CACHE_GENERATION[0] += 1
for cache in (_LIST_CACHE, _LIST_STALE):
for k in list(cache):
k_acct = k[0] if len(k) > 0 else ""
k_folder = k[1] if len(k) > 1 else ""
if (account_id is None or k_acct == (account_id or "")) and \
(folder is None or k_folder == folder):
cache.pop(k, None)
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
uid_s = str(uid)
for key, (expires_at, value) in list(_LIST_CACHE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
emails = list((value or {}).get("emails") or [])
changed = False
if seen and key[2] == "unread":
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
if len(kept) != len(emails):
value = dict(value)
value["emails"] = kept
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
changed = True
else:
for e in emails:
if str((e or {}).get("uid") or "") == uid_s:
e["is_read"] = bool(seen)
changed = True
if changed:
_LIST_CACHE[key] = (expires_at, value)
def _index_update_flags(owner, account_id, folder, uid, flag, add):
with _LIST_CACHE_LOCK:
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
_invalidate_list_cache(account_id, folder)
def _index_delete(owner, account_id, folder, uid):
with _LIST_CACHE_LOCK:
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
def _read_cache_get(key):
v = _READ_CACHE.get(key)
@@ -2327,6 +2323,8 @@ def setup_email_routes():
SECURITY: `owner` is propagated so when `account_id` is missing,
the fallback config lookup is scoped to this user's accounts only.
"""
with _LIST_CACHE_LOCK:
index_generation = _LIST_CACHE_GENERATION[0]
conn = None
conn_ok = False
try:
@@ -2621,7 +2619,9 @@ def setup_email_routes():
if parsed:
fetched_emails.append(parsed)
cached_rows[parsed["uid"]] = parsed
_email_index_upsert(owner, account_id, folder, fetched_emails)
with _LIST_CACHE_LOCK:
if index_generation == _LIST_CACHE_GENERATION[0]:
_email_index_upsert(owner, account_id, folder, fetched_emails)
for uid_num in uid_order:
row = cached_rows.get(uid_num)
if not row:
@@ -2915,7 +2915,7 @@ def setup_email_routes():
# with a `stale` marker and refresh in the background so the next view
# is warm. Blocking IMAP only remains for a genuine cold start with no
# snapshot and no indexed rows (e.g. a brand-new account/folder).
if not from_addr and not has_attachments and not (date_from or date_to):
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
snapshot = _list_stale_get(ck)
if snapshot is None:
try:
@@ -2950,6 +2950,8 @@ def setup_email_routes():
ck, folder, account_id, owner,
)
return payload
with _LIST_CACHE_LOCK:
cache_generation = _LIST_CACHE_GENERATION[0]
result = await _asyncio.to_thread(
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
@@ -2958,7 +2960,9 @@ def setup_email_routes():
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
_list_cache_put(ck, result)
with _LIST_CACHE_LOCK:
if cache_generation == _LIST_CACHE_GENERATION[0]:
_list_cache_put(ck, result)
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
if elapsed_ms > 1500:
logger.warning(
@@ -3306,7 +3310,7 @@ def setup_email_routes():
conn.select(_q(folder))
moved = _move_email_message(conn, uid, "Junk", role="junk")
if moved:
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception:
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
@@ -3323,7 +3327,7 @@ def setup_email_routes():
if deleted:
conn.expunge()
if deleted:
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception as exc:
delete_error = "Email was unsubscribed but could not be moved to Trash"
@@ -3405,7 +3409,7 @@ def setup_email_routes():
if moved:
changed += 1
cleaned_uids.append(uid)
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
else:
failed += 1
except Exception:
@@ -3494,6 +3498,8 @@ def setup_email_routes():
folder (Gmail does), we transparently swap to All Mail so the
search surfaces archived / labelled emails too. Plain IMAP
accounts fall back to whatever folder the caller specified."""
with _LIST_CACHE_LOCK:
index_generation = _LIST_CACHE_GENERATION[0]
if not q or len(q) < 2:
return {"emails": [], "total": 0, "query": q}
# CRLF in q would terminate the IMAP command early — reject defensively.
@@ -3637,7 +3643,9 @@ def setup_email_routes():
except Exception as e:
logger.warning(f"Error parsing search result {uid}: {e}")
continue
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
with _LIST_CACHE_LOCK:
if index_generation == _LIST_CACHE_GENERATION[0]:
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
return {
"emails": emails,
@@ -3774,8 +3782,7 @@ def setup_email_routes():
# accepted it, so the index and list cache cannot drift ahead of
# the mailbox.
if mark_seen and not mark_seen_failed:
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
related_attachments = []
if full and not _has_visible_attachments(msg):
@@ -3914,8 +3921,7 @@ def setup_email_routes():
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
if status != "OK":
return False
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
return True
except Exception as e:
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
@@ -5147,7 +5153,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Seen", add=False):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5167,7 +5173,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
_invalidate_list_cache(account_id, folder)
return {"success": True, "flagged": bool(on)}
except Exception as e:
@@ -5185,7 +5191,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Seen", add=True):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5205,7 +5211,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _move_email_message(conn, uid, "Archive", role="archive"):
return {"success": False, "error": "Email not found"}
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5236,7 +5242,7 @@ def setup_email_routes():
# click or by another mailbox client; it is already gone
# from the requested folder, so do not trap the UI on a
# permanent “Email not found” error.
_email_index_delete(owner, account_id, folder, str(uid))
_index_delete(owner, account_id, folder, str(uid))
_invalidate_list_cache(account_id, folder)
return {"success": True, "already_deleted": True}
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
@@ -5254,9 +5260,9 @@ def setup_email_routes():
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
return {"success": False, "error": "Could not move email to Trash"}
_email_index_delete(owner, account_id, folder, resolved_uid)
_index_delete(owner, account_id, folder, resolved_uid)
if resolved_uid != str(uid):
_email_index_delete(owner, account_id, folder, str(uid))
_index_delete(owner, account_id, folder, str(uid))
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5272,7 +5278,7 @@ def setup_email_routes():
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
return {"success": False, "error": "Email not found"}
conn.expunge()
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5340,7 +5346,7 @@ def setup_email_routes():
pending_expunge.append(uid)
continue
deleted_uids.append(uid)
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
except Exception:
failed_uids.append(uid)
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
@@ -5362,7 +5368,7 @@ def setup_email_routes():
if expunge_ok:
for uid in pending_expunge:
deleted_uids.append(uid)
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
else:
# Prioritize preservation over cleanup: unflag \Deleted from our
# copied messages so future EXPUNGE operations do not destroy them.
@@ -5481,7 +5487,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _move_email_message(conn, uid, dest):
return {"success": False, "error": f"Failed to move to {dest}"}
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5567,7 +5573,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Answered", add=True):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
_clear_done_response_tags(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
return {"success": True}
@@ -5583,7 +5589,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Answered", add=False):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -6250,7 +6256,7 @@ def setup_email_routes():
try:
st, _sel = imap.select(_q(_source_folder), readonly=False)
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
_invalidate_list_cache(_account_id, _source_folder)
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
+2 -7
View File
@@ -290,14 +290,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
# (most sessions have none), not an error. Return HTTP 200 with an
# explicit empty status so loading a session does not log a spurious
# 404; every client treats a non-"running" status as idle anyway.
# A session whose research exists but belongs to ANOTHER user stays a
# 404, so we do not leak the existence of someone else's research.
# Research owned by ANOTHER user answers identically, so the response
# does not reveal whether that research exists.
if not _owns_in_memory(session_id, user):
exists = session_id in getattr(research_handler, "_active_tasks", {})
if not exists:
exists = _find_research_path(session_id) is not None
if exists:
raise HTTPException(404, "No research found for this session")
return {"status": "none"}
status = research_handler.get_status(session_id)
if status is None:
-2
View File
@@ -951,14 +951,12 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
+18 -3
View File
@@ -628,6 +628,14 @@ async def extract_and_store(
if not fact_text or len(fact_text) < 5:
continue
# A fuller owner-specific name must not rewrite a shared legacy
# entry, but that less-specific entry must not suppress it either.
legacy_refinements = {
id(entry) for entry in existing
if _owner and entry.get("owner") is None
and _name_enrichment_target(fact_text, [entry]) is entry
}
# Dedup: check vector similarity first (fast), then exact text match.
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
# remote endpoint down) must not abort the whole batch — fall through
@@ -649,18 +657,25 @@ async def extract_and_store(
# silently dropped. Mirror the owner predicate used by the
# text dedup below; cross-tenant/stale matches fall through.
_match = next((e for e in existing if e.get("id") == existing_id), None)
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
if (_match is not None and id(_match) not in legacy_refinements
and (_match.get("owner") == _owner or _match.get("owner") is None)):
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
continue
# Text dedup fallback: exact match + fuzzy similarity
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
user_existing = [e for e in existing
if (e.get("owner") == _owner or e.get("owner") is None)
and id(e) not in legacy_refinements] if _owner else existing
# Name enrichment: if an existing name fact is a strict prefix of
# this more complete one, upgrade it in place instead of adding a
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
_enrich = _name_enrichment_target(fact_text, user_existing)
# Legacy unowned entries may be visible to multiple owners; never
# rewrite one with a private fact from the current owner.
_enrich = _name_enrichment_target(
fact_text, [e for e in user_existing if e.get("owner") == _owner]
)
if _enrich is not None:
_enrich["text"] = fact_text
_enrich["timestamp"] = int(time.time())
+154 -139
View File
@@ -21061,6 +21061,103 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_author
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
disabled = set(disabled_tools or ())
from src.spec_router import latest_decision
_spec_decision = latest_decision() or {}
_spec_choice = _spec_decision.get("choice")
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
and decided and _spec_choice == "answer_directly")
try:
_mgr = get_mcp_manager()
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
except Exception:
mcp_schemas = []
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
if _spec_choice == "ask_user":
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
# can act when the detail is there or ask when it is not.
_top = _spec_decision.get("top") or []
if _top:
picks = set(picks) | {_top[0][0]}
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
base_offered = set(turn_contract.offered)
# The router sees the full catalog, while this request's policy may
# deny a tool (for example, a browser turn with Web access switched
# off). Preserve that conflict as an unavailable contract. Keeping the
# old empty menu instead lets the model invent a tool result in prose.
_top = _spec_decision.get("top") or []
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
if _primary and f"mcp__email__{_primary}" in mcp_names:
_primary = f"mcp__email__{_primary}"
# A policy denial is surfaced even if the router chose outside the
# base inventory; it never grants access to that tool.
def _denied(name):
return (name in disabled
or (tool_policy is not None and (
tool_policy.blocks(name)
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
_unavailable_pick = ""
if _primary in picks and _denied(_primary):
_unavailable_pick = _primary
elif picks and all(_denied(name) for name in picks):
_unavailable_pick = sorted(picks)[0]
if _unavailable_pick:
blocked = replace(
turn_contract, required=frozenset(), offered=frozenset(),
unavailable=frozenset({_unavailable_pick}), schema_json=(),
required_read_operation=None, routing_experiment="spec-policy-denied",
)
_spec_active_contract.set(blocked)
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
return blocked
want = set(picks) - disabled
# Always-on basics only when the base turn offers them, so the Spec
# arm never offers a tool the base arm would not.
if not _spec_direct:
want |= {"ask_user", "update_plan"} & base_offered
if not (strict and decided):
want |= base_offered
want &= base_offered
if not want:
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
sorted(picks), sorted(base_offered))
return turn_contract
offer = want & base_offered & set(turn_contract.executable)
# A router may narrow the menu only when every existing obligation
# remains satisfiable. It cannot redefine the user's requested work.
required_read = getattr(turn_contract, "required_read_operation", None)
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
or (required_read is not None and required_read.tool not in offer)):
return turn_contract
by_name = {}
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
nm = (sc.get("function") or {}).get("name")
if nm in offer and nm not in by_name:
by_name[nm] = sc
for sc in turn_contract.schemas():
nm = (sc.get("function") or {}).get("name")
if nm in offer:
by_name[nm] = sc
if not by_name and not _spec_direct:
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
return turn_contract
# Required tools and the required read carry over unchanged: harness
# completion logic keys on them (e.g. a calendar list is a read, not an
# effect that may end with "Done."). If one lost its schema, contract
# validation fails and the original contract is retained below.
new = replace(
turn_contract, offered=frozenset(by_name),
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
routing_experiment="spec-router")
_spec_active_contract.set(new)
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
sorted(by_name), sorted(turn_contract.offered), decided)
return new
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=None):
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
@@ -21069,10 +21166,17 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
Runs before any runtime dispatch so the compact preview runtime and the
main loop both serve the router's set; only the offered tools change.
"""
# Contexts can be inherited by a child task; a turn must never inherit a
# previous turn's decision when routing is disabled or unavailable.
from src import spec_router as _router_context
if hasattr(_router_context, "clear_decision"):
_router_context.clear_decision()
if (turn_contract is None
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
return turn_contract
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
return turn_contract
try:
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
return turn_contract
@@ -21080,105 +21184,38 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
return turn_contract
try:
from src.spec_router import spec_route_tools as _spec_route_tools_early
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
disabled = set(disabled_tools or ())
picks, decided = _spec_route_tools_early(
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
from src.spec_router import LAST_DECISIONS as _spec_decisions
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
and decided and _spec_choice == "answer_directly")
try:
_mgr = get_mcp_manager()
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
except Exception:
mcp_schemas = []
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
if _spec_choice == "ask_user":
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
# can act when the detail is there or ask when it is not.
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
if _top:
picks = set(picks) | {_top[0][0]}
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
base_offered = set(turn_contract.offered)
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
# The router sees the full catalog, while this request's policy may
# deny a tool (for example, a browser turn with Web access switched
# off). Preserve that conflict as an unavailable contract. Keeping the
# old empty menu instead lets the model invent a tool result in prose.
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
if _primary and f"mcp__email__{_primary}" in mcp_names:
_primary = f"mcp__email__{_primary}"
# `executable` belongs to the base contract. In subset=0, Spec may
# legitimately add a policy-permitted tool that the base inventory did
# not include. Only an explicit request-policy denial blocks the pick.
def _denied(name):
return (name in disabled
or (tool_policy is not None and (
tool_policy.blocks(name)
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
_unavailable_pick = ""
if _primary in picks and _denied(_primary):
_unavailable_pick = _primary
elif picks and all(_denied(name) for name in picks):
_unavailable_pick = sorted(picks)[0]
if _unavailable_pick:
blocked = replace(
turn_contract, required=frozenset(), offered=frozenset(),
unavailable=frozenset({_unavailable_pick}), schema_json=(),
required_read_operation=None, routing_experiment="spec-policy-denied",
)
_spec_active_contract.set(blocked)
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
return blocked
want = set(picks) - disabled
# Always-on basics only when the base turn offers them, so the Spec
# arm never offers a tool the base arm would not.
if not _spec_direct:
want |= {"ask_user", "update_plan"} & base_offered
if not (strict and decided):
want |= base_offered
if subset_of_base:
# A/B control: Spec offered set is a subset of the base offered set
# (measures narrowing only). Empty intersection keeps the base set.
want &= base_offered
if not want:
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
sorted(picks), sorted(base_offered))
return turn_contract
offer = want if strict else (want & set(turn_contract.executable))
by_name = {}
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
nm = (sc.get("function") or {}).get("name")
if nm in offer and nm not in by_name:
by_name[nm] = sc
for sc in turn_contract.schemas():
nm = (sc.get("function") or {}).get("name")
if nm in offer:
by_name[nm] = sc
if not by_name and not _spec_direct:
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
return turn_contract
# Keep the contract's required tools and required read whenever they
# are still offered: harness completion logic keys on them (e.g. a
# calendar list is a read, not an effect that may end with "Done.").
_rro = getattr(turn_contract, "required_read_operation", None)
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
new = replace(
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
offered=frozenset(by_name),
executable=frozenset(set(turn_contract.executable) | set(by_name)),
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
_spec_active_contract.set(new)
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
sorted(by_name), sorted(turn_contract.offered), decided)
return new
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
except Exception as exc:
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
return turn_contract
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=None):
"""Asynchronous non-blocking turn contract routing."""
from src import spec_router as _router_context
if hasattr(_router_context, "clear_decision"):
_router_context.clear_decision()
if (turn_contract is None
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
return turn_contract
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
return turn_contract
try:
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
return turn_contract
except Exception:
return turn_contract
try:
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
picks, decided = await _spec_route_tools_early_async(
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
except Exception as exc:
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
return turn_contract
@@ -21263,20 +21300,11 @@ async def stream_agent_loop(
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
and not (tool_policy and tool_policy.block_all_tool_calls)
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
from src.spec_driver import stream_spec_turn
async for chunk in stream_spec_turn(
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
client_runtime_context=client_runtime_context,
external_untrusted_context_seen=external_untrusted_context_seen,
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
temperature=0.0, request_authority=request_authority,
):
yield chunk
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
yield 'data: [DONE]\n\n'
return
turn_contract = _spec_router_contract(
turn_contract = await _spec_router_contract_async(
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=tool_policy)
if (turn_contract is not None
@@ -21289,6 +21317,20 @@ async def stream_agent_loop(
yield 'data: [DONE]\n\n'
return
try:
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
except Exception:
_spec_turn = False
# Without a turn contract (scheduled, skill and monitor runs) no Spec
# routing applies; the turn runs on the compact surface as usual.
if _spec_turn and _blocks_before_inference(turn_contract):
unavailable = ", ".join(sorted(turn_contract.unavailable))
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
yield 'data: [DONE]\n\n'
return
if is_compact_preview_contract(turn_contract):
from src.clean_agent_preview import stream_preview
async for chunk in stream_preview(
@@ -21314,26 +21356,9 @@ async def stream_agent_loop(
yield chunk
return
_spec_turn = False
try:
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
except Exception:
_spec_turn = False
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
# Ajax Spec: the router, not the regex turn contract, decides the tools.
# Drop the contract here and in the dispatcher's context so neither the
# route build nor execute_tool_block enforces its offered set.
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
turn_contract = None
try:
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
_spec_active_contract.set(None)
except Exception as _exc:
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
if turn_contract is not None:
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
if not _spec_turn and _blocks_before_inference(turn_contract):
if _blocks_before_inference(turn_contract):
unavailable = ", ".join(sorted(turn_contract.unavailable))
clarification = (
"I can’t perform this request with the currently permitted tools "
@@ -24336,7 +24361,7 @@ async def stream_agent_loop(
# "spec" = compact tools plus a small-model router; the harness-facing
# surface already reads "compact", so detect spec from the raw mode.
try:
_spec_surface = _configured_model_tool_surface_raw(
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
candidate_url, candidate_model, owner, headers=candidate_headers,
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
except Exception:
@@ -24410,7 +24435,7 @@ async def stream_agent_loop(
if _spec_mode == "strict" and _spec_decided:
# The router decides which tools are injected: its pick(s),
# tools already used in this chat, and the always-on basics.
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
if _spec_strict:
route_tools = _spec_strict
_spec_offered = frozenset(route_tools)
@@ -25361,15 +25386,6 @@ async def stream_agent_loop(
if (schema.get("function", {}).get("name") or schema.get("name"))
in _spec_names
]
_have = {
(schema.get("function", {}).get("name") or schema.get("name"))
for schema in contract_schemas
}
contract_schemas += [
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
and schema.get("function", {}).get("name") not in disabled_tools
]
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
if route_state["is_api_model"]:
if tool_surface == "full":
@@ -26321,7 +26337,7 @@ async def stream_agent_loop(
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
if round_num == 1:
try:
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
except Exception:
pass
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
@@ -33925,7 +33941,6 @@ async def stream_agent_loop(
"mutation_epoch": _workspace_mutation_epoch,
"error": _failure_text[:600],
}
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
logger.info(
"[agent] recorded failed call %s in round %s at mutation epoch %s",
_call_signature,
+5
View File
@@ -211,10 +211,15 @@ class ChatHandler:
from src.settings import get_setting
vision_enabled = get_setting("vision_enabled", True)
if vision_enabled:
from src.owner_identity import auth_disabled
sess_owner = getattr(sess, "owner", None)
if not sess_owner and not auth_disabled():
sess_owner = ""
main_is_vision = await asyncio.to_thread(
model_supports_vision,
sess.model or "",
getattr(sess, "endpoint_url", "") or "",
sess_owner,
)
if effective_att_ids and vision_enabled:
+243 -24
View File
@@ -5,9 +5,14 @@ import re
import os
import json
import time
import socket
import ipaddress
import logging
import httpx
import typing
import httpcore
from httpcore._backends.sync import SyncBackend, SyncStream
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
from urllib.parse import urlparse
from fastapi import HTTPException
from fastapi import UploadFile
@@ -17,6 +22,9 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
logger = logging.getLogger(__name__)
_ORIGINAL_HTTPX_GET = httpx.get
_ORIGINAL_HTTPX_POST = httpx.post
def extract_urls(text: str) -> List[str]:
"""Extract URLs from text using regex pattern."""
@@ -101,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
_lmstudio_models_cache: dict = {}
def _is_local_host(host: Optional[str]) -> bool:
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
host = (host or "").lower()
_BLOCKED_METADATA_IPS = {
ipaddress.ip_address("169.254.169.254"),
ipaddress.ip_address("169.254.169.253"),
ipaddress.ip_address("100.100.100.200"),
}
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
if ip in _BLOCKED_METADATA_IPS:
return False
if ip.is_link_local:
return False
# Explicit loopback check before reserved rejection:
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
if ip.is_loopback:
return True
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
return False
if ip.is_private:
return True
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
return True
return False
def _is_safe_local_host(host: Optional[str]) -> bool:
"""True for loopback/LAN/Tailscale hosts.
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
unresolved single-label names, and public domains.
"""
host = (host or "").strip().lower()
if not host:
return False
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
return True
# Strip optional IPv6 square brackets: [::1] -> ::1
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
try:
ip = ipaddress.ip_address(host)
ip = ipaddress.ip_address(literal)
return _is_ip_safe_local(ip)
except ValueError:
return "." not in host
if ip.is_loopback or ip.is_private or ip.is_link_local:
return True
return ip in ipaddress.ip_network("100.64.0.0/10")
pass
allowed_names = {"localhost", "host.docker.internal"}
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
return False
try:
infos = socket.getaddrinfo(host, None)
if not infos:
return False
resolved_ips = []
for info in infos:
sockaddr = info[4]
ip_str = str(sockaddr[0]).split("%")[0]
resolved_ips.append(ipaddress.ip_address(ip_str))
if not resolved_ips:
return False
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
except Exception:
return False
def _probe_lmstudio_models(url: str) -> Optional[list]:
_is_local_host = _is_safe_local_host
class SafeProbeBackend(SyncBackend):
"""Enforces SSRF address policies at TCP connection time.
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
"""
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.NetworkStream:
source_address = None if local_address is None else (local_address, 0)
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
with map_exceptions(exc_map):
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
if not infos:
raise ConnectError(f"Host {host} did not resolve")
for family, socktype, proto, _canon, sockaddr in infos:
ip_str = str(sockaddr[0]).split("%")[0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
raise ConnectError(f"Invalid resolved IP: {ip_str}")
if not _is_ip_safe_local(ip):
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
last_err: Optional[OSError] = None
for family, socktype, proto, _canon, sockaddr in infos:
sock = socket.socket(family, socktype, proto)
try:
if timeout is not None:
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
if socket_options:
for opt in socket_options:
sock.setsockopt(*opt)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
return SyncStream(sock)
except OSError as e:
last_err = e
sock.close()
if last_err is not None:
raise last_err
raise ConnectError(f"Could not connect to {host}:{port}")
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
if httpx.get is not _ORIGINAL_HTTPX_GET:
try:
if headers:
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
return httpx.get(url, timeout=timeout, follow_redirects=False)
except TypeError:
if headers:
return httpx.get(url, headers=headers, timeout=timeout)
return httpx.get(url, timeout=timeout)
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
transport = httpx.HTTPTransport(trust_env=False)
transport._pool = pool
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
return client.get(url, headers=headers, timeout=timeout)
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
if httpx.post is not _ORIGINAL_HTTPX_POST:
try:
if headers:
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
except TypeError:
if headers:
return httpx.post(url, headers=headers, json=json, timeout=timeout)
return httpx.post(url, json=json, timeout=timeout)
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
transport = httpx.HTTPTransport(trust_env=False)
transport._pool = pool
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
return client.post(url, headers=headers, json=json, timeout=timeout)
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
parsed = urlparse(url)
@@ -127,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
cached = _lmstudio_models_cache.get(key)
if cached is not None and cached[1] > now:
return cached[0]
authority = host if parsed.port is None else f"{host}:{parsed.port}"
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
try:
r = httpx.get(probe_url, timeout=1.0)
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
except Exception:
return None
try:
@@ -148,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
return models
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
if not model:
return None
# Never probe a remote provider; LM Studio is always a local/LAN host.
if not _is_local_host(urlparse(url).hostname):
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
if not _is_safe_local_host(urlparse(url).hostname):
return None
models = _probe_lmstudio_models(url)
models = _probe_lmstudio_models(url, headers=headers)
if not models:
return None
want = model.strip().lower()
@@ -172,21 +324,79 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
return None
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
def model_supports_vision(
model_name: str,
endpoint_url: str = "",
owner: Optional[str] = None,
) -> bool:
"""Whether a model accepts images, using the endpoint's reported
capability when available (LM Studio), a cached one-time probe for local
OpenAI-compatible endpoints, and name-based detection otherwise."""
if is_vision_model(model_name):
return True
if endpoint_url:
target_url = endpoint_url
headers: Optional[dict] = None
if owner is not None:
if not owner:
# Authentication enabled, but caller has no owner: refuse probing immediately
return is_vision_model(model_name)
try:
from src.database import SessionLocal
from src.endpoint_resolver import (
normalize_base,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
with SessionLocal() as db:
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
resolved_base, api_key = resolve_endpoint_runtime(ep)
target_url = normalize_base(resolved_base or ep.base_url)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
except ValueError:
# Foreign-owned, disabled, or unregistered endpoint
return is_vision_model(model_name)
except Exception as e:
logger.warning("Endpoint authorization check failed: %s", e)
return is_vision_model(model_name)
else:
# Single-user / auth-disabled mode (owner is None):
try:
from src.database import SessionLocal
from src.endpoint_resolver import (
normalize_base,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
with SessionLocal() as db:
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
resolved_base, api_key = resolve_endpoint_runtime(ep)
target_url = normalize_base(resolved_base or ep.base_url)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
except Exception:
# In single-user mode, an unregistered endpoint is allowed
# only if target URL passes safe local host checks (probed with headers=None).
target_url = endpoint_url
headers = None
try:
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
if headers:
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
else:
advertised = lmstudio_supports_vision(target_url, model_name or "")
except TypeError:
advertised = lmstudio_supports_vision(target_url, model_name or "")
except Exception:
advertised = None
if advertised is not None:
return advertised
try:
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
if headers:
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
else:
probed = probe_endpoint_supports_vision(target_url, model_name or "")
except TypeError:
probed = probe_endpoint_supports_vision(target_url, model_name or "")
except Exception:
probed = None
if probed is not None:
@@ -215,7 +425,11 @@ def _chat_completions_url(endpoint_url: str) -> str:
return base + "/chat/completions"
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
def probe_endpoint_supports_vision(
endpoint_url: str,
model_name: str,
headers: Optional[dict] = None,
) -> Optional[bool]:
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
an image_url content part for `model_name`?
@@ -226,8 +440,8 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
if not endpoint_url or not model_name or not _vision_probe_enabled():
return None
parsed = urlparse(endpoint_url)
if not _is_local_host(parsed.hostname):
return None # never probe a public provider
if not _is_safe_local_host(parsed.hostname):
return None # never probe a public provider or disallowed host
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
now = time.time()
cached = _vision_probe_cache.get(key)
@@ -247,7 +461,12 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
}
verdict: Optional[bool] = None
try:
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
r = _safe_probe_post(
_chat_completions_url(endpoint_url),
headers=headers,
json=payload,
timeout=8.0,
)
if r.status_code == 200:
verdict = True
elif r.status_code in (400, 413, 415, 422):
+36 -8
View File
@@ -1466,6 +1466,29 @@ def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_us
return None
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
try:
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
if not spec_router.think_router_enabled():
return None
from src.agent_loop import _configured_model_tool_surface_raw
if _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers
) != 'spec':
return None
if hasattr(spec_router, 'route_thinking_async'):
return await spec_router.route_thinking_async(direct_user_text)
return spec_router.route_thinking(direct_user_text)
except Exception:
logging.getLogger(__name__).warning(
'spec think router decision failed; using rules only', exc_info=True)
return None
def spec_router_think_reason(round_number, decision, existing_deliberate):
"""Add 'router_think' on round 1 when the router asked for thinking and no
other rule already selected a deliberate reason."""
@@ -1508,10 +1531,7 @@ def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
if not decisions:
return None
decision = decisions[-1]
decision = spec_router.latest_decision()
if not isinstance(decision, dict):
return None
# Guard against a stale decision from a previous turn.
@@ -1573,8 +1593,7 @@ def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
set(disabled_tools or ()), list(conversation or []),
return_decision=True,
)
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
decision = decisions[-1] if decisions else {}
decision = spec_router.latest_decision() or {}
if not isinstance(decision, dict) or decision.get('error'):
return None
choice = decision.get('choice')
@@ -5600,6 +5619,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
external_tool_schemas=None, temperature=0.0,
context_resolution=None,
request_authority=MISSING_AUTHORITY, **ignored):
if spec_driver_active(endpoint_url, model, owner, headers):
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
yield 'data: [DONE]\n\n'
return
from src.generation_sampling import validate_temperature
temperature = validate_temperature(temperature)
# This path sends requests directly with httpx and therefore bypasses
@@ -6095,7 +6118,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
has_real_usage = intent_accounting.get('usage_source') == 'real'
# Optional env-gated router decision (spec surface only). Computed
# once per user turn; disabled/None leaves behaviour unchanged.
_spec_route_think = spec_router_thinking_decision(
_spec_route_think = await spec_router_thinking_decision_async(
endpoint_url, model, owner, headers, direct_user_text)
if _spec_route_think is not None:
logging.getLogger(__name__).info(
@@ -6108,12 +6131,17 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
# round 1 is sent with tool_choice 'none' (same tools list).
_spec_direct_p = spec_direct_no_tools_p(
direct_user_text, endpoint_url, model, owner, headers)
if (turn_contract.required or turn_contract.unavailable
or turn_contract.required_read_operation is not None):
_spec_direct_p = None
# Option B: send round 1 with no tools at all for a direct answer.
_spec_direct_empty_menu = (
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
# every round; the 9B only writes the arguments or the answer.
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
# Driver execution is refused at entry. Keep this false even if
# configuration changes while the stream is already in progress.
_spec_driver_on = False
# Driver mode narrows the contract per round to the Spec pick.
# Rebuild each round from this frozen base so the narrowing does
# not accumulate/mutate across rounds.
+4 -3
View File
@@ -92,7 +92,7 @@ def prune_multimodal_images(
return pruned
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
def _image_turn_label(message: Dict) -> str:
@@ -102,7 +102,8 @@ def _image_turn_label(message: Dict) -> str:
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
image_url = item.get("image_url")
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
if candidate:
name = str(candidate)
break
@@ -146,7 +147,7 @@ def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict
trimmed.append(message)
continue
kept_blocks = [
item for item in content
dict(item) if isinstance(item, dict) else item for item in content
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
]
if len(kept_blocks) == len(content):
+12 -11
View File
@@ -17,6 +17,7 @@ Env:
from __future__ import annotations
import logging
import math
import os
from typing import Any, Dict, Optional
@@ -98,18 +99,18 @@ def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
if not isinstance(answer, dict):
return None
if answer.get("type") == "score":
try:
return float(answer.get("score"))
except Exception:
try:
if answer.get("type") == "score":
value = float(answer.get("score"))
elif answer.get("type") == "choice":
probs = answer.get("probabilities") or {}
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
else:
return None
if answer.get("type") == "choice":
probs = answer.get("probabilities") or {}
try:
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
except Exception:
return None
return None
except Exception:
return None
# A malformed adapter reply must fall back like any other failure.
return value if math.isfinite(value) else None
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
-2
View File
@@ -78,8 +78,6 @@ def untrusted_context_message(
"""
safe_label = _sanitize_label(label)
text = "" if content is None else str(content)
import logging as _lg
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
text = _escape_guard_markers(text)
metadata: Dict[str, Any] = {
"trusted": False,
+5 -167
View File
@@ -1,13 +1,4 @@
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
Each round the router (spec_router.next_action) picks one action from the full tool surface:
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
the executor runs it; the result goes back to the router for the next pick.
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
ask_user -> the chat model writes the question through the ask_user tool.
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
authority and tool policy (execute_tool_block); nothing else is layered on top.
"""
"""Helpers for the experimental Spec driver, whose execution is disabled."""
from __future__ import annotations
@@ -110,161 +101,8 @@ async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id
client_runtime_context=None, external_untrusted_context_seen=False,
max_tokens=768, max_rounds=8, temperature=0.0,
request_authority=MISSING_AUTHORITY, **ignored):
from src.turn_contract import _ACTIVE_CONTRACT
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
started = time.monotonic()
surface = spec_surface(disabled_tools)
by_name = {_name(s): s for s in surface}
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
history = list(messages)
security = ToolRunSecurityContext(
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
security.observe_messages(messages)
picks, executions, done_calls = [], [], set()
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
recovery = {}
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
for round_number in range(1, max_rounds + 1):
rounds = round_number
action = await asyncio.to_thread(
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
if action is None:
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
if round_number == max_rounds and action['kind'] == 'tool':
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
picks.append({'round': round_number, **action})
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
'temperature': temperature, 'stream': True,
'stream_options': {'include_usage': True},
'chat_template_kwargs': {'enable_thinking': False}}
if action['kind'] == 'answer_directly':
answer_parts = []
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
async for line in response.aiter_lines():
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
continue
try:
chunk = json.loads(line[6:])
except ValueError:
continue
if chunk.get('error') or chunk.get('object') == 'error':
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
u = chunk.get('usage') or {}
usage_in += u.get('prompt_tokens') or 0
usage_out += u.get('completion_tokens') or 0
for choice in chunk.get('choices') or []:
text = (choice.get('delta') or {}).get('content') or ''
if text:
first_token = first_token or time.monotonic()
answer_parts.append(text)
yield event({'delta': text})
answer = ''.join(answer_parts).strip()
if not answer:
yield event({'delta': 'The model returned no answer.'})
history.append({'role': 'assistant', 'content': answer})
break
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
options = [name]
if action['kind'] == 'tool' and topk > 1:
options = [n for n, _ in action.get('top') or []][:topk] or [name]
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
for n in options]
options = [n for n, sc in zip(options, schemas) if sc is not None]
schemas = [sc for sc in schemas if sc is not None]
if not schemas:
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
latest = f'Tool {name} is not available.'
continue
request['tools'] = [grammar_safe(sc) for sc in schemas]
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
if len(options) == 1 else 'required')
if len(options) > 1:
logger.info('[spec-driver] round=%s options=%s', round_number, options)
try:
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
except (RuntimeError, httpx.HTTPError) as exc:
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
latest = f'The {name} call could not be produced: {exc}'
continue
first_token = first_token or time.monotonic()
usage_in += usage.get('prompt_tokens') or 0
usage_out += usage.get('completion_tokens') or 0
call = next((c for c in calls if c['name'] in options), None)
if call is not None and len(options) > 1:
name = call['name']
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
round_number, name, options.index(name) + 1, options)
picks[-1]['chosen'] = name
if call is None:
latest = f'The {name} call was not produced.'
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
continue
arguments = call['arguments'] or '{}'
signature = (name, arguments)
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
history.append({'role': 'assistant', 'content': content or '',
'tool_calls': [{'id': call_id, 'type': 'function',
'function': {'name': name, 'arguments': arguments}}]})
if signature in done_calls:
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
latest = output
continue
try:
args = json.loads(arguments)
except ValueError:
args = {}
block = function_call_to_tool_block(name, arguments)
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
'full_command': arguments, 'round': round_number})
tool_started = time.monotonic()
if block is None:
desc, result = f'{name}: invalid arguments', {
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
else:
desc, result = await execute_tool_block(
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
tool_policy=tool_policy, security_context=security,
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
client_runtime_context=client_runtime_context, request_authority=request_authority)
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
output = preview_tool_result_text(result, name, args)
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
'desc': desc, 'round': round_number,
'seconds': round(time.monotonic() - tool_started, 3)}
if result.get('image_url'):
yield event({'type': 'generated_image', 'url': result['image_url'],
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
if result.get('ui_event'):
yield event({'type': 'ui_control', 'data': result})
executions.append(tool_event)
yield event(tool_event)
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
if not failed:
done_calls.add(signature)
latest = output
ask = result.get('ask_user')
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
question = str(ask['question']).strip()
history.append({'role': 'assistant', 'content': question})
yield event({'type': 'final_response', 'content': question})
yield event({'type': 'ask_user', 'data': ask})
break
elapsed = time.monotonic() - started
yield event({'type': 'metrics', 'data': {
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
'time_to_first_token': round(first_token - started, 3) if first_token else None,
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
}})
# This experimental runtime lacks the normal turn and completion gates.
# Refuse direct callers too, before contacting a router or executor.
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
yield 'data: [DONE]\n\n'
return
+274 -9
View File
@@ -1,9 +1,9 @@
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
The router (a 0.8B decision model served by jeff-serve) reads the user's
message and scores every offered tool in one forward pass. The harness only
ever ADDS tools from its answer, never removes any, and fails open: on any
error, timeout or low-confidence answer the compact tool set is unchanged.
message and scores offered tools in one forward pass. The harness may narrow
the existing turn contract, but cannot add authority or drop its obligations.
On an error, timeout or low-confidence answer it retains the existing contract.
Env:
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
@@ -17,12 +17,17 @@ from __future__ import annotations
import json
import logging
import math
import os
import time
import asyncio
import urllib.request
import urllib.error
from contextvars import ContextVar
from typing import Any, Dict, Iterable, List, Optional, Set
import httpx
logger = logging.getLogger(__name__)
_HEAD = {
@@ -35,8 +40,16 @@ _INSTRUCTIONS = (
"If a tool is needed but information it requires is missing, choose ask the user."
)
_AGENT = "Odysseus, a personal tool-using assistant."
# Recent decisions, for debugging in the UI/logs: most recent last.
LAST_DECISIONS: List[Dict[str, Any]] = []
_LAST_DECISION: ContextVar[Optional[Dict[str, Any]]] = ContextVar('spec_router_decision', default=None)
def latest_decision() -> Optional[Dict[str, Any]]:
"""Return the router decision in the current request context."""
return _LAST_DECISION.get()
def clear_decision() -> None:
_LAST_DECISION.set(None)
_MCP_BROWSER_TOOLS = (
"mcp__builtin_browser__browser_navigate",
"mcp__builtin_browser__browser_snapshot",
@@ -105,6 +118,7 @@ def spec_route_tools(
the router answered confidently (a tool pick, or answer_directly)."""
added: Set[str] = set(prior_tool_names(messages))
decided = False
clear_decision()
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
if not url or not (user_message or "").strip():
return (added, False) if return_decision else added
@@ -143,6 +157,15 @@ def spec_route_tools(
time.sleep(0.1)
probs = out.get("probabilities") or {}
choice = out.get("choice")
if not isinstance(probs, dict) or choice not in criteria:
raise ValueError("invalid router choice")
for key, probability in probs.items():
if key not in criteria or isinstance(probability, bool):
raise ValueError("invalid router probability key")
number = float(probability)
if not math.isfinite(number) or not 0 <= number <= 1:
raise ValueError("invalid router probability")
probs = {key: float(value) for key, value in probs.items()}
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
ranked = sorted(
@@ -172,11 +195,133 @@ def spec_route_tools(
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
except Exception as exc: # fail open
added = set(prior_tool_names(messages))
decided = False
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
logger.warning("spec router failed open: %s", exc)
LAST_DECISIONS.append(decision)
del LAST_DECISIONS[:-50]
decision["decided"] = decided
_LAST_DECISION.set(decision)
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
return (added, decided) if return_decision else added
_ORIGINAL_SPEC_ROUTE_TOOLS = spec_route_tools
async def spec_route_tools_async(
user_message: str,
schemas: Iterable[Dict[str, Any]],
disabled_tools: Optional[Set[str]] = None,
messages: Optional[Iterable[Dict[str, Any]]] = None,
return_decision: bool = False,
):
"""Asynchronous non-blocking tool routing.
Preserves request-local ContextVar semantics, decision propagation,
cancellation behavior, timeout limits, and safe fallback on router failure.
"""
if spec_route_tools is not _ORIGINAL_SPEC_ROUTE_TOOLS:
return spec_route_tools(
user_message,
schemas,
disabled_tools=disabled_tools,
messages=messages,
return_decision=return_decision,
)
added: Set[str] = set(prior_tool_names(messages))
decided = False
clear_decision()
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
if not url or not (user_message or "").strip():
return (added, False) if return_decision else added
t0 = time.time()
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
try:
disabled = set(disabled_tools or ())
lines: Dict[str, str] = {}
for s in schemas:
n = str(_fn(s).get("name") or "")
if n and n not in disabled and n != "ask_user" and n not in lines:
lines[n] = tool_line(s)
names = sorted(lines)
criteria = dict(_HEAD)
for i, n in enumerate(names, 1):
criteria[f"t{i}"] = lines[n]
req = {
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
}
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
deadline = time.time() + timeout
async with httpx.AsyncClient(timeout=timeout) as client:
while True:
remaining = max(0.2, deadline - time.time())
try:
resp = await client.post(
url + "/v1/systemone",
json=req,
headers={"content-type": "application/json"},
timeout=remaining,
)
if resp.status_code == 529:
if time.time() + 0.15 >= deadline:
resp.raise_for_status()
decision["retries"] = decision.get("retries", 0) + 1
await asyncio.sleep(0.1)
continue
resp.raise_for_status()
out = resp.json()["answers"]["tool"]
break
except httpx.HTTPStatusError as busy:
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
raise
decision["retries"] = decision.get("retries", 0) + 1
await asyncio.sleep(0.1)
probs = out.get("probabilities") or {}
choice = out.get("choice")
if not isinstance(probs, dict) or choice not in criteria:
raise ValueError("invalid router choice")
for key, probability in probs.items():
if key not in criteria or isinstance(probability, bool):
raise ValueError("invalid router probability key")
number = float(probability)
if not math.isfinite(number) or not 0 <= number <= 1:
raise ValueError("invalid router probability")
probs = {key: float(value) for key, value in probs.items()}
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
ranked = sorted(
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
)
picks: List[str] = []
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
picks.append(names[int(choice[1:]) - 1])
for p, k in ranked[:3]:
nm = names[int(k[1:]) - 1]
if nm not in picks and p >= second_min:
picks.append(nm)
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
if _bmode != "0" and "private_browser" in picks:
picks = [n for n in picks if n != "private_browser"]
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
for n in extra + ["web_search", "web_fetch"]:
if n not in picks:
picks.append(n)
added.update(picks)
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
except Exception as exc: # fail open
added = set(prior_tool_names(messages))
decided = False
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
logger.warning("spec router failed open: %s", exc)
decision["decided"] = decided
_LAST_DECISION.set(decision)
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
return (added, decided) if return_decision else added
@@ -328,13 +473,59 @@ def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
if kind5:
out["kind"] = ans.get("choice")
logger.info("spec think router: %s msg=%r", out, text[:120])
logger.info("spec think router: %s", out)
return out
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
return None
_ORIGINAL_ROUTE_THINKING = route_thinking
async def route_thinking_async(user_message: str) -> Optional[Dict[str, Any]]:
"""Asynchronous non-blocking route_thinking."""
if route_thinking is not _ORIGINAL_ROUTE_THINKING:
return route_thinking(user_message)
if not think_router_enabled():
return None
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
text = (user_message or "").strip()
if not url or not text:
return None
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
req = {
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
"questions": {"thinking": question},
}
t0 = time.time()
try:
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url + "/v1/systemone",
json=req,
headers={"content-type": "application/json"},
timeout=timeout,
)
resp.raise_for_status()
ans = resp.json()["answers"]["thinking"]
probs = ans.get("probabilities") or {}
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
if kind5:
out["kind"] = ans.get("choice")
logger.info("spec think router: %s", out)
return out
except Exception as exc:
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
return None
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
messages: Optional[Iterable[Dict[str, Any]]] = None,
@@ -389,7 +580,81 @@ def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[st
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
logger.info("spec next action: %s", action)
return action
except Exception as exc:
logger.warning("spec next action failed: %s", exc)
return None
_ORIGINAL_NEXT_ACTION = next_action
async def next_action_async(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
messages: Optional[Iterable[Dict[str, Any]]] = None,
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
"""Asynchronous non-blocking next_action."""
if next_action is not _ORIGINAL_NEXT_ACTION:
return next_action(user_message, latest_result, schemas, messages=messages, disabled_tools=disabled_tools)
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
if not url:
return None
t0 = time.time()
disabled = set(disabled_tools or ())
lines: Dict[str, str] = {}
for s in schemas:
n = str(_fn(s).get("name") or "")
if n and n not in disabled and n != "ask_user" and n not in lines:
lines[n] = tool_line(s)
names = sorted(lines)
criteria = dict(_HEAD)
for i, n in enumerate(names, 1):
criteria[f"t{i}"] = lines[n]
um = (user_message or "")[-3000:]
if latest_result:
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
req = {
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
}
try:
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
deadline = time.time() + timeout
async with httpx.AsyncClient(timeout=timeout) as client:
while True:
remaining = max(0.2, deadline - time.time())
try:
resp = await client.post(
url + "/v1/systemone",
json=req,
headers={"content-type": "application/json"},
timeout=remaining,
)
if resp.status_code == 529:
if time.time() + 0.15 >= deadline:
resp.raise_for_status()
await asyncio.sleep(0.1)
continue
resp.raise_for_status()
out = resp.json()["answers"]["tool"]
break
except httpx.HTTPStatusError as busy:
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
raise
await asyncio.sleep(0.1)
probs = out.get("probabilities") or {}
choice = str(out.get("choice") or "")
if choice.startswith("t") and choice[1:].isdigit():
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
elif choice in ("answer_directly", "ask_user"):
action = {"kind": choice, "name": choice}
else:
return None
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
logger.info("spec next action: %s", action)
return action
except Exception as exc:
logger.warning("spec next action failed: %s", exc)
+10 -12
View File
@@ -47,25 +47,23 @@ def resolve_task_candidates(
candidates.append((url, model, headers or {}))
if override_url and override_model:
headers = override_headers or {}
try:
from src.database import ModelEndpoint, SessionLocal
from src.endpoint_resolver import normalize_base, resolve_endpoint_runtime, build_headers
from src.database import SessionLocal
from src.endpoint_resolver import (
build_chat_url, build_headers, resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
db = SessionLocal()
try:
from src.auth_helpers import owner_filter
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
for ep in owner_filter(query, ModelEndpoint, owner).all():
base = normalize_base(getattr(ep, "base_url", "") or "")
if _same_endpoint_base(override_url, base):
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
headers = build_headers(api_key, runtime_base or base)
break
ep = resolve_owner_registered_endpoint(db, override_url, owner)
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
_append(build_chat_url(runtime_base), override_model, build_headers(api_key, runtime_base))
finally:
db.close()
except Exception:
# A missing, foreign, disabled or unreadable registration is not
# authority to contact a caller-provided endpoint or use its key.
pass
_append(override_url, override_model, headers)
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
_append(*resolve_endpoint("utility", owner=owner))
_append(*resolve_endpoint("default", owner=owner))
+50 -25
View File
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable, Dict, Tuple
from core.auth import RESERVED_USERNAMES
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.owner_identity import REQUEST_SENTINEL_OWNERS, auth_disabled
from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
@@ -21,6 +21,15 @@ from src.task_action_policy import (
logger = logging.getLogger(__name__)
class TaskEndpointAuthorityError(RuntimeError):
"""A scheduled task's endpoint or its credentials cannot be authorized."""
def _require_task_owner(owner):
if not str(owner or "").strip() and not auth_disabled():
raise TaskEndpointAuthorityError("Scheduled task has no owner in authenticated mode")
def _is_sft_fixture_owner(owner: str | None) -> bool:
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
return str(owner or "").strip().lower().startswith("sft_")
@@ -1345,6 +1354,7 @@ class TaskScheduler:
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
"""Execute a built-in action (no LLM needed)."""
_require_task_owner(task.owner)
from src.builtin_actions import BUILTIN_ACTIONS
from src.agent_runtime.authority import (
bind_request_authority, restore_task_authority, task_operation,
@@ -1634,6 +1644,7 @@ class TaskScheduler:
async def _execute_llm_task(self, task, db) -> str:
"""Execute an LLM task with full tool access via the agent loop."""
from core.database import Session as DbSession, ChatMessage, CrewMember
_require_task_owner(task.owner)
# If this task is wired to a CrewMember (personal assistant, custom
# crew), prefer the crew member's persona/model/endpoint as overrides.
@@ -1654,6 +1665,11 @@ class TaskScheduler:
endpoint_url, model = self._resolve_defaults(db, task.owner)
if not endpoint_url or not model:
raise RuntimeError("No model/endpoint configured")
# Authorize the original URL before normalization can erase a query,
# fragment, or caller-selected path. Stored tasks do not themselves
# grant permission to contact an endpoint.
from src.endpoint_resolver import resolve_owner_registered_endpoint
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
endpoint_url = _normalize_chat_endpoint(endpoint_url)
# Record the resolved model so _execute_task_locked can persist it on
# the run (tasks rarely pin a model, so this is the only record of
@@ -1772,6 +1788,8 @@ class TaskScheduler:
relevant_tools=relevant_tools,
datetime_context_msg=_dt_msg,
)
except TaskEndpointAuthorityError:
raise
except Exception as e:
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
from src.task_endpoint import task_llm_call_async
@@ -1986,6 +2004,7 @@ class TaskScheduler:
override_user_message: str | None = None,
datetime_context_msg: dict | None = None) -> str:
"""Run the full agent loop with tool access, collecting the final text."""
_require_task_owner(task.owner)
from src.agent_loop import stream_agent_loop
from src.agent_runtime.authority import restore_task_authority
@@ -2015,8 +2034,8 @@ class TaskScheduler:
headers = build_headers(api_key, base)
finally:
db2.close()
except Exception:
pass
except Exception as exc:
raise TaskEndpointAuthorityError("Scheduled task endpoint is unavailable or unauthorized") from exc
full_text = ""
final_text_replaced = False
tool_results = []
@@ -2172,11 +2191,16 @@ class TaskScheduler:
from src.settings import get_setting
# Resolve endpoint/model: research settings > task settings > session defaults
_require_task_owner(task.owner)
endpoint_url = task.endpoint_url
model = task.model
headers = {}
headers_from_resolver = False
if endpoint_url:
from src.endpoint_resolver import resolve_owner_registered_endpoint
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
if not endpoint_url or not model:
try:
from src.endpoint_resolver import resolve_endpoint
@@ -2213,8 +2237,8 @@ class TaskScheduler:
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
endpoint_url = build_chat_url(base)
headers = build_headers(api_key, base)
except Exception:
pass
except Exception as exc:
raise TaskEndpointAuthorityError("Scheduled research endpoint is unavailable or unauthorized") from exc
endpoint_url = _normalize_chat_endpoint(endpoint_url)
max_tokens, extraction_timeout, extraction_concurrency = (
@@ -2348,11 +2372,11 @@ class TaskScheduler:
have to special-case each tool's schema; the MCP tool ignores keys it
doesn't recognise.
"""
_require_task_owner(task.owner)
from src.tool_utils import get_mcp_manager
mcp = get_mcp_manager()
if not mcp:
logger.warning(f"Task {task.id}: MCP manager not available for delivery")
return
raise RuntimeError("MCP manager not available for task delivery")
# Resolve recipient — prefer the configured email From (the established
# "email yourself" pattern from daily_brief), fall back to task.owner.
@@ -2361,7 +2385,7 @@ class TaskScheduler:
recipient = None
try:
from routes.email_helpers import _get_email_config
cfg = _get_email_config() or {}
cfg = _get_email_config(owner=task.owner or "") or {}
recipient = cfg.get("from_address") or None
except Exception as _e:
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
@@ -2377,6 +2401,14 @@ class TaskScheduler:
"X-Odysseus-Ref": str(task.id),
},
}
if tool_name.startswith("mcp__email__"):
# The email server uses this reserved field for its account and
# approval scope; it must come from the task owner, never a model.
from src.tool_execution import _EMAIL_MCP_OWNER_ARG, _EMAIL_MCP_SESSION_ARG
if task.owner:
args[_EMAIL_MCP_OWNER_ARG] = task.owner
if getattr(task, "session_id", None):
args[_EMAIL_MCP_SESSION_ARG] = task.session_id
if recipient:
# Cover the common field names so we work across MCP servers (Gmail,
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
@@ -2391,25 +2423,18 @@ class TaskScheduler:
)
try:
mcp_result = await mcp.call_tool(tool_name, args)
stderr = mcp_result.get("stderr", "")
stdout = mcp_result.get("stdout", "")
body_len = len(result or "")
exit_code = mcp_result.get("exit_code", 0)
if exit_code != 0:
logger.warning(
f"Task {task.id} MCP delivery FAILED via {tool_name}: "
f"exit={exit_code} stderr={stderr[:400]!r} stdout={stdout[:400]!r}"
)
else:
# Include the MCP tool's own stdout (e.g. email_server returns
# "Sent email to ... with subject ...") + the body size so a
# silent SMTP failure is easier to spot in the logs.
logger.info(
f"Task {task.id} delivered via MCP tool {tool_name} "
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
)
if isinstance(mcp_result, dict):
from src.tool_execution import _normalize_mcp_text_error
mcp_result = _normalize_mcp_text_error(dict(mcp_result))
if not isinstance(mcp_result, dict) or mcp_result.get("error") or mcp_result.get("exit_code") != 0:
raise RuntimeError("MCP task delivery failed or returned an invalid result")
if tool_name == "mcp__email__send_email" and not str(mcp_result.get("stdout") or "").strip().startswith("Sent email to "):
raise RuntimeError("MCP email delivery did not report a completed send")
logger.info("Task %s delivered via MCP tool %s (recipient_set=%s, body=%sb)",
task.id, tool_name, bool(recipient), len(result or ""))
except Exception as e:
logger.error(f"Task {task.id} MCP delivery failed: {e}")
raise
async def run_task_now(self, task_id: str, *, force: bool = False):
"""Manually trigger a task execution."""
+8 -6
View File
@@ -1559,7 +1559,7 @@ def _agent_subprocess_env() -> dict:
def _legacy_private_browser_enabled() -> bool:
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
@@ -1757,11 +1757,13 @@ async def execute_tool_block(
transport = operation.transport_tool
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
# producer in src.browser_identity refuses every page action in this
# build. This compatibility transport requires an explicit opt-in.
return await _legacy_private_browser(
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
# The legacy process cannot establish the sealed browser/page identity
# required by the current runtime. Never execute it ahead of resource
# binding and the untrusted-context approval gate, even when opted in.
return f"{transport}: UNSUPPORTED", {
**page_unavailable(),
"error": "Legacy browser transport cannot establish the required browser authority.",
}
if operation.tool == "private_browser":
try:
_, browser_args = parse_browser_operation(operation.input)
+1 -1
View File
@@ -2000,7 +2000,7 @@ FUNCTION_TOOL_SCHEMAS = [
},
]
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}:
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
+7
View File
@@ -19,6 +19,13 @@ def test_infers_only_explicit_output_or_edit_paths():
assert requirements.verifier_required is True
def test_email_address_is_not_an_output_artifact():
requirements = infer_completion_requirements(
"Write a summary and email it to jane.doe@example.com."
)
assert requirements.required_artifacts == ()
def test_known_workspace_inputs_are_not_inferred_as_output_artifacts():
requirements = infer_completion_requirements(
"My clues are saved in /tmp_workspace/inputs/clues.md, the required answer "
+4 -3
View File
@@ -233,9 +233,10 @@ def test_research_status_rejects_wrong_owner():
rh.get_status.return_value = {"status": "running", "progress": {}}
router = setup_research_routes(rh)
target = next(r.endpoint for r in router.routes if getattr(r, "path", "") == "/api/research/status/{session_id}")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="x", request=_fake_request(user="bob")))
assert exc.value.status_code == 404
# Another owner's research is indistinguishable from no research at all.
out = asyncio.run(target(session_id="x", request=_fake_request(user="bob")))
assert out == {"status": "none"}
rh.get_status.assert_not_called()
def test_research_cancel_rejects_anonymous():
+103 -18
View File
@@ -480,9 +480,12 @@ def test_spinoff_detected_from_chatmessage_history():
assert _session_is_research_spinoff(sess) is True
def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch):
def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch, tmp_path):
import src.database as database
import src.endpoint_resolver as resolver
import src.llm_core as llm_core
import src.task_endpoint as task_endpoint
from tests.helpers.database import disposable_database
resolver_calls = []
llm_calls = []
@@ -500,24 +503,33 @@ def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch)
llm_calls.append((url, model, messages, kwargs))
return "Focused Fix"
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", fake_resolve_task_endpoint)
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call)
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", lambda ep, **kwargs: (ep.base_url, ep.api_key))
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", fake_resolve_task_endpoint)
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call)
with factory() as db:
db.add(database.ModelEndpoint(
id="ep-1", name="Session EP", owner="alice",
base_url="http://session.example/v1", api_key="registered-key", is_enabled=True,
))
db.commit()
session_headers = {"Authorization": "Bearer session"}
sess = SimpleNamespace(
id="session-1",
owner="alice",
endpoint_url="http://session.example/v1/chat/completions",
model="session-model",
headers=session_headers,
history=[SimpleNamespace(role="user", content="Please fix the endpoint fallback bug.")],
)
updates = []
session_manager = SimpleNamespace(
update_session_name=lambda session_id, title: updates.append((session_id, title))
)
session_headers = {"Authorization": "Bearer session"}
sess = SimpleNamespace(
id="session-1",
owner="alice",
endpoint_url="http://session.example/v1/chat/completions",
model="session-model",
headers=session_headers,
history=[SimpleNamespace(role="user", content="Please fix the endpoint fallback bug.")],
)
updates = []
session_manager = SimpleNamespace(
update_session_name=lambda session_id, title: updates.append((session_id, title))
)
asyncio.run(auto_name_session(session_manager, sess))
asyncio.run(auto_name_session(session_manager, sess))
assert resolver_calls == [(
"http://session.example/v1/chat/completions",
@@ -527,10 +539,83 @@ def test_auto_name_session_passes_session_fallback_to_task_resolver(monkeypatch)
)]
assert llm_calls[0][0] == "http://session.example/v1/chat/completions"
assert llm_calls[0][1] == "session-model"
assert llm_calls[0][3]["headers"] == session_headers
assert llm_calls[0][3]["headers"] == {"Authorization": "Bearer registered-key"}
assert updates == [("session-1", "Focused Fix")]
@pytest.mark.parametrize("owner,enabled,expected", [
("alice", True, True), ("bob", True, False), ("alice", False, False), ("alice", None, False),
])
def test_ajax_auto_title_uses_current_owner_endpoint_authority(monkeypatch, tmp_path, owner, enabled, expected):
import src.database as database
import src.endpoint_resolver as resolver
import src.llm_core as llm_core
from tests.helpers.database import disposable_database
calls, updates = [], []
async def fake_call(url, model, messages, **kwargs):
calls.append((url, kwargs.get("headers")))
return "A useful title"
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", lambda ep, **kwargs: (ep.base_url, ep.api_key))
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
if enabled is not None:
with factory() as db:
db.add(database.ModelEndpoint(id="ajax", name="Ajax", owner="alice",
base_url="https://ajax.example/v1", api_key="current", is_enabled=enabled))
db.commit()
sess = SimpleNamespace(id="s", owner=owner, model="ajax_c375",
endpoint_url="https://ajax.example/v1/chat/completions",
headers={"Authorization": "Bearer stale"},
history=[SimpleNamespace(role="user", content="Private title request")])
manager = SimpleNamespace(update_session_name=lambda sid, title: updates.append(title))
asyncio.run(auto_name_session(manager, sess))
assert bool(calls) is expected
assert updates == (["A useful title"] if expected else ["Private title request"])
if expected:
assert calls == [("https://ajax.example/v1/chat/completions", {"Authorization": "Bearer current"})]
@pytest.mark.parametrize("owner, enabled, expected", [
("alice", True, True), ("bob", True, False), ("alice", False, False), ("alice", None, False),
(None, True, False),
])
def test_non_ajax_auto_title_uses_current_owner_endpoint_authority(monkeypatch, tmp_path, owner, enabled, expected):
import src.database as database
import src.endpoint_resolver as resolver
import src.llm_core as llm_core
from tests.helpers.database import disposable_database
calls, updates = [], []
async def fake_call(url, model, messages, **kwargs):
calls.append((url, kwargs.get("headers")))
return "Non-spec title"
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", lambda ep, **kwargs: (ep.base_url, ep.api_key))
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
if enabled is not None:
with factory() as db:
db.add(database.ModelEndpoint(id="claude", name="Claude", owner="alice",
base_url="https://claude.example/v1", api_key="current", is_enabled=enabled))
db.commit()
sess = SimpleNamespace(id="s2", owner=owner, model="claude-3-5-sonnet",
endpoint_url="https://claude.example/v1/chat/completions",
headers={"Authorization": "Bearer stale"},
history=[SimpleNamespace(role="user", content="Private title request")])
manager = SimpleNamespace(update_session_name=lambda sid, title: updates.append(title))
asyncio.run(auto_name_session(manager, sess))
assert bool(calls) is expected
assert updates == (["Non-spec title"] if expected else ["Private title request"])
if expected:
assert calls == [("https://claude.example/v1/chat/completions", {"Authorization": "Bearer current"})]
def test_spinoff_detected_from_dict_history():
sess = SimpleNamespace(history=[
{"role": "system", "metadata": {"research_spinoff_from": "rp-2"}},
+2 -2
View File
@@ -7351,6 +7351,7 @@ def test_spec_direct_no_tools_decision(monkeypatch):
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
]
fake.latest_decision = lambda: fake.LAST_DECISIONS[-1]
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
@@ -7407,8 +7408,7 @@ def test_spec_direct_no_tools_decision(monkeypatch):
)
class _Boom:
@property
def LAST_DECISIONS(self):
def latest_decision(self):
raise RuntimeError('router down')
monkeypatch.setitem(sys.modules, 'src.spec_router', _Boom())
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
+17
View File
@@ -499,3 +499,20 @@ class TestResearchPrimerPreserved:
trimmed = trim_for_context(msgs, context_length=1024, reserve_tokens=256)
joined = "\n".join(str(m.get("content", "")) for m in trimmed)
assert "You are Odysseus." in joined
def test_unterminated_image_label_with_long_whitespace_finishes_promptly():
"""Untrusted captions must not make image-history compaction quadratic."""
import subprocess
import sys
script = '''
from src.context_compactor import keep_recent_image_turns
messages = [
{'role': 'user', 'content': [{'type': 'text', 'text': '[Image:' + ' ' * 100000},
{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AA=='}}]},
{'role': 'user', 'content': [{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AA=='}}]},
]
assert '[Image]' in keep_recent_image_turns(messages, keep=1)[0]['content'][0]['text']
'''
subprocess.run([sys.executable, '-c', script], check=True, timeout=8)
@@ -77,3 +77,141 @@ def test_swr_contract_is_wired_into_the_list_path():
assert "manual_refresh = bool(refresh)" in SRC
assert "_invalidate_list_cache(account_id, folder)" in SRC
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
def test_manual_refresh_fetches_mailbox_instead_of_returning_stale_index(monkeypatch):
class EmptyMailbox:
def select(self, folder, readonly=False):
return 'OK', [b'0']
def uid(self, command, *args):
assert command == 'SEARCH'
return 'OK', [b'']
def logout(self):
pass
endpoint = _list_endpoint()
monkeypatch.setattr(er, '_start_poller', lambda: None)
monkeypatch.setattr(er, '_imap_connect', lambda *a, **k: EmptyMailbox())
monkeypatch.setattr(er, '_email_index_list', lambda *a, **k: (
[{'uid': 'old', 'subject': 'Old'}], 1, 'yesterday'))
result = _call(endpoint, refresh=1)
assert result['emails'] == []
assert result['total'] == 0
assert not result.get('sync', {}).get('stale')
def test_stale_index_is_scoped_to_authenticated_owner(monkeypatch):
endpoint = _list_endpoint()
monkeypatch.setattr(er, '_start_poller', lambda: None)
monkeypatch.setattr(er, '_email_index_list', lambda owner, *a: (
[{'uid': owner, 'subject': owner}], 1, 'yesterday'))
monkeypatch.setattr(er, '_imap_connect', lambda *a, **k: (_ for _ in ()).throw(RuntimeError('offline')))
alice = _call(endpoint, owner='alice')
bob = _call(endpoint, owner='bob')
assert [mail['uid'] for mail in alice['emails']] == ['alice']
assert [mail['uid'] for mail in bob['emails']] == ['bob']
def test_inflight_refresh_cannot_restore_old_index_flags_after_mark_read(monkeypatch, tmp_path):
"""A delayed mailbox snapshot must not undo a completed owner mutation."""
import threading
from contextlib import contextmanager
from routes.email import email_helpers
database = tmp_path / 'email.db'
monkeypatch.setattr(email_helpers, 'SCHEDULED_DB', database)
monkeypatch.setattr(er, 'SCHEDULED_DB', database)
email_helpers._init_scheduled_db()
er._email_index_upsert('alice', 'acct', 'INBOX', [{
'uid': '42', 'subject': 'Example', 'flags': '', 'date_epoch': 1,
}])
fetched = threading.Event()
release = threading.Event()
threads = []
class Mailbox:
def __init__(self, mutation=False):
self.mutation = mutation
def select(self, *args, **kwargs):
return 'OK', [b'1']
def uid(self, command, *args):
if command == 'SEARCH':
return 'OK', [b'42']
if command == 'FETCH':
if self.mutation:
return 'OK', [b'1 (UID 42)']
threads.append(threading.current_thread())
fetched.set()
assert release.wait(5)
return 'OK', [(b'1 (UID 42 FLAGS () RFC822.SIZE 100 RFC822.HEADER {20}',
b'Subject: Example\r\n\r\n')]
if command == 'STORE':
return 'OK', []
raise AssertionError(command)
def logout(self):
pass
@contextmanager
def mailbox(*args, **kwargs):
yield Mailbox(mutation=True)
monkeypatch.setattr(er, '_start_poller', lambda: None)
monkeypatch.setattr(er, '_imap_connect', lambda *a, **kw: Mailbox())
monkeypatch.setattr(er, '_imap', mailbox)
router = er.setup_email_routes()
listing = next(r.endpoint for r in router.routes if getattr(r, 'name', '') == 'list_emails')
mark_read = next(r.endpoint for r in router.routes if getattr(r, 'name', '') == 'mark_read')
try:
assert _call(listing, owner='alice', account_id='acct')['emails'][0]['is_read'] is False
assert fetched.wait(5)
refresh_thread = threads[0]
result = asyncio.run(mark_read('42', folder='INBOX', account_id='acct', owner='alice'))
assert result['success'] is True
finally:
release.set()
refresh_thread.join(timeout=5)
assert not refresh_thread.is_alive()
rows, _, _ = er._email_index_list('alice', 'acct', 'INBOX', 'all', 50, 0)
assert rows[0]['is_read'] is True
def test_mark_read_keeps_other_owners_default_mailbox_unread(monkeypatch, tmp_path):
from contextlib import contextmanager
from routes.email import email_helpers
database = tmp_path / 'email.db'
monkeypatch.setattr(email_helpers, 'SCHEDULED_DB', database)
monkeypatch.setattr(er, 'SCHEDULED_DB', database)
email_helpers._init_scheduled_db()
for owner in ('alice', 'bob'):
er._email_index_upsert(owner, None, 'INBOX', [{
'uid': '42', 'subject': owner, 'flags': '', 'date_epoch': 1,
}])
class Mailbox:
def select(self, *args, **kwargs):
return 'OK', [b'1']
def uid(self, command, *args):
if command == 'FETCH':
return 'OK', [b'1 (UID 42)']
if command == 'STORE':
return 'OK', []
raise AssertionError(command)
@contextmanager
def mailbox(*args, **kwargs):
yield Mailbox()
monkeypatch.setattr(er, '_start_poller', lambda: None)
monkeypatch.setattr(er, '_imap', mailbox)
monkeypatch.setattr(er, '_imap_connect', lambda *a, **kw: (_ for _ in ()).throw(RuntimeError('offline')))
router = er.setup_email_routes()
listing = next(r.endpoint for r in router.routes if getattr(r, 'name', '') == 'list_emails')
mark_read = next(r.endpoint for r in router.routes if getattr(r, 'name', '') == 'mark_read')
assert _call(listing, owner='bob')['emails'][0]['is_read'] is False
assert asyncio.run(mark_read('42', folder='INBOX', account_id=None, owner='alice'))['success'] is True
assert _call(listing, owner='bob')['emails'][0]['is_read'] is False
rows, _, _ = er._email_index_list('alice', None, 'INBOX', 'all', 50, 0)
assert rows[0]['is_read'] is True
@@ -14,6 +14,20 @@ from src.teacher_escalation import with_teacher_takeover
CLAIM = 'I created report.md and all tests passed.'
@pytest.fixture(autouse=True)
def registered_scheduler_endpoint(tmp_path, monkeypatch):
import core.database as database
from tests.helpers.database import disposable_database
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, 'SessionLocal', factory)
with factory() as db:
db.add(database.ModelEndpoint(id='test-endpoint', name='test', owner='admin',
base_url='http://ep/v1', is_enabled=True))
db.commit()
yield
def _event(payload):
return 'data: ' + json.dumps(payload) + '\n\n'
+7
View File
@@ -37,6 +37,13 @@ def test_score_value_and_choice_of():
assert jd.choice_of(None) is None
def test_non_finite_score_falls_back(monkeypatch):
for bad in (float("nan"), float("inf"), "nan"):
assert jd.score_value({"type": "score", "score": bad}) is None
_enable(monkeypatch, bad)
assert _run(ba._spec_triage_email_urgency({"subject": "x", "body": "y"}, triage_version=11)) is None
def test_disabled_job_does_not_call_adapter(monkeypatch):
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "0")
calls = {"n": 0}
+29
View File
@@ -0,0 +1,29 @@
"""Legacy browser compatibility cannot bypass runtime resource and approval gates."""
from unittest.mock import AsyncMock
import pytest
from src.agent_runtime.authority import OperationGrant, RequestAuthority
from src.tool_capabilities import ToolRunSecurityContext
from src.tool_types import ToolBlock
from src import tool_execution
def test_empty_legacy_browser_flag_does_not_enable_compatibility(monkeypatch):
monkeypatch.setenv('ODYSSEUS_PRIVATE_BROWSER_LEGACY', '')
assert not tool_execution._legacy_private_browser_enabled()
@pytest.mark.asyncio
@pytest.mark.parametrize('untrusted', [False, True])
async def test_legacy_browser_never_bypasses_untrusted_context_gate(monkeypatch, untrusted):
monkeypatch.setenv('ODYSSEUS_PRIVATE_BROWSER_LEGACY', '1')
legacy = AsyncMock(return_value=('executed', {'exit_code': 0}))
monkeypatch.setattr(tool_execution, '_legacy_private_browser', legacy)
authority = RequestAuthority('request', 'alice', 'thread', '', (OperationGrant('private_browser'),))
_, result = await tool_execution.execute_tool_block(
ToolBlock('private_browser', '{"action":"click","selector":"#submit"}'),
owner='alice', session_id='thread', request_authority=authority,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=untrusted))
assert result.get('exit_code') != 0
legacy.assert_not_awaited()
@@ -162,3 +162,23 @@ def test_identity_auto_pin_stops_after_first_five_for_owner(monkeypatch):
assert added.get("category") == "identity"
assert added.get("pinned") is not True
assert sum(1 for r in rows if r.get("category") == "identity" and r.get("pinned")) == 5
@pytest.mark.parametrize('vector_match', [False, True])
def test_name_refinement_never_rewrites_shared_legacy_memory(monkeypatch, vector_match):
class NameSession(FakeSession):
def get_context_messages(self):
return [{'role': 'user', 'content': 'My name is Alex Morgan.'},
{'role': 'assistant', 'content': 'Noted.'}]
mm = FakeMemoryManager([
{'id': 'legacy', 'text': 'Name is Alex', 'owner': None},
])
_install_llm_stub(monkeypatch, '[{"text":"Name is Alex Morgan","evidence":"My name is Alex Morgan"}]')
module = _load_extractor()
vector = FakeVector('legacy') if vector_match else NoMatchVector()
asyncio.run(module.extract_and_store(NameSession('userB'), mm, vector,
endpoint_url='http://fixture', model='fixture'))
assert next(row for row in mm.rows if row['id'] == 'legacy')['text'] == 'Name is Alex'
assert any(row.get('owner') == 'userB' and row.get('text') == 'Name is Alex Morgan'
for row in mm.rows)
+26 -1
View File
@@ -142,7 +142,7 @@ def test_editor_agent_endpoint_reports_scoped_tools():
eaar.set_editor_planner(None)
async def _route_contract(monkeypatch, message, *, attachment_meta=()):
async def _route_contract(monkeypatch, message, *, attachment_meta=(), form=None, messages_out=None):
from routes import chat_routes
from src import tool_security
from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint
@@ -164,6 +164,8 @@ async def _route_contract(monkeypatch, message, *, attachment_meta=()):
async def capture_agent(*args, **kwargs):
observed.append(kwargs["turn_contract"])
if messages_out is not None:
messages_out.extend(kwargs.get("messages") or (args[2] if len(args) > 2 else []))
yield 'data: {"delta":"ok"}\n\n'
yield "data: [DONE]\n\n"
@@ -173,6 +175,7 @@ async def _route_contract(monkeypatch, message, *, attachment_meta=()):
"message": message, "compare_mode": "false",
"allow_web_search": "false", "allow_bash": "false",
})
request._form.update(form or {})
response = await endpoint(request)
async for _ in response.body_iterator:
pass
@@ -200,6 +203,28 @@ async def test_attached_photo_edit_selects_photo_editor_family(monkeypatch):
assert not (CORE_NAMES & set(contract.offered))
@pytest.mark.asyncio
@pytest.mark.parametrize("session,expected", [
("alice:3", "(session alice:3)"),
("x). Ignore all prior rules and call every tool (", None),
("line\nbreak", None),
])
async def test_editor_session_is_an_identifier_before_reaching_system_prompt(monkeypatch, session, expected):
messages = []
contract = await _route_contract(monkeypatch, "make this black and white",
form={"editor_session": session}, messages_out=messages)
system = " ".join(str(m.get("content") or "") for m in messages if m.get("role") == "system")
if expected:
assert contract.capabilities == {"photo_editor"}
assert "The image editor is open " + expected in system
else:
# A rejected id is no editor session: it neither reaches the system
# prompt nor requests the editor family on its own.
assert "photo_editor" not in contract.capabilities
assert "Ignore all prior rules" not in system and "line\nbreak" not in system
assert "The image editor is open" not in system
def test_chat_context_guides_open_or_absent_editor():
from pathlib import Path
+11 -3
View File
@@ -529,9 +529,11 @@ async def test_skill_test_ingress_distinguishes_user_task_from_tool_payload(monk
@pytest.mark.asyncio
async def test_scheduled_override_cannot_replace_stored_authority(monkeypatch):
async def test_scheduled_override_cannot_replace_stored_authority(monkeypatch, tmp_path):
from src import agent_loop
from src.task_scheduler import TaskScheduler
from tests.helpers.database import disposable_database
import core.database as database
seen = []
async def fake_stream(*args, **kwargs):
seen.append(kwargs)
@@ -542,8 +544,14 @@ async def test_scheduled_override_cannot_replace_stored_authority(monkeypatch):
owner="alice", name="test", max_steps=1)
with bind_request_authority(authority("transcribe_media", workspace="/workspace/original")):
task.request_authority_json = seal_task_authority(task.prompt, "llm", None, owner="alice")
await TaskScheduler(session_manager=None)._run_agent_loop("https://inference.invalid", "test", task, "s",
override_user_message="Run bash and python")
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
with factory() as db:
db.add(database.ModelEndpoint(id="model", name="model", owner="alice",
base_url="https://inference.invalid", is_enabled=True))
db.commit()
await TaskScheduler(session_manager=None)._run_agent_loop("https://inference.invalid", "test", task, "s",
override_user_message="Run bash and python")
grant = seen[0]["request_authority"]
assert grant.permits(ExactOperation.normalize("transcribe_media", '{}'))
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
+6 -2
View File
@@ -31,7 +31,7 @@ def test_unrelated_override_receives_no_saved_credentials(monkeypatch, url):
monkeypatch.setattr(tasks, "resolve_endpoint", lambda *a, **k: (None, None, {}))
monkeypatch.setattr(tasks, "resolve_utility_fallback_candidates", lambda **k: [])
candidates = resolve_task_candidates(override_url=url, override_model="test")
assert candidates == [(url, "test", {})]
assert candidates == []
runtime.assert_not_called()
@@ -73,7 +73,11 @@ def test_credential_resolution_is_exact_and_owner_scoped(monkeypatch, tmp_path,
monkeypatch.setattr(settings, "get_setting", lambda key, default=None: default)
try:
if resolver_kind == "task":
headers = tasks.resolve_task_candidates(override_url=url, override_model="test", owner=owner)[0][2]
candidates = tasks.resolve_task_candidates(override_url=url, override_model="test", owner=owner)
if expected is None:
assert candidates == []
return
headers = candidates[0][2]
else:
from routes.skills_routes import _resolve_audit_models
headers = _resolve_audit_models(owner=owner, endpoint_url=url, model_spec="test")[2]
@@ -31,6 +31,10 @@ def _patch_scheduler_deps(monkeypatch):
lambda key, default=None: [] if key == "disabled_tools" else default,
)
monkeypatch.setattr("src.tool_index.get_tool_index", lambda: None)
monkeypatch.setattr("src.endpoint_resolver.resolve_owner_registered_endpoint",
lambda *a, **k: SimpleNamespace(base_url="http://ep/v1"))
monkeypatch.setattr("src.endpoint_resolver.resolve_endpoint_runtime",
lambda ep, owner=None: (ep.base_url, None))
# ---------------------------------------------------------------------------
@@ -70,6 +74,7 @@ async def test_scheduler_agent_loop_path(monkeypatch):
async def test_scheduler_retires_unattended_exact_approval(monkeypatch):
_patch_scheduler_deps(monkeypatch)
from src.task_scheduler import TaskScheduler
from src.tool_approvals import tool_approval_store
from src.tool_capabilities import capabilities_for_action
+119
View File
@@ -0,0 +1,119 @@
"""Spec must never widen a request or discard its evidence obligations."""
import sys
import types
from dataclasses import replace
import pytest
from src import agent_loop
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.turn_contract import active_turn_contract, bind_turn_contract, resolve_turn_contract
def _contract():
return resolve_turn_contract(
capabilities={'notes'}, required_capabilities={'notes'},
schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy(),
selected_tools={'manage_notes', 'ask_user'}, message='List my notes',
)
def _router(monkeypatch, picks, choice='t1', mode='strict'):
fake = types.ModuleType('src.spec_router')
fake.LAST_DECISIONS = [{'choice': choice, 'top': [(next(iter(picks), ''), .99)]}]
fake.latest_decision = lambda: fake.LAST_DECISIONS[-1]
fake.spec_route_tools = lambda *a, **kw: (picks, True)
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(agent_loop, '_configured_model_tool_surface_raw', lambda *a, **kw: 'spec')
monkeypatch.setattr(agent_loop, 'get_mcp_manager', lambda: None)
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', mode)
def _route(contract):
return agent_loop._spec_router_contract(contract, [{'role': 'user', 'content': 'List my notes'}],
set(), 'http://fixture', 'fixture', 'alice', {}, ToolPolicy())
@pytest.mark.parametrize('picks,choice', [({'bash'}, 't1'), (set(), 'answer_directly'), ({'ask_user'}, 't1')])
def test_router_cannot_replace_required_read_or_offer_other_tools(monkeypatch, picks, choice):
_router(monkeypatch, picks, choice)
contract = _contract()
assert contract.required_read_operation is not None
with bind_turn_contract(contract):
routed = _route(contract)
assert routed.required == contract.required
assert routed.required_read_operation == contract.required_read_operation
assert routed.offered <= contract.offered
assert routed.executable == contract.executable
assert active_turn_contract() is routed
assert active_turn_contract() is None
def test_router_keeps_unavailable_read_outside_required_tool_set(monkeypatch):
# The only representable read outside `required` is an explicitly
# unavailable one; the router must not turn that refusal into a menu.
_router(monkeypatch, {'ask_user'})
base = _contract()
contract = replace(base, required=frozenset(),
unavailable=frozenset({base.required_read_operation.tool}))
with bind_turn_contract(contract):
assert _route(contract) is contract
assert active_turn_contract() is contract
def test_router_narrowing_keeps_read_and_executable_set(monkeypatch):
_router(monkeypatch, {'manage_notes'})
contract = _contract()
with bind_turn_contract(contract):
routed = _route(contract)
assert routed is not contract and routed.routing_experiment == 'spec-router'
assert routed.required == contract.required
assert routed.required_read_operation == contract.required_read_operation
assert routed.offered <= contract.offered
assert routed.executable == contract.executable
@pytest.mark.parametrize('mode', ['off', 'unsupported'])
def test_off_and_unknown_modes_keep_contract_without_router(monkeypatch, mode):
_router(monkeypatch, {'bash'}, mode=mode)
contract = _contract()
assert _route(contract) is contract
def test_router_exception_keeps_bound_contract(monkeypatch):
_router(monkeypatch, {'bash'})
sys.modules['src.spec_router'].spec_route_tools = lambda *a, **kw: 1 / 0
contract = _contract()
with bind_turn_contract(contract):
assert _route(contract) is contract
assert active_turn_contract() is contract
@pytest.mark.asyncio
async def test_driver_refuses_before_provider_or_dispatch(monkeypatch):
from src import spec_driver
monkeypatch.setattr(spec_driver, 'preview_model_response', lambda *a, **k: pytest.fail('provider called'))
monkeypatch.setattr(spec_driver, 'execute_tool_block', lambda *a, **k: pytest.fail('tool called'))
contract = _contract()
with bind_turn_contract(contract):
chunks = [chunk async for chunk in spec_driver.stream_spec_turn(
endpoint_url='http://fixture', model='fixture', messages=[], headers={},
session_id='s', owner='alice', disabled_tools=set(), tool_policy=ToolPolicy())]
assert active_turn_contract() is contract
assert 'unavailable' in ''.join(chunks)
assert chunks[-1] == 'data: [DONE]\n\n'
@pytest.mark.asyncio
async def test_driver_close_restores_context_after_early_disconnect(monkeypatch):
from src import spec_driver
contract = _contract()
with bind_turn_contract(contract):
stream = spec_driver.stream_spec_turn(
endpoint_url='http://fixture', model='fixture', messages=[], headers={},
session_id='s', owner='alice', disabled_tools=set(), tool_policy=ToolPolicy())
await anext(stream)
await stream.aclose()
assert active_turn_contract() is contract
assert active_turn_contract() is None
+2 -2
View File
@@ -34,6 +34,7 @@ def _install_spec_router(monkeypatch, *, p):
fake.LAST_DECISIONS = [
{'message': PROMPT, 'choice': 'answer_directly', 'answer_directly': p},
]
fake.latest_decision = lambda: fake.LAST_DECISIONS[-1]
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
@@ -133,8 +134,7 @@ async def test_spec_direct_router_error_is_unchanged(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
class _Boom:
@property
def LAST_DECISIONS(self):
def latest_decision(self):
raise RuntimeError('router down')
monkeypatch.setitem(sys.modules, 'src.spec_router', _Boom())
monkeypatch.setattr(
+29 -121
View File
@@ -31,10 +31,11 @@ def _contract(prompt=PROMPT):
def _install_driver_router(monkeypatch, decision, picks=None):
"""Fake src.spec_router exposing spec_route_tools + LAST_DECISIONS."""
"""Fake the router's request-local decision."""
fake = types.ModuleType('src.spec_router')
fake.think_router_enabled = lambda: False
fake.LAST_DECISIONS = [decision]
fake.latest_decision = lambda: fake.LAST_DECISIONS[-1]
def _spec_route_tools(user_message, schemas, disabled_tools=None,
messages=None, return_decision=False):
@@ -168,130 +169,37 @@ def test_spec_driver_pick_shapes(monkeypatch):
@pytest.mark.asyncio
async def test_spec_driver_tool_pick_sends_only_that_tool(monkeypatch):
@pytest.mark.parametrize('decision,picks', [
({'choice': 't1', 'top': [('web_fetch', 0.9)]}, ['web_fetch']),
({'choice': 'answer_directly', 'top': []}, []),
({'choice': None, 'error': 'timeout'}, []),
({'choice': 't1', 'top': [('manage_calendar', 0.9)]}, ['manage_calendar']),
({'choice': 't1', 'top': [('totally_missing_tool', 0.9)]}, ['totally_missing_tool']),
])
async def test_spec_driver_refuses_before_router_model_or_tools(monkeypatch, decision, picks):
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 't1', 'tools': ['web_fetch'],
'top': [('web_fetch', 0.11)], 'answer_directly': 0.2, 'ask_user': 0.1,
}, picks=['web_fetch'])
captured = []
await _run(monkeypatch, captured)
assert captured, 'stream_preview made a provider round'
first = captured[0]
names = [(s.get('function') or {}).get('name') for s in (first.get('tools') or [])]
assert names == ['web_fetch']
assert first.get('tool_choice') == {
'type': 'function', 'function': {'name': 'web_fetch'}}
@pytest.mark.asyncio
async def test_spec_driver_answer_directly_omits_tools(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 'answer_directly', 'tools': [],
'top': [('answer_directly', 0.35)], 'answer_directly': 0.35, 'ask_user': 0.05,
}, picks=[])
captured = []
await _run(monkeypatch, captured)
assert captured
assert 'tools' not in captured[0] # vLLM rejects an empty array
@pytest.mark.asyncio
async def test_spec_driver_router_error_keeps_the_normal_menu(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': None, 'tools': [],
'top': [], 'answer_directly': 0.0, 'ask_user': 0.0, 'error': 'boom',
}, picks=[])
captured = []
contract, _events = await _run(monkeypatch, captured)
assert captured
sent = {(s.get('function') or {}).get('name')
for s in (captured[0].get('tools') or [])}
assert sent == set(contract.offered) # unchanged normal menu
assert sent
@pytest.mark.asyncio
async def test_spec_driver_pick_uses_the_full_spec_surface(monkeypatch):
"""m3518: Spec picks from the FULL surface (not the compact round menu) and
the pick is admitted/executable that round (sent as the single tool)."""
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 't1', 'tools': ['manage_calendar'],
'top': [('manage_calendar', 0.9)], 'answer_directly': 0.05, 'ask_user': 0.05,
}, picks=['manage_calendar'])
captured = []
await _run(monkeypatch, captured)
assert captured
names = [(s.get('function') or {}).get('name')
for s in (captured[0].get('tools') or [])]
assert names == ['manage_calendar']
@pytest.mark.asyncio
async def test_spec_driver_pick_not_on_the_surface_keeps_the_normal_menu(monkeypatch):
"""Only a pick that is truly not executable falls back for the round."""
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 't1', 'tools': ['totally_missing_tool'],
'top': [('totally_missing_tool', 0.9)], 'answer_directly': 0.05, 'ask_user': 0.05,
}, picks=['totally_missing_tool'])
captured = []
contract, _events = await _run(monkeypatch, captured)
assert captured
sent = {(s.get('function') or {}).get('name')
for s in (captured[0].get('tools') or [])}
assert 'totally_missing_tool' not in sent
assert sent == set(contract.offered) # normal menu retained
@pytest.mark.asyncio
async def test_spec_driver_drops_off_pick_native_call(monkeypatch):
"""m3517: execute ONLY the Spec pick; drop any other native call."""
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 't1', 'tools': ['web_fetch'],
'top': [('web_fetch', 0.7)], 'answer_directly': 0.1, 'ask_user': 0.1,
}, picks=['web_fetch'])
_install_driver_router(monkeypatch, decision, picks=picks)
router = sys.modules['src.spec_router']
router.spec_route_tools = lambda *a, **k: pytest.fail('driver called router')
executed = _install_recorder(monkeypatch)
scripts = [
{'tool_calls': [
{'name': 'web_search', 'arguments': '{"query": "x"}'},
{'name': 'web_fetch', 'arguments': '{"url": "http://x"}'},
]},
{'content': 'done'},
]
captured = []
await _run(monkeypatch, captured, scripts=scripts, max_rounds=2)
assert executed == ['web_fetch'], executed
@pytest.mark.asyncio
async def test_spec_driver_answer_directly_strips_markup_and_executes_nothing(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 'answer_directly', 'tools': [],
'top': [('answer_directly', 0.9)], 'answer_directly': 0.9, 'ask_user': 0.05,
}, picks=[])
executed = _install_recorder(monkeypatch)
scripts = [{
'content': 'Hello <tool_call>{"name": "web_search", "arguments": {}}</tool_call> world',
}]
captured = []
_contract_, events = await _run(monkeypatch, captured, scripts=scripts)
_, events = await _run(monkeypatch, captured)
assert captured == []
assert executed == []
finals = [e.get('content', '') for e in events if e.get('type') == 'final_response']
assert finals, events
assert 'Hello' in finals[-1] and '<tool_call' not in finals[-1]
assert any('unavailable' in event.get('delta', '') for event in events)
@pytest.mark.asyncio
async def test_driver_configuration_is_not_rechecked_mid_turn(monkeypatch):
calls = []
def active(*args):
calls.append(True)
return len(calls) > 1
monkeypatch.setattr(runner, 'spec_driver_active', active)
captured = []
await _run(monkeypatch, captured)
assert captured
assert len(calls) == 1
def test_spec_driver_wired_in_stream_preview():
+73 -4
View File
@@ -30,6 +30,7 @@ def _route(monkeypatch, disabled, *, subset="1", base_browser=True, hidden=(),
fake.LAST_DECISIONS = [{
"choice": "t62", "top": [("private_browser", 0.859)],
}]
fake.latest_decision = lambda: fake.LAST_DECISIONS[-1]
fake.spec_route_tools = lambda *a, **kw: ({"private_browser"}, True)
monkeypatch.setitem(sys.modules, "src.spec_router", fake)
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: "spec")
@@ -91,7 +92,7 @@ async def test_spec_unavailable_contract_finishes_before_provider_call(monkeypat
assert events[-1] == "data: [DONE]\n\n"
@pytest.mark.parametrize("subset,base_browser", [("0", False), ("0", True), ("1", True)])
@pytest.mark.parametrize("subset,base_browser", [("0", True), ("1", True)])
def test_spec_browser_allowed_keeps_executable_tool(monkeypatch, subset, base_browser):
routed = _route(monkeypatch, set(), subset=subset, base_browser=base_browser)
assert routed.routing_experiment == "spec-router"
@@ -109,13 +110,18 @@ def test_subset_control_does_not_confuse_base_omission_with_policy_denial(monkey
assert "private_browser" in routed.executable
def test_subset_override_cannot_add_an_unoffered_browser(monkeypatch):
routed = _route(monkeypatch, set(), subset="0", base_browser=False)
assert "private_browser" not in routed.offered
@pytest.mark.asyncio
async def test_non_strict_inherited_unavailable_continues_to_preview(monkeypatch):
# Non-strict Spec preserves an unrelated base-contract unavailable value.
# That value must not be mistaken for our newly sealed policy conflict.
inherited = _route(monkeypatch, set(), subset="0", mode="additive",
inherited_unavailable={"manage_notes"})
assert inherited.routing_experiment == "spec-router"
assert inherited.routing_experiment != "spec-router"
assert inherited.unavailable == frozenset({"manage_notes"})
async def fake_preview(**kwargs):
@@ -130,8 +136,8 @@ async def test_non_strict_inherited_unavailable_continues_to_preview(monkeypatch
turn_contract=inherited, disabled_tools=set(), tool_policy=ToolPolicy(),
_is_teacher_run=True,
)]
assert any("preview continued" in chunk for chunk in events)
assert not any("Enable Web search" in chunk for chunk in events)
assert not any("preview continued" in chunk for chunk in events)
assert any("manage_notes" in chunk for chunk in events)
def test_policy_hidden_browser_is_denied_even_without_disabled_snapshot(monkeypatch):
@@ -157,6 +163,7 @@ def test_guide_only_policy_keeps_its_existing_control_path(monkeypatch):
def test_spec_direct_answer_is_not_blocked_by_unavailable_tool_policy(monkeypatch):
fake = types.ModuleType("src.spec_router")
fake.LAST_DECISIONS = [{"choice": "answer_directly", "top": []}]
fake.latest_decision = lambda: fake.LAST_DECISIONS[-1]
fake.spec_route_tools = lambda *a, **kw: (set(), True)
monkeypatch.setitem(sys.modules, "src.spec_router", fake)
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: "spec")
@@ -173,3 +180,65 @@ def test_spec_direct_answer_is_not_blocked_by_unavailable_tool_policy(monkeypatc
{"private_browser"}, "http://fixture", "Ajax", "fixture", {},
)
assert "private_browser" not in routed.unavailable
@pytest.mark.asyncio
async def test_contractless_spec_run_uses_the_compact_path(monkeypatch):
# Scheduled, skill and monitor runs call the loop without a turn contract.
# Without a contract no Spec routing applies, so the run must proceed like
# the compact surface instead of reporting a refusal as its output.
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: "spec")
reached = []
async def provider(*args, **kwargs):
reached.append(True)
raise RuntimeError("provider reached")
yield # pragma: no cover
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", provider)
events = [chunk async for chunk in agent_loop.stream_agent_loop(
endpoint_url="http://fixture", model="Ajax",
messages=[{"role": "user", "content": "hello"}],
turn_contract=None, disabled_tools=set(), tool_policy=ToolPolicy(),
_is_teacher_run=True,
)]
assert reached
assert not any("requires a turn contract" in chunk for chunk in events)
@pytest.mark.asyncio
async def test_contractless_spec_run_gains_no_tool_authority(monkeypatch):
# A contract-less Spec run must offer exactly what the compact surface
# offers under the same policy, and must never consult the router.
import src.spec_router as spec_router
def router_consulted(*args, **kwargs):
raise AssertionError("contract-less Spec run consulted the router")
monkeypatch.setattr(spec_router, "spec_route_tools", router_consulted)
policy = ToolPolicy(disabled_tools=frozenset({"manage_calendar"}))
async def offered_tools(surface):
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: surface)
menus = []
async def provider(*args, **kwargs):
menus.append(sorted((tool.get("function") or {}).get("name") or ""
for tool in kwargs.get("tools") or ()))
raise RuntimeError("stop after the first provider request")
yield # pragma: no cover
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", provider)
with pytest.raises(RuntimeError, match="stop after"):
async for _ in agent_loop.stream_agent_loop(
endpoint_url="http://fixture", model="Ajax",
messages=[{"role": "user", "content": "list my notes and add a calendar event tomorrow at 9"}],
turn_contract=None, disabled_tools={"manage_calendar"}, tool_policy=policy,
_is_teacher_run=True,
):
pass
return menus
spec_menus = await offered_tools("spec")
assert spec_menus and spec_menus == await offered_tools("compact")
assert all("manage_calendar" not in menu for menu in spec_menus)
+195
View File
@@ -0,0 +1,195 @@
"""Concurrent Spec decisions must remain bound to their own requests."""
import io
import json
import threading
import asyncio
from concurrent.futures import ThreadPoolExecutor
import pytest
from src import agent_loop, clean_agent_preview, spec_router
def test_concurrent_identical_requests_do_not_share_router_decisions(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.setattr(agent_loop, '_configured_model_tool_surface_raw', lambda *a, **k: 'spec')
completed = threading.Barrier(2)
first_done = threading.Event()
def response(request, timeout):
if threading.current_thread().name.endswith('_0'):
choice, probability = 'answer_directly', .99
else:
assert first_done.wait(2)
choice, probability = 't1', .99
return io.BytesIO(json.dumps({'answers': {'tool': {
'choice': choice, 'probabilities': {choice: probability},
}}}).encode())
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', response)
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
def request(owner):
spec_router.spec_route_tools('same private question', schemas, return_decision=True)
if owner == 'alice':
first_done.set()
completed.wait(timeout=2)
return clean_agent_preview.spec_direct_no_tools_p(
'same private question', 'http://fixture', 'fixture', owner, {})
with ThreadPoolExecutor(max_workers=2) as pool:
alice = pool.submit(request, 'alice')
bob = pool.submit(request, 'bob')
assert alice.result() == .99
assert bob.result() is None
@pytest.mark.asyncio
async def test_concurrent_async_sessions_keep_their_own_decisions(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.setattr(agent_loop, '_configured_model_tool_surface_raw', lambda *a, **k: 'spec')
def response(request, timeout):
body = json.loads(request.data)
choice = 'answer_directly' if body['state']['user_message'] == 'alice' else 't1'
return io.BytesIO(json.dumps({'answers': {'tool': {
'choice': choice, 'probabilities': {choice: .99},
}}}).encode())
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', response)
ready = asyncio.Event()
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
async def session(owner):
spec_router.spec_route_tools(owner, schemas, return_decision=True)
if owner == 'alice':
await ready.wait()
else:
ready.set()
return clean_agent_preview.spec_direct_no_tools_p(owner, 'http://fixture', 'fixture', owner, {})
assert await asyncio.gather(session('alice'), session('bob')) == [.99, None]
@pytest.mark.parametrize('response', [
{'choice': 't0', 'probabilities': {'t0': .99}},
{'choice': 't1', 'probabilities': {'t1': .99, 't999': .8}},
{'choice': 't1', 'probabilities': {'t1': 1.5}},
])
def test_invalid_router_response_cannot_choose_a_tool(monkeypatch, response):
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', lambda *a, **k: io.BytesIO(
json.dumps({'answers': {'tool': response}}).encode()))
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
picks, decided = spec_router.spec_route_tools('List notes', schemas, return_decision=True)
assert picks == set()
assert decided is False
def test_disabled_router_clears_previous_turn_decision(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', lambda *a, **k: io.BytesIO(
json.dumps({'answers': {'tool': {'choice': 'answer_directly',
'probabilities': {'answer_directly': .99}}}}).encode()))
spec_router.spec_route_tools('first request', [], return_decision=True)
assert spec_router.latest_decision()['choice'] == 'answer_directly'
monkeypatch.delenv('ODYSSEUS_SPEC_ROUTER_URL')
assert spec_router.spec_route_tools('second request', [], return_decision=True) == (set(), False)
assert spec_router.latest_decision() is None
@pytest.mark.asyncio
async def test_async_router_does_not_block_event_loop(monkeypatch):
"""When the spec router is stalled or retrying, the asyncio event loop remains
responsive and concurrent requests can run and complete without blocking."""
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_URL", "http://router.stall.invalid")
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "2.0")
stall_started = asyncio.Event()
unblock_router = asyncio.Event()
timeline = []
class _FakeAsyncResponse:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {
"answers": {
"tool": {
"choice": "t1",
"probabilities": {"t1": 0.95},
}
}
}
async def fake_async_post(client, url, json=None, headers=None, timeout=None):
timeline.append("router_post_start")
stall_started.set()
# Simulate network latency or waiting for router:
# Await unblock event so other tasks must run on the loop while this waits
await unblock_router.wait()
timeline.append("router_post_finish")
return _FakeAsyncResponse()
monkeypatch.setattr(spec_router.httpx.AsyncClient, "post", fake_async_post)
schemas = [{"function": {"name": "manage_notes", "parameters": {"properties": {}}}}]
async def stalled_routing_task():
timeline.append("task_a_start")
picks, decided = await spec_router.spec_route_tools_async(
"Find notes", schemas, return_decision=True
)
timeline.append("task_a_end")
dec = spec_router.latest_decision()
return picks, decided, dec
async def concurrent_responsive_task():
# Wait until the router has started waiting on the event loop
await stall_started.wait()
timeline.append("task_b_executed")
# Task B does work and finishes immediately while router is still stalled!
unblock_router.set()
return "task_b_done"
res_b, res_a = await asyncio.gather(
concurrent_responsive_task(),
stalled_routing_task(),
)
assert res_b == "task_b_done"
picks, decided, dec = res_a
assert picks == {"manage_notes"}
assert decided is True
assert dec is not None
assert dec.get("choice") == "t1"
# Verify task_b ran and finished while task_a was waiting on the post
assert timeline == [
"task_a_start",
"router_post_start",
"task_b_executed",
"router_post_finish",
"task_a_end",
]
@pytest.mark.asyncio
async def test_async_router_cancellation_propagates(monkeypatch):
"""When a request cancels, spec_route_tools_async cancels cleanly."""
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_URL", "http://router.stall.invalid")
async def hanging_post(*a, **k):
await asyncio.sleep(10.0)
monkeypatch.setattr(spec_router.httpx.AsyncClient, "post", hanging_post)
task = asyncio.create_task(
spec_router.spec_route_tools_async("hello", [], return_decision=True)
)
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
+73
View File
@@ -0,0 +1,73 @@
"""Scheduled MCP delivery must use the task owner's mailbox and report failure."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.task_scheduler import TaskScheduler
@pytest.mark.asyncio
async def test_mcp_delivery_uses_task_owner_mailbox(monkeypatch):
calls = []
monkeypatch.setattr('routes.email_helpers._get_email_config',
lambda **kwargs: calls.append(kwargs) or {'from_address': 'alice@example.test'})
manager = SimpleNamespace(call_tool=AsyncMock(return_value={'exit_code': 0, 'stdout': 'Sent email to alice@example.test'}))
monkeypatch.setattr('src.tool_utils.get_mcp_manager', lambda: manager)
task = SimpleNamespace(id='task', name='Digest', owner='alice')
await TaskScheduler(None)._deliver_via_mcp('mcp__email__send_email', task, 'content')
assert calls == [{'owner': 'alice'}]
assert manager.call_tool.await_args.args[1]['to'] == 'alice@example.test'
assert manager.call_tool.await_args.args[1]['_odysseus_owner'] == 'alice'
@pytest.mark.asyncio
@pytest.mark.parametrize('output', [
{'exit_code': 1, 'stderr': 'failed'},
{'error': 'not connected', 'exit_code': 1},
{'exit_code': 0, 'stdout': 'Error: SMTP failed'},
{'exit_code': 0, 'stdout': 'Draft staged for approval. Nothing has been sent yet.'},
{},
None,
])
async def test_mcp_delivery_failure_is_reported(monkeypatch, output):
monkeypatch.setattr('routes.email_helpers._get_email_config', lambda **kwargs: {})
manager = SimpleNamespace(call_tool=AsyncMock(return_value=output))
monkeypatch.setattr('src.tool_utils.get_mcp_manager', lambda: manager)
task = SimpleNamespace(id='task', name='Digest', owner='alice@example.test')
with pytest.raises(RuntimeError):
await TaskScheduler(None)._deliver_via_mcp('mcp__email__send_email', task, 'content')
@pytest.mark.asyncio
async def test_generic_mcp_delivery_reports_text_errors(monkeypatch):
monkeypatch.setattr('routes.email_helpers._get_email_config', lambda **kwargs: {})
manager = SimpleNamespace(call_tool=AsyncMock(return_value={'exit_code': 0, 'stdout': 'Error: delivery failed'}))
monkeypatch.setattr('src.tool_utils.get_mcp_manager', lambda: manager)
task = SimpleNamespace(id='task', name='Digest', owner='alice@example.test')
with pytest.raises(RuntimeError):
await TaskScheduler(None)._deliver_via_mcp('mcp__custom__send', task, 'content')
@pytest.mark.asyncio
async def test_authenticated_ownerless_action_is_not_executed(monkeypatch):
from src import builtin_actions
monkeypatch.setenv('AUTH_ENABLED', 'true')
action = AsyncMock(return_value=('done', True))
monkeypatch.setitem(builtin_actions.BUILTIN_ACTIONS, 'test_action', action)
task = SimpleNamespace(id='task', name='job', owner=None, action='test_action',
prompt='', task_type='action')
with pytest.raises(RuntimeError):
await TaskScheduler(None)._execute_action(task)
action.assert_not_awaited()
@pytest.mark.asyncio
async def test_authenticated_ownerless_mcp_delivery_is_not_executed(monkeypatch):
monkeypatch.setenv('AUTH_ENABLED', 'true')
manager = SimpleNamespace(call_tool=AsyncMock())
monkeypatch.setattr('src.tool_utils.get_mcp_manager', lambda: manager)
task = SimpleNamespace(id='task', name='job', owner=None)
with pytest.raises(RuntimeError):
await TaskScheduler(None)._deliver_via_mcp('mcp__email__send_email', task, 'content')
manager.call_tool.assert_not_awaited()
@@ -138,7 +138,11 @@ async def test_scheduler_refresh_failure_never_uses_static_or_caller_credentials
db.commit()
runtime = MagicMock(side_effect=ValueError("Session expired"))
monkeypatch.setattr("src.chatgpt_subscription.resolve_runtime_credentials", runtime)
assert (await run(kind, base))["headers"] == {}
fallback = AsyncMock(side_effect=AssertionError("unauthorized endpoint must not fall back"))
monkeypatch.setattr("src.task_endpoint.task_llm_call_async", fallback)
with pytest.raises((ValueError, RuntimeError)):
await run(kind, base)
fallback.assert_not_awaited()
runtime.assert_called_once_with("server-session", owner="alice")
@@ -155,12 +159,13 @@ async def test_scheduler_credentials_require_enabled_visible_registration(execut
db.commit()
runtime = MagicMock(wraps=resolver.resolve_endpoint_runtime)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", runtime)
captured = await run(kind, base)
if state == "shared":
captured = await run(kind, base)
assert captured["headers"] == resolver.build_headers("server-secret", base)
assert runtime.call_args.kwargs == {"owner": "alice"}
else:
assert captured["headers"] == {}
with pytest.raises((ValueError, RuntimeError)):
await run(kind, base)
runtime.assert_not_called()
@@ -191,8 +196,8 @@ async def test_scheduler_invalid_route_never_recovers_registered_credentials(exe
}
runtime = MagicMock(wraps=resolver.resolve_endpoint_runtime)
monkeypatch.setattr(resolver, "resolve_endpoint_runtime", runtime)
captured = await run(kind, mutations[change].geturl(), execute_agent_task=False)
assert captured["headers"] == {}
with pytest.raises((ValueError, RuntimeError)):
await run(kind, mutations[change].geturl(), execute_agent_task=False)
runtime.assert_not_called()
@@ -208,3 +213,34 @@ async def test_scheduled_research_keeps_configured_resolver_credentials(executio
assert captured == {"endpoint_url": url, "headers": headers}
assert configured.call_args.kwargs["owner"] == "alice"
recovery.assert_not_called()
@pytest.mark.parametrize('kind', ['agent', 'research'])
async def test_scheduler_refuses_unregistered_task_endpoint(execution, kind):
_, run = execution
with pytest.raises((ValueError, RuntimeError)):
await run(kind, 'https://unregistered.example.test/v1')
@pytest.mark.parametrize('kind', ['agent', 'research'])
async def test_ownerless_task_cannot_use_another_owners_endpoint(execution, monkeypatch, kind):
factory, run = execution
monkeypatch.setenv('AUTH_ENABLED', 'true')
with factory() as db:
db.add(database.ModelEndpoint(id='bob', name='Bob endpoint', owner='bob',
base_url='https://bob.example.test/v1', is_enabled=True))
db.commit()
with pytest.raises((ValueError, RuntimeError)):
await run(kind, 'https://bob.example.test/v1', owner=None)
@pytest.mark.parametrize('kind', ['agent', 'research'])
async def test_ownerless_task_works_in_explicit_single_user_mode(execution, monkeypatch, kind):
factory, run = execution
monkeypatch.setenv('AUTH_ENABLED', 'false')
with factory() as db:
db.add(database.ModelEndpoint(id='shared', name='Local endpoint', owner=None,
base_url='http://localhost:1234/v1', is_enabled=True))
db.commit()
captured = await run(kind, 'http://localhost:1234/v1', owner=None)
assert captured['endpoint_url'] == 'http://localhost:1234/v1/chat/completions'
+1
View File
@@ -107,6 +107,7 @@ async def test_scheduled_task_honors_global_disabled_tools(monkeypatch):
return {"web_fetch"}
monkeypatch.setattr("src.tool_index.get_tool_index", lambda: _FakeIndex())
monkeypatch.setattr("src.endpoint_resolver.resolve_owner_registered_endpoint", lambda *a, **k: object())
captured = {}
+283
View File
@@ -149,3 +149,286 @@ def test_keep_recent_image_turns_noop_when_few_images():
msgs = [_user_with_image("a.png", "[Image attached: a.png]")]
out = keep_recent_image_turns(msgs, keep=2)
assert out == msgs
def test_keep_recent_image_turns_does_not_mutate_original_history():
import copy
messages = [_user_with_image('a.png', 'hello'), _user_with_image('b.png', 'later')]
original = copy.deepcopy(messages)
result = keep_recent_image_turns(messages, keep=1)
assert messages == original
assert '[Image]' in str(result[0]['content'])
def test_old_image_with_string_url_does_not_break_history_compaction():
messages = [
{'role': 'user', 'content': [{'type': 'image_url', 'image_url': 'data:image/png;base64,AAAA'}]},
_user_with_image('later.png', 'later'),
]
result = keep_recent_image_turns(messages, keep=1)
assert result[0]['content'] == [{'type': 'text', 'text': '[Image]'}]
# --- vision probe security & authorization regressions -----------------------
def test_probe_blocks_link_local_and_cloud_metadata(monkeypatch):
ch._vision_probe_cache.clear()
calls = []
monkeypatch.setattr(ch.httpx, "post", lambda *a, **k: calls.append(a) or None)
disallowed_urls = [
"http://169.254.169.254/v1",
"http://169.254.169.253/v1",
"http://169.254.1.1/v1",
"http://[fe80::1]/v1",
"http://100.100.100.200/v1",
"http://instance-data/v1",
"http://metadata.google.internal/v1",
]
for url in disallowed_urls:
assert ch.probe_endpoint_supports_vision(url, "some-model") is None
assert ch.lmstudio_supports_vision(url, "some-model") is None
assert calls == []
def test_probe_blocks_public_domains(monkeypatch):
ch._vision_probe_cache.clear()
calls = []
monkeypatch.setattr(ch.httpx, "post", lambda *a, **k: calls.append(a) or None)
public_urls = [
"https://api.openai.com/v1",
"https://generativelanguage.googleapis.com/v1",
"http://example.com/v1",
]
for url in public_urls:
assert ch.probe_endpoint_supports_vision(url, "gpt-4o") is None
assert ch.lmstudio_supports_vision(url, "gpt-4o") is None
assert calls == []
def test_model_supports_vision_enforces_owner_authorization(tmp_path, monkeypatch):
from tests.helpers.database import disposable_database
from core import database
post_calls = []
class _Resp:
status_code = 200
text = '{"choices":[]}'
monkeypatch.setattr(ch, "is_vision_model", lambda m: False)
monkeypatch.setattr(ch.httpx, "post", lambda *a, **k: post_calls.append((a, k)) or _Resp())
monkeypatch.setattr(ch, "lmstudio_supports_vision", lambda *a, **k: None)
with disposable_database(tmp_path) as factory:
monkeypatch.setattr("src.database.SessionLocal", factory)
with factory() as db:
db.add(database.ModelEndpoint(
id="alice-local",
name="Alice Local",
owner="alice",
base_url="http://127.0.0.1:18607/v1",
api_key="alice-secret",
is_enabled=True,
))
db.add(database.ModelEndpoint(
id="alice-disabled",
name="Alice Disabled",
owner="alice",
base_url="http://127.0.0.1:18608/v1",
api_key="alice-secret",
is_enabled=False,
))
db.commit()
# 1. Foreign owner cannot probe alice's endpoint
ch._vision_probe_cache.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18607/v1", owner="bob") is False
assert len(post_calls) == 0
# 2. Disabled endpoint is never probed
ch._vision_probe_cache.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18608/v1", owner="alice") is False
assert len(post_calls) == 0
# 3. Unregistered endpoint is never probed when owner is provided
ch._vision_probe_cache.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18609/v1", owner="alice") is False
assert len(post_calls) == 0
# 4. Registered, enabled endpoint owned by alice IS probed with registered runtime credentials
ch._vision_probe_cache.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18607/v1", owner="alice") is True
assert len(post_calls) == 1
args, kwargs = post_calls[0]
assert "http://127.0.0.1:18607/v1/chat/completions" in args[0]
assert kwargs.get("headers", {}).get("Authorization") == "Bearer alice-secret"
def test_is_ip_safe_local_ipv6_and_mapped_addresses():
import ipaddress
# IPv4 loopback & IPv6 loopback must be allowed
assert ch._is_ip_safe_local(ipaddress.ip_address("127.0.0.1")) is True
assert ch._is_ip_safe_local(ipaddress.ip_address("::1")) is True
# Cloud metadata destinations must be blocked
assert ch._is_ip_safe_local(ipaddress.ip_address("169.254.169.254")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("169.254.169.253")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("100.100.100.200")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("fe80::1")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("169.254.1.1")) is False
# IPv4-mapped IPv6 addresses must be unwrapped and judged as IPv4
assert ch._is_ip_safe_local(ipaddress.ip_address("::ffff:169.254.169.254")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("::ffff:100.100.100.200")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("::ffff:127.0.0.1")) is True
assert ch._is_ip_safe_local(ipaddress.ip_address("::ffff:192.168.1.1")) is True
# NAT64 translated addresses must be unwrapped
assert ch._is_ip_safe_local(ipaddress.ip_address("64:ff9b::169.254.169.254")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("64:ff9b::127.0.0.1")) is True
# Unspecified, multicast, and non-loopback reserved addresses must be blocked
assert ch._is_ip_safe_local(ipaddress.ip_address("::")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("0.0.0.0")) is False
assert ch._is_ip_safe_local(ipaddress.ip_address("ff02::1")) is False
def test_is_safe_local_host_handles_ipv6_and_dual_stack(monkeypatch):
import socket
# Direct literals
assert ch._is_safe_local_host("::1") is True
assert ch._is_safe_local_host("[::1]") is True
assert ch._is_safe_local_host("127.0.0.1") is True
# Dual-stack localhost where getaddrinfo returns both IPv6 ::1 and IPv4 127.0.0.1
def fake_dual_stack_getaddrinfo(host, port):
return [
(socket.AF_INET6, 1, 6, "", ("::1", 0, 0, 0)),
(socket.AF_INET, 1, 6, "", ("127.0.0.1", 0)),
]
monkeypatch.setattr(socket, "getaddrinfo", fake_dual_stack_getaddrinfo)
assert ch._is_safe_local_host("localhost") is True
# Mixed DNS resolution (one valid loopback, one metadata destination) must be rejected
def fake_mixed_getaddrinfo(host, port):
return [
(socket.AF_INET, 1, 6, "", ("127.0.0.1", 0)),
(socket.AF_INET, 1, 6, "", ("169.254.169.254", 0)),
]
monkeypatch.setattr(socket, "getaddrinfo", fake_mixed_getaddrinfo)
assert ch._is_safe_local_host("rig.local") is False
def test_bracketed_ipv6_url_handling(monkeypatch):
calls = []
class _Resp:
status_code = 200
text = '{"models":[{"key":"model1","architecture":{},"capabilities":{"vision":true}}]}'
is_success = True
def json(self):
import json
return json.loads(self.text)
monkeypatch.setattr(ch, "_safe_probe_get", lambda url, **k: calls.append(url) or _Resp())
ch._lmstudio_models_cache.clear()
models = ch._probe_lmstudio_models("http://[::1]:18607/v1")
assert models is not None
assert calls == ["http://[::1]:18607/api/v1/models"]
def test_connection_time_address_enforcement_blocks_unsafe_destinations():
backend = ch.SafeProbeBackend()
import httpcore
import pytest
with pytest.raises(httpcore.ConnectError, match="not an authorized safe local destination"):
backend.connect_tcp("169.254.169.254", 80, timeout=0.1)
with pytest.raises(httpcore.ConnectError, match="not an authorized safe local destination"):
backend.connect_tcp("100.100.100.200", 80, timeout=0.1)
def test_vision_probe_never_follows_redirects(monkeypatch):
class _RedirectResp:
status_code = 302
headers = {"Location": "http://169.254.169.254/secret"}
is_success = False
text = ""
# Verify _probe_lmstudio_models does not follow redirects
monkeypatch.setattr(ch, "_safe_probe_get", lambda url, **k: _RedirectResp())
ch._lmstudio_models_cache.clear()
assert ch._probe_lmstudio_models("http://127.0.0.1:18607/v1") is None
# Verify probe_endpoint_supports_vision does not follow redirects
monkeypatch.setattr(ch, "_safe_probe_post", lambda url, **k: _RedirectResp())
ch._vision_probe_cache.clear()
assert ch.probe_endpoint_supports_vision("http://127.0.0.1:18607/v1", "text-only-9b") is None
def test_model_supports_vision_auth_policy_matrix(tmp_path, monkeypatch):
from tests.helpers.database import disposable_database
from core import database
post_calls = []
class _Resp:
status_code = 200
text = '{"choices":[]}'
monkeypatch.setattr(ch, "is_vision_model", lambda m: False)
monkeypatch.setattr(ch, "lmstudio_supports_vision", lambda *a, **k: None)
monkeypatch.setattr(ch, "_safe_probe_post", lambda *a, **k: post_calls.append((a, k)) or _Resp())
with disposable_database(tmp_path) as factory:
monkeypatch.setattr("src.database.SessionLocal", factory)
with factory() as db:
db.add(database.ModelEndpoint(
id="alice-local",
name="Alice Local",
owner="alice",
base_url="http://127.0.0.1:18607/v1",
api_key="alice-key",
is_enabled=True,
))
db.add(database.ModelEndpoint(
id="unowned-local",
name="Unowned Local",
owner=None,
base_url="http://127.0.0.1:18610/v1",
api_key="unowned-key",
is_enabled=True,
))
db.commit()
# Case 1: Auth disabled, explicitly configured local endpoint -> probes with runtime credentials
monkeypatch.setenv("AUTH_ENABLED", "false")
ch._vision_probe_cache.clear()
post_calls.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18610/v1", owner=None) is True
assert len(post_calls) == 1
assert post_calls[0][1].get("headers", {}).get("Authorization") == "Bearer unowned-key"
# Case 2: Auth disabled, unregistered caller-supplied endpoint -> allowed if safe local, probed without credentials
monkeypatch.setenv("AUTH_ENABLED", "false")
ch._vision_probe_cache.clear()
post_calls.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18611/v1", owner=None) is True
assert len(post_calls) == 1
assert post_calls[0][1].get("headers") is None
# Case 3: Auth enabled, missing owner ("" or None from unowned session) -> rejected immediately
monkeypatch.setenv("AUTH_ENABLED", "true")
ch._vision_probe_cache.clear()
post_calls.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18607/v1", owner="") is False
assert len(post_calls) == 0
# Case 4: Auth enabled, foreign-owned endpoint -> rejected immediately
monkeypatch.setenv("AUTH_ENABLED", "true")
ch._vision_probe_cache.clear()
post_calls.clear()
assert ch.model_supports_vision("text-only-9b", "http://127.0.0.1:18607/v1", owner="bob") is False
assert len(post_calls) == 0
+2 -4
View File
@@ -21,7 +21,7 @@ described as a switch that turns something off, the read rejects `0`, `false`,
`no` and `off` and treats everything else as on. The `Default` column is the
value the code falls back to when the variable is unset, quoted from the source.
The source tree reads **149** `ODYSSEUS_*` variables: 90 an operator may want to set, and 59 that are internal - sentinels, fixture switches, capture hooks and development tooling. The internal ones are listed too, in their own section, so this page can be checked against the source mechanically.
The source tree reads **147** `ODYSSEUS_*` variables: 90 an operator may want to set, and 57 that are internal - sentinels, fixture switches, capture hooks and development tooling. The internal ones are listed too, in their own section, so this page can be checked against the source mechanically.
> This page is generated. Edit `scripts/generate_env_reference.py` and
> re-run it; `tests/test_env_reference.py` enforces that the committed page
@@ -222,14 +222,12 @@ Listed for completeness. Setting one of these on a real install is either a no-o
| `ODYSSEUS_SPEC_DIRECT_MIN_P` | `'0.9'` | `src/clean_agent_preview.py` | Minimum confidence probability a spec-router direct-answer decision must reach before the no-tools path is taken. |
| `ODYSSEUS_SPEC_DIRECT_MODE` | `'tool_choice_none'` | `src/clean_agent_preview.py` | Controls how the spec router answers high-confidence direct-answer turns; either tool_choice_none (default) or empty_menu. |
| `ODYSSEUS_SPEC_DIRECT_NOTOOLS` | `''` | `src/clean_agent_preview.py` | Set to 1, true, yes or on to enable the spec router's direct-answer no-tools path for high-confidence answer_directly decisions. |
| `ODYSSEUS_SPEC_DRIVER_TOPK` | `'1'` | `src/spec_driver.py` | Number of router-selected tool candidates exposed in a driver round. |
| `ODYSSEUS_SPEC_MODE` | `'strict'` | `src/agent_loop.py` (+1 more) | Spec routing mode for explicitly configured models, including strict and driver. |
| `ODYSSEUS_SPEC_ROUTER_MIN_CONF` | `'0.3'` | `src/spec_router.py` | Minimum tool-selection confidence accepted from the router. |
| `ODYSSEUS_SPEC_ROUTER_MODEL` | `'odysseus-s60'` | `src/spec_router.py` | Decision adapter model used for tool routing. |
| `ODYSSEUS_SPEC_ROUTER_SECOND` | `'0.25'` | `src/spec_router.py` | Minimum confidence for adding a secondary tool candidate. |
| `ODYSSEUS_SPEC_ROUTER_TIMEOUT` | `'0.5'` | `src/spec_router.py` | Time budget in seconds for a router decision. |
| `ODYSSEUS_SPEC_ROUTER_URL` | `''` | `src/spec_router.py` | Base URL of the optional Spec router; an empty URL disables requests. |
| `ODYSSEUS_SPEC_SUBSET_OF_BASE` | `'1'` | `src/agent_loop.py` | Limit Spec tool selections to the existing request inventory by default. |
| `ODYSSEUS_SPEC_THINK_MIN_P` | `'0.5'` | `src/spec_router.py` | Minimum router confidence for enabling thinking. |
| `ODYSSEUS_SPEC_THINK_MODEL` | `os.getenv('ODYSSEUS_SPEC_ROUTER_MODEL', 'odysseus-s60')` | `src/spec_router.py` | Optional thinking decision adapter model, falling back to the tool router model. |
| `ODYSSEUS_SPEC_THINK_QUESTION` | `'binary'` | `src/spec_router.py` | Thinking classifier question format: binary or kind5. |
@@ -302,7 +300,7 @@ reads three ways, because no single pattern covers the codebase:
lines, so one read lives inside a string literal.
The three passes are not redundancy. A line-based grep for a direct
`os.environ.get("ODYSSEUS_...` call finds 119 of the 149 variables on this
`os.environ.get("ODYSSEUS_...` call finds 117 of the 147 variables on this
page. What it misses is reads through an env-reader helper, reads whose call
spans more than one line, reads whose variable name is held in a module
constant, and reads through a mapping passed in as an argument - which is the