Integrate updated Ajax runtime review

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:37:00 +00:00
46 changed files with 8262 additions and 112 deletions
+23 -10
View File
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
"subject": subject,
"from": sender_name or sender_addr or sender,
"from_address": sender_addr,
"to": str(row.get("to") or headers.get("To") or ""),
"cc": str(row.get("cc") or headers.get("Cc") or ""),
"references": str(row.get("references") or headers.get("References") or ""),
"date": date_str,
"date_epoch": date_epoch,
"summary": body[:240],
@@ -1557,6 +1560,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
if not isinstance(rows, list):
return False
owner = _current_owner()
matches = []
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
continue
@@ -1568,14 +1572,19 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
continue
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
continue
for key, value in updates.items():
if value is None:
row.pop(key, None)
else:
row[key] = value
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return True
return False
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
continue
matches.append(row)
if len(matches) != 1:
return False
row = matches[0]
for key, value in updates.items():
if value is None:
row.pop(key, None)
else:
row[key] = value
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return True
# ── Tool implementations ──
@@ -2925,7 +2934,9 @@ def _create_email_draft_document(
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
"""Create a threaded Odysseus reply draft document. Does not send."""
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
if _fixture_email_enabled() and (fixture is None or fixture.get("error")):
return fixture or {"error": "No email fixture is available for the authenticated owner"}
if fixture is not None:
sender = str(fixture.get("from_address") or fixture.get("from") or "")
_, sender_addr = email.utils.parseaddr(sender)
@@ -3178,7 +3189,9 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
def _set_flag(uid, folder, flag, add=True, account=None):
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
if _fixture_email_enabled():
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is None:
return False
if flag == "\\Seen":
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
if flag == "\\Answered":
+44
View File
@@ -571,6 +571,50 @@ async def auto_name_session(session_manager, sess):
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
model_name = str(getattr(sess, "model", "") or "")
# The spec ("Ajax") model names its own chats from the user's first
# prompt. This function only runs after the first response stream has
# settled (auto_name_session_after_stream), so titling does not
# contend with the visible answer. Thinking is disabled and the
# budget kept small so the model returns a bare title.
try:
from src.model_profiles import uses_odysseus_progressive_thinking
_is_spec_model = bool(model_name) and uses_odysseus_progressive_thinking(model_name)
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:
title = await llm_call_async(
endpoint_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."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=32,
headers=_headers or None,
timeout=20,
thinking_mode="off",
)
except Exception as e:
logger.warning("[auto-name] spec title call failed for %s: %s", sess.id, e)
title = ""
title = str(title or "").strip().strip('"\'').strip()
from src.text_helpers import strip_think
title = strip_think(title, prose=False, prompt_echo=False)
if not title or len(title) >= 80 or "\n" in title:
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named spec session {sess.id}: {title}")
return
if (
"ttft" in model_name.lower()
or re.search(r":18\d{3}\b", endpoint_url)
+96 -10
View File
@@ -27,11 +27,13 @@ from src.llm_core import (
from src.agent_loop import (
stream_agent_loop,
_configured_model_tool_surface,
_configured_model_tool_surface_raw,
_local_media_needs_browser_render,
_looks_like_workspace_coding_request,
)
from src.agent_loop import _normalize_ody_qwen_text_artifacts
from src import agent_runs
from src import shutdown as _shutdown
from src.model_context import estimate_tokens
from src.context_compactor import (
apply_compaction_state,
@@ -2688,6 +2690,19 @@ def setup_chat_routes(
logger.info("chat→agent auto-escalation: explicit private browser workflow")
active_doc_id = form_data.get("active_doc_id", "").strip()
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
# 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 = (
form_data.get("editor_session")
or form_data.get("editor_session_id")
or ""
).strip()
photo_editor_requested = (
str(form_data.get("photo_editor") or "").strip().lower()
in {"1", "true", "yes", "on"}
or bool(editor_session)
)
logger.info(
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
chat_mode, active_doc_id, active_doc_state,
@@ -3270,7 +3285,34 @@ def setup_chat_routes(
active_document=bool(active_doc), workspace=bool(workspace),
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
) if _use_turn_contract else frozenset()
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
# Scoped photo_editor family: offered ONLY for an explicit editor
# request (Quick Edit popup / editor session) or an image-edit chat
# turn carrying an image. When active the turn offers just the editor
# tools plus ask_user, never the general tool list.
_photo_editor_active = bool(_use_turn_contract) and (
photo_editor_requested
or (
"image_editing" in _turn_capabilities
and bool(ctx.preprocessed.attachment_meta)
)
)
if _photo_editor_active:
if editor_session:
editor_context = {'role': 'system', 'content':
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
'editor and confirm the result with a screenshot.'}
else:
editor_context = {'role': 'system', 'content':
'The user attached an image but no image editor is open, so the editor tools cannot '
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
'tools will then apply it.'}
ctx.messages.insert(0, editor_context)
if foreground_policy.enabled:
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
if image_refs:
@@ -3307,6 +3349,11 @@ def setup_chat_routes(
# pasted revision request. This only offers permitted schemas;
# it never requires or performs a document mutation.
_turn_capabilities = _turn_capabilities | {"documents"}
# Scoped photo_editor family (see above): when active the turn offers
# just the editor tools plus ask_user, never the general tool list.
if _photo_editor_active:
_turn_capabilities = frozenset({"photo_editor"})
_active_turn_capabilities = _turn_capabilities
# Same decision that prepared the turn; it only stamps the contract
# inside the agent-contract branch below.
_clean_v3_preview = _compact_preview_turn
@@ -3556,9 +3603,22 @@ def setup_chat_routes(
"document_editor": not {
"manage_documents", "create_document", "edit_document", "update_document",
}.issubset(disabled_tools),
# A photo-editing chat turn can only reach a canvas when the image
# editor is actually open. The compact runtime uses this to reply
# (open the image in the editor) instead of looping on editor tools
# that have no canvas to drive.
"photo_editor_open": bool(editor_session),
}
# Capture permission state before schema selection/reconciliation.
# Only deterministic request intent supplies grants, never inventory.
# The strict router (spec) surface picks its tools in src/spec_router.py
# AFTER authority is built, so its admission is keyed off the surface.
try:
_spec_surface = _configured_model_tool_surface_raw(
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
) == "spec"
except Exception:
_spec_surface = False
_request_authority = request_authority_for_http(
request, message, owner=_user, session_id=session, workspace=workspace,
history=_turn_history, policy=tool_policy,
@@ -3566,9 +3626,9 @@ def setup_chat_routes(
active_document=bool(active_doc),
image_attachment=any(str(a.get('mime') or '').startswith('image/')
for a in (ctx.preprocessed.attachment_meta or [])),
capabilities=({'search_browser'} if (
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
_explicit_browser_intent or _external_discovery_intent
) else ()),
) else set())),
)
if exact_tool_approval is not None:
from src.agent_runtime.authority import RequestAuthority
@@ -3758,7 +3818,9 @@ def setup_chat_routes(
# personal manager; core shell/Web tools are not task
# fallbacks. Other turns retain warm and workspace tools.
extra_tools=(
frozenset()
frozenset({"ask_user"})
if _active_turn_capabilities == frozenset({"photo_editor"})
else frozenset()
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
_active_turn_capabilities in (
frozenset({"transcription"}), frozenset({"ocr"}),
@@ -3960,7 +4022,7 @@ def setup_chat_routes(
_heartbeat_counter = 0
_last_progress = {}
_sent_avg = False
while True:
while not _shutdown.is_shutting_down():
status = research_handler.get_status(session)
if not status or status["status"] != "running":
break
@@ -4222,8 +4284,6 @@ def setup_chat_routes(
# indicator, but don't fold them into the saved
# reply (mirrors the rewrite path below).
if data.get("thinking"):
if thinking_mode == "off":
continue
thinking_response += data["delta"]
else:
full_response += data["delta"]
@@ -4667,8 +4727,6 @@ def setup_chat_routes(
# Forward them for the live indicator, but keep
# them out of the saved reply (same as chat mode).
if data.get("thinking"):
if thinking_mode == "off":
continue
thinking_response += data["delta"]
else:
full_response = _render_state.content
@@ -4852,6 +4910,25 @@ def setup_chat_routes(
_terminal_saved = True
if _saved_id:
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
run_post_response_tasks(
sess, session_manager, session, message, _saveable_no_tool_response,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
agent_rounds=_agent_rounds,
agent_tool_calls=_agent_tool_calls,
skills_manager=skills_manager,
owner=_user,
extract_skills=(
user_requested_agent
and not tool_approval_continuation
),
allow_background_extraction=_post_response_extraction_allowed(
tools_blocked=tool_policy.block_all_tool_calls,
tool_approval_continuation=tool_approval_continuation,
client_runtime_context=client_runtime_context,
),
)
yield f'data: {json.dumps(_metrics_event)}\n\n'
except json.JSONDecodeError:
yield chunk
@@ -4962,6 +5039,8 @@ def setup_chat_routes(
raises before reaching a mode-specific finally block."""
try:
async for chunk in stream_with_save():
if _shutdown.is_shutting_down():
break
yield chunk
finally:
_active_streams.pop(session, None)
@@ -5056,7 +5135,12 @@ def setup_chat_routes(
if rec is None:
if agent_runs.is_active(session_id):
return {"status": "streaming", "detached": True}
raise HTTPException(404, "No active stream for this session")
# No active stream: report an explicit idle state with HTTP 200.
# A 404 here made every session load look like a failed request in
# the browser console. The client distinguishes "no stream" by the
# body (status != "streaming"), and still tolerates a 404 from an
# older server, so this stays backward compatible.
return {"status": "idle"}
return rec
# ------------------------------------------------------------------ #
@@ -5155,6 +5239,8 @@ def setup_chat_routes(
max_tokens=0,
tools=None,
):
if _shutdown.is_shutting_down():
break
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
data = json.loads(chunk[6:])
+4 -4
View File
@@ -1185,7 +1185,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
"""Use AI to judge if documents are junk/test/accidental, then delete them.
Caches verdicts so previously-reviewed docs are skipped."""
from src.task_endpoint import resolve_task_endpoint
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
@@ -1228,13 +1228,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
+ "\n".join(doc_list)
)
response = await llm_call_async(
url, model,
response = await task_llm_call_async(
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
{"role": "user", "content": prompt}],
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=user or None,
temperature=0.1,
max_tokens=200,
headers=headers,
timeout=30,
)
+15 -3
View File
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.constants import DEEP_RESEARCH_DIR
from src import shutdown as _shutdown
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
@@ -285,11 +286,22 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
async def research_status(session_id: str, request: Request):
user = _require_user(request)
_validate_session_id(session_id)
# No in-flight or persisted research for this session is a normal state
# (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.
if not _owns_in_memory(session_id, user):
raise HTTPException(404, "No research found for this session")
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:
raise HTTPException(404, "No research found for this session")
return {"status": "none"}
try:
source_state = research_handler.get_source_state(session_id)
if isinstance(source_state, str) and source_state:
@@ -657,7 +669,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
raise HTTPException(404, "No research found for this session")
async def _generate():
last_payload = None
while True:
while not _shutdown.is_shutting_down():
status = research_handler.get_status(session_id)
if status is None:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
+8 -5
View File
@@ -1320,7 +1320,7 @@ def setup_session_routes(
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens.
"""
from src.llm_core import llm_call
from src.llm_core import llm_call, llm_call_with_fallback
from src.utility_effort import effort_for_call
user = effective_user(request)
single_user_mode = not user and _auth_disabled()
@@ -1467,7 +1467,7 @@ def setup_session_routes(
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
# Pick an endpoint — prefer admin-configured task endpoint
from src.task_endpoint import resolve_task_endpoint
from src.task_endpoint import resolve_task_endpoint, resolve_task_candidates
url, model, headers = resolve_task_endpoint(owner=user)
if not url:
url, model, headers = _pick_endpoint_for_sort(owner=user)
@@ -1493,9 +1493,12 @@ def setup_session_routes(
# 16384 (was 4096): with many chats the folder JSON is large, and a
# reasoning model spends tokens thinking first — 4096 truncated the
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
reasoning_effort=effort_for_call(url, model, user))
raw = llm_call_with_fallback(
resolve_task_candidates(fallback_url=url, fallback_model=model,
fallback_headers=headers, owner=user),
[{"role": "user", "content": prompt}],
temperature=0.3, max_tokens=16384, timeout=120,
reasoning_effort=effort_for_call(url, model, user))
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
# Extract JSON from response — handle markdown fences, leading text,
# reasoning-model <think> blocks, and trailing commas.
+4 -3
View File
@@ -27,6 +27,7 @@ from src.host_docker_access import (
from src.optional_deps import prepare_optional_dependency_import
from src.auth_helpers import _auth_disabled
from src import process_lifecycle
from src import shutdown as _shutdown
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
# on Windows, so importing them unconditionally crashed app startup there
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
lines_sent = 0
exit_code = None
while True:
while not _shutdown.is_shutting_down():
# Check client disconnect
if await request.is_disconnected():
# tmux keeps running — that's the whole point
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
lines_sent = 0
exit_code = None
while True:
while not _shutdown.is_shutting_down():
if await request.is_disconnected():
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
return
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
"""Read chunks, split on \\n or \\r for progress bar support."""
try:
buf = b""
while True:
while not _shutdown.is_shutting_down():
chunk = await stream.read(4096)
if not chunk:
if buf:
+37
View File
@@ -946,6 +946,43 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
"Seconds the outpainting proxy waits for the image backend before giving "
"up on a single request.",
),
"ODYSSEUS_JEFF_": ("Agent loop and tool execution", INTERNAL, "Prefix for per-job decision adapter opt-ins; each job is disabled by default."),
"ODYSSEUS_JEFF_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Timeout in seconds for a decision adapter request."),
"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."),
"ODYSSEUS_SPEC_THINK_ROUTER": ("Agent loop and tool execution", INTERNAL, "Enable router thinking decisions for explicitly configured Spec models."),
"ODYSSEUS_SPEC_TOOL_FORMAT": ("Agent loop and tool execution", INTERNAL, "Use names for compact router tool signatures, otherwise include descriptions."),
"ODYSSEUS_SPEC_TRIM_PROMPT": ("Agent loop and tool execution", INTERNAL, "Enable prompt trimming for the selected Spec tool menu."),
"ODYSSEUS_VISION_MAX_SIDE": ("Upload and media limits", USER, "Maximum pixel side for image attachments sent directly to a vision model."),
"ODYSSEUS_VISION_MODELS": ("Model routing and providers", USER, "Comma-separated model-name fragments that explicitly support inline images."),
"ODYSSEUS_VISION_PROBE": ("Model routing and providers", USER, "Allow a cached vision-capability probe against local model endpoints."),
"ODYSSEUS_SPEC_DIRECT_MODE": (
"Agent loop and tool execution", INTERNAL,
"Controls how the spec router answers high-confidence direct-answer turns; "
"either tool_choice_none (default) or empty_menu.",
),
"ODYSSEUS_SPEC_DIRECT_NOTOOLS": (
"Agent loop and tool execution", INTERNAL,
"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_DIRECT_MIN_P": (
"Agent loop and tool execution", INTERNAL,
"Minimum confidence probability a spec-router direct-answer decision must "
"reach before the no-tools path is taken.",
),
"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT": (
"Deployment and first run", INTERNAL,
"Seconds the server waits for in-flight requests and background loops to "
+198 -7
View File
@@ -15,6 +15,7 @@ import json
import logging
import os
import re
import time
from typing import Optional
from src.memory import MemoryStoreUnreadable
@@ -78,11 +79,13 @@ EXTRACT_SYSTEM_PROMPT = (
"things the assistant said, one-off tasks, opinions on the current topic.\n\n"
"Rules:\n"
"- MAX 2 facts per conversation — only the most important\n"
"- Only extract facts the USER stated or clearly implied\n"
"- Only extract facts the USER explicitly stated about themselves\n"
"- Questions, requests to inspect records, and tool/assistant output are not evidence\n"
"- Each fact must be a single short sentence (under 15 words)\n"
"- If a fact is similar to something likely already known, skip it\n"
"- If nothing durable was revealed, return []\n\n"
"Return a JSON array of objects with 'text' and 'category' fields.\n"
"Return a JSON array of objects with 'text', 'category', and 'evidence' fields. "
"Evidence must be an exact quote from a user's own factual statement.\n"
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
"Return ONLY valid JSON, no markdown fences."
)
@@ -213,7 +216,10 @@ def _fallback_memory_candidates(messages) -> list[dict]:
if not text:
continue
m = re.search(r"\bmy name is\s+([A-Za-z][A-Za-z0-9 .'\-]{1,50})\b", text, re.I)
m = re.search(
r"\bmy name is\s+([A-Za-z][A-Za-z0-9 '\-]{0,49}?)"
r"(?=\s+(?:and|but|please)\b|[.!?;,\n]|$)", text, re.I,
)
if m:
name = _clean_memory_value(m.group(1), 50)
if name:
@@ -259,22 +265,183 @@ def _fallback_memory_candidates(messages) -> list[dict]:
return candidates[:2]
_USER_FACT_ASSERTION = re.compile(
r"\b(?:i(?:'m| am| live| work| use| have| prefer| like| love| hate| own| study| grew up)\b"
r"|my\s+[a-z][a-z -]{0,40}\s+(?:is|are|was|works|lives)\b"
r"|we\s+(?:are|live|work|have|own)\b"
r"|our\s+[a-z][a-z -]{0,40}\s+(?:is|are|was)\b)",
re.IGNORECASE,
)
def _has_user_fact_evidence(fact: dict, messages) -> bool:
"""Require an exact user assertion before persisting an LLM-inferred memory."""
evidence = fact.get("evidence")
if not isinstance(evidence, str):
return False
evidence = re.sub(r"\s+", " ", evidence).strip()
if len(evidence.split()) < 3 or not _USER_FACT_ASSERTION.match(evidence):
return False
for message in messages:
if _message_role(message) != "user":
continue
utterance = re.sub(r"\s+", " ", _message_text(message)).strip()
position = utterance.casefold().find(evidence.casefold())
if position < 0:
continue
# Judge the clause containing the quote, so a later question does not
# erase an earlier factual statement in the same user turn.
clause_start = max(
utterance.rfind(mark, 0, position) for mark in (".", "?", "!", ";")
) + 1
clause_end = min(
[end for mark in (".", "?", "!", ";")
if (end := utterance.find(mark, position)) >= 0] or [len(utterance)]
)
clause = utterance[clause_start:clause_end].strip()
# A question about one's records is not a statement of the answer.
if utterance[clause_end:clause_end + 1] == "?" or re.match(
r"^(?:who|what|where|when|why|how|do|does|did|can|could|would|"
r"show|list|find|search|tell|summarize|open|read)\b",
clause, re.IGNORECASE,
):
continue
return True
return False
_TOKEN_RE = re.compile(r"[a-z0-9]+")
# Function words carry no distinguishing content. Dropping them before
# comparing two facts keeps punctuation and harmless rephrasing from defeating
# the duplicate check.
_STOPWORDS = frozenset(
"a an the and or but if then of to in on at for with by from as is are was "
"were be been being do does did has have had it its this that these those "
"there here user users someone somebody they them their he she his her him i "
"we you your my our me us who whom whose what when where why how also "
"currently now still about into over under again more most very can could "
"may might must will would should".split()
)
# Words that frame a name/username statement; excluded when reading the value
# so the actual name tokens can be compared.
_NAME_TEMPLATE_WORDS = frozenset(
"name names named called call appears appear seems seem the user users his "
"her their it is are was were be been being username usernames login "
"account handle".split()
)
_NAME_KEYWORDS = frozenset({"name", "named", "called"})
_USERNAME_KEYWORDS = frozenset({"username", "login"})
def _content_tokens(text: str) -> set:
"""Lowercased content tokens with punctuation and stopwords removed."""
toks = _TOKEN_RE.findall((text or "").lower())
return {t for t in toks if t not in _STOPWORDS and len(t) >= 2}
def _name_slot(text: str):
"""Return (slot, value_tokens) when text states a name/username, else None.
Tokens are scanned so "username" is never mistaken for "name", and the
first keyword decides the slot (a sentence about a server *named* X that
also mentions a *username* Y is a 'name' fact, not a username fact)."""
words = _TOKEN_RE.findall((text or "").lower())
slot = None
for w in words:
if w in _NAME_KEYWORDS:
slot = "name"
break
if w in _USERNAME_KEYWORDS:
slot = "username"
break
if not slot:
return None
value = {
w for w in words
if w not in _NAME_TEMPLATE_WORDS and w not in _STOPWORDS and len(w) >= 2
}
return (slot, value) if value else None
def _name_slot_tokens(text: str):
slot = _name_slot(text)
return slot[1] if slot else None
def _name_vals_close(a: set, b: set) -> bool:
"""True when two name values state the same name: identical, or one is a
strict refinement of the other where the larger value is a clean name
(<=4 alphabetic tokens). Guards against merging a username into a fact
like 'a server named Odysseus ... with username pewds'."""
if a == b:
return True
if not a or not b:
return False
bigger = a if len(a) >= len(b) else b
clean = len(bigger) <= 4 and all(t.isalpha() for t in bigger)
return clean and (a <= b or b <= a)
def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) -> bool:
"""Check if new_text is too similar to any existing memory (Jaccard similarity)."""
new_tokens = set(new_text.lower().split())
"""Check if new_text states the same fact as an existing memory.
Three tests, cheapest first: symmetric Jaccard over content tokens,
containment (one fact is a strict refinement of the other), and a
name/username-slot match. The naive whole-string comparison this replaces
kept punctuation (so "Alex." != "alex") and missed short-vs-full names
("Felix" vs "FELIX ARVID ULF KJELLBERG" scored 0.43).
"""
new_tokens = _content_tokens(new_text)
if not new_tokens:
return False
new_slot = _name_slot(new_text)
for entry in _memory_dicts(existing):
old_tokens = set(entry.get("text", "").lower().split())
old_text = entry.get("text", "")
old_tokens = _content_tokens(old_text)
if not old_tokens:
continue
intersection = new_tokens & old_tokens
if not intersection:
continue
union = new_tokens | old_tokens
if len(intersection) / len(union) >= threshold:
return True
# Containment: one fact is a strict refinement of the other. Guard on
# the shorter side so two-token facts ("Likes Python") are not merged
# into longer sentences that merely start the same way.
if len(intersection) >= 3 and len(intersection) / min(len(new_tokens), len(old_tokens)) >= 0.85:
return True
# Name slot: both state the same kind of name and the values match
# (short vs full name, or the same name behind different phrasing).
old_slot = _name_slot(old_text)
if new_slot and old_slot and new_slot[0] == old_slot[0]:
if _name_vals_close(new_slot[1], old_slot[1]):
return True
return False
def _name_enrichment_target(new_text: str, existing: list):
"""Return an existing entry whose name value is a strict subset of the
new fact's, so the fuller name replaces it in place; else None."""
ns = _name_slot(new_text)
if not ns:
return None
slot, new_val = ns
best = None
for entry in _memory_dicts(existing):
old_slot = _name_slot(entry.get("text", ""))
if not old_slot or old_slot[0] != slot:
continue
old_val = old_slot[1]
if old_val < new_val and _name_vals_close(old_val, new_val) and len(new_val) <= 4 and all(t.isalpha() for t in new_val):
if best is None or len(entry.get("text", "")) < len(best.get("text", "")):
best = entry
return best
def _parse_extraction_json(raw: str) -> list:
"""Parse the extraction LLM's reply into a list of facts, tolerating
reasoning-model noise.
@@ -420,8 +587,13 @@ async def extract_and_store(
if not isinstance(facts, list):
facts = []
# The extraction model sees assistant/tool text and can mistake a
# failed lookup or a one-off request for a durable personal fact.
# Only its claims with a quoted user assertion may enter memory.
facts = [fact for fact in facts if isinstance(fact, dict)
and _has_user_fact_evidence(fact, stripped_recent)]
if fallback_facts:
facts = list(facts) + fallback_facts
facts.extend(fallback_facts)
if not facts:
logger.info("Auto memory extraction ran: 0 candidates")
@@ -483,6 +655,25 @@ async def extract_and_store(
# 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
# 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)
if _enrich is not None:
_enrich["text"] = fact_text
_enrich["timestamp"] = int(time.time())
if category:
_enrich["category"] = category
if memory_vector and memory_vector.healthy:
try:
memory_vector.add(_enrich["id"], fact_text)
except Exception as e:
logger.warning(f"Memory vector update failed for {_enrich['id']}: {e}")
added += 1
continue
if memory_manager.find_duplicates(fact_text, user_existing):
continue
# Fuzzy text similarity check (catches rephrased duplicates when vector index is unavailable)
+4 -2
View File
@@ -240,11 +240,13 @@ def _artifact_match_is_callable(instruction: str, match: re.Match[str], path: st
def _artifact_match_is_email_host(instruction: str, match: re.Match[str]) -> bool:
"""Reject the domain portion of an email address as an output path."""
"""Reject either dotted portion of an email address as an output path."""
start = match.start("path")
prefix = instruction[max(0, start - 80):start]
return bool(re.search(r"[A-Za-z0-9_.+-]+@$", prefix))
return instruction[match.end("path"):].startswith("@") or bool(
re.search(r"[A-Za-z0-9_.+-]+@$", prefix)
)
def _workspace_path_identity(value: str) -> str:
+343 -9
View File
@@ -162,7 +162,7 @@ def _resolved_tool_call_id(
logger = logging.getLogger(__name__)
_MODEL_TOOL_SURFACES = {"none", "compact", "full"}
_MODEL_TOOL_SURFACES = {"none", "compact", "full", "spec"}
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
_NO_THINKING_COMPACT_DOMAINS = {
"email",
@@ -8472,7 +8472,7 @@ def _agent_route_tool_mode(
return is_api_model, is_ollama_native, ollama_openai_compat
def _configured_model_tool_surface(
def _configured_model_tool_surface_raw(
endpoint_url: str,
model: str,
owner: Optional[str] = None,
@@ -8561,6 +8561,19 @@ _ADMIN_KEYWORDS = [
"note", "notes", "todo", "todos", "reminder", "reminders",
]
def _configured_model_tool_surface(
endpoint_url: str,
model: str,
owner: Optional[str] = None,
headers: Optional[Dict] = None,
endpoint_id: Optional[str] = None,
) -> str:
"""Tool surface as the harness sees it: Ajax Spec runs the compact runtime
(same dispatch, prompts, completions); only Spec hooks read the raw mode."""
value = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers, endpoint_id=endpoint_id)
return "compact" if value == "spec" else value
def _detect_admin_intent(messages: List[Dict]) -> bool:
"""Check if the last user message suggests admin/management tool usage."""
for msg in reversed(messages):
@@ -13565,6 +13578,20 @@ def _ody_qwen_terminal_tool_summary(tool_event: dict[str, Any], user_text: str =
"""Return a deterministic user-facing answer for tools we can render safely."""
tool_name = _resolved_tool_event_name(tool_event)
output = str(tool_event.get("output") or "")
if tool_name in {
"mcp__email__list_email_accounts", "mcp__email__list_emails",
"mcp__email__search_emails", "mcp__email__read_email",
} and output.lstrip().startswith("{"):
# The external email bridge wraps stdout in a JSON result. Render its
# successful read payload, never the wrapper or a failed result.
try:
email_result = json.loads(output)
except (TypeError, ValueError):
email_result = None
if isinstance(email_result, dict) and isinstance(email_result.get("stdout"), str):
if email_result.get("exit_code") not in (None, 0) or email_result.get("error"):
return ""
output = email_result["stdout"]
command = str(tool_event.get("command") or "")
action = ""
try:
@@ -21019,9 +21046,142 @@ def _blocks_before_inference(turn_contract) -> bool:
)
def _spec_unavailable_reply(unavailable) -> str:
blocked = set(unavailable or ())
if blocked & (set(WEB_TOOL_NAMES) | {"private_browser", "youtube_tool"}):
return ("Web or browser access is unavailable for this chat. Enable Web search "
"in the chat controls, if available, and ask me to check again. "
"I have not opened the page or verified its contents.")
return ("The tool needed for this request is unavailable in this chat. "
"Enable the required capability, if available, and try again. "
"I have not performed the requested lookup or action.")
from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_authority, with_request_authority
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
router's picks (plus ask_user/update_plan), or the contract unchanged.
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.
"""
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
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 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
except Exception as exc:
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
return turn_contract
@with_request_authority
@with_turn_contract
@with_teacher_takeover
@@ -21099,6 +21259,36 @@ async def stream_agent_loop(
set(tool_policy.all_disabled_names()) if tool_policy else set()
))
# Ajax Spec driver: the Spec router picks every step (src/spec_driver.py).
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
return
turn_contract = _spec_router_contract(
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=tool_policy)
if (turn_contract is not None
and getattr(turn_contract, "routing_experiment", "") == "spec-policy-denied"):
# A selected but disallowed tool is a policy outcome, not an invitation
# to generate an ungrounded answer with an empty tool menu.
reply = _spec_unavailable_reply(turn_contract.unavailable)
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
yield 'data: ' + json.dumps({"delta": reply}) + '\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(
@@ -21124,9 +21314,26 @@ 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 _blocks_before_inference(turn_contract):
if not _spec_turn and _blocks_before_inference(turn_contract):
unavailable = ", ".join(sorted(turn_contract.unavailable))
clarification = (
"I can’t perform this request with the currently permitted tools "
@@ -24126,6 +24333,15 @@ async def stream_agent_loop(
headers=candidate_headers,
endpoint_id=(route_descriptor or {}).get("endpoint_id"),
)
# "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(
candidate_url, candidate_model, owner, headers=candidate_headers,
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
except Exception:
_spec_surface = False
_spec_offered = None
textual_tools = (
force_textual_tool_transport
or force_textual_tools
@@ -24159,6 +24375,57 @@ async def stream_agent_loop(
_last_user,
_intent_domains,
)
if _spec_surface:
from src.spec_router import spec_route_tools as _spec_route_tools
if turn_contract is not None and getattr(turn_contract, "routing_experiment", "") == "spec-router":
_spec_added, _spec_decided = set(turn_contract.offered) - {"ask_user", "update_plan"}, True
else:
_spec_added, _spec_decided = _spec_route_tools(
_last_user,
FUNCTION_TOOL_SCHEMAS,
disabled_tools,
compacted_source,
return_decision=True,
)
_spec_added = _spec_added - set(disabled_tools)
# The router catalog uses the legacy native email names; the
# live tools are the MCP ones, and the legacy alias schemas are
# dropped when the MCP server is connected. Map picks over.
try:
from src.tool_utils import get_mcp_manager as _spec_mcp_mgr
_mgr = _spec_mcp_mgr()
_mcp_names = {
(schema.get("function") or {}).get("name")
for schema in (_mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else [])
}
except Exception:
_mcp_names = set()
_spec_added = {
(f"mcp__email__{name}" if f"mcp__email__{name}" in _mcp_names else name)
for name in _spec_added
}
_spec_mode = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower()
_compact_before = set(route_tools)
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)
if _spec_strict:
route_tools = _spec_strict
_spec_offered = frozenset(route_tools)
else:
logger.info("[spec-harness] strict set empty after disabled-tool filter; keeping compact set")
route_tools = set(route_tools) | _spec_added
else:
route_tools = set(route_tools) | _spec_added
logger.info("[spec-debug] disabled_tools (%d): %s | policy block_all=%s disable_mcp=%s",
len(set(disabled_tools)), sorted(set(disabled_tools))[:80],
getattr(tool_policy, "block_all_tool_calls", None), getattr(tool_policy, "disable_mcp", None))
logger.info(
"[spec-harness] mode=%s decided=%s compact=%s final=%s",
_spec_mode, _spec_decided, sorted(_compact_before), sorted(route_tools),
)
# The compact router only receives user text, while native task
# inputs may be declared out-of-band by the runner. Reapply that
# concrete input contract after compaction so an implicit video
@@ -24199,7 +24466,7 @@ async def stream_agent_loop(
_irrelevant_local_media_web_tools.add("private_browser")
route_tools.difference_update(_irrelevant_local_media_web_tools)
if turn_contract is not None and tool_surface != "full":
route_tools = set(turn_contract.offered)
route_tools = set(_spec_offered) if _spec_offered else set(turn_contract.offered)
if prompt_compact:
route_tools.update(
_blocked_network_recovery_tools(tool_events)
@@ -24299,7 +24566,7 @@ async def stream_agent_loop(
disabled_tools,
needs_admin=_needs_admin,
relevant_tools=prompt_route_tools,
preserve_conversation=turn_contract is not None,
preserve_conversation=(turn_contract is not None) or _spec_surface,
mcp_disabled_map=_mcp_disabled_map,
compact=prompt_compact,
owner=owner,
@@ -24511,6 +24778,13 @@ async def stream_agent_loop(
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
if guide_only:
_prepend_agent_directive(route_messages, GUIDE_ONLY_DIRECTIVE)
if _spec_offered and os.getenv("ODYSSEUS_SPEC_TRIM_PROMPT", "1") != "0":
from src.spec_router import trim_system_prompt as _spec_trim
route_messages = _spec_trim(route_messages, _spec_offered)
from src.spec_router import drop_skills_index as _spec_drop_skills
route_messages = _spec_drop_skills(route_messages, _spec_offered)
return {
"messages": route_messages,
"mcp_schemas": route_mcp_schemas,
@@ -24519,6 +24793,8 @@ async def stream_agent_loop(
"is_ollama_native": is_native_ollama,
"ollama_openai_compat": is_ollama_compat,
"tool_surface": tool_surface,
"spec_offered": _spec_offered,
"spec_surface": _spec_surface,
"ody_qwen_finetune_model": is_ody,
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
"ody_doc_finetune_mode": doc_mode,
@@ -25076,6 +25352,24 @@ async def stream_agent_loop(
if (schema.get("function", {}).get("name") or schema.get("name"))
not in disabled_tools
]
_spec_names = route_state.get("spec_offered")
if _spec_names:
# Ajax Spec: the router, not the turn contract, decides which
# tools are injected for this turn.
contract_schemas = [
schema for schema in contract_schemas
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":
@@ -25136,7 +25430,7 @@ async def stream_agent_loop(
if schema.get("function", {}).get("name") not in disabled_tools
and schema.get("name") not in disabled_tools
]
if _pure_web_turn and tool_surface != "full":
if _pure_web_turn and tool_surface != "full" and not (route_state.get("spec_surface") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"):
allowed = _web_only_route_tools(_last_user, disabled_tools)
schemas = [
schema for schema in schemas
@@ -25997,6 +26291,8 @@ async def stream_agent_loop(
"is_ollama_native": _is_ollama_native,
"ollama_openai_compat": _ollama_openai_compat,
"tool_surface": _route_state.get("tool_surface", ""),
"spec_offered": _route_state.get("spec_offered"),
"spec_surface": _route_state.get("spec_surface"),
"ody_qwen_finetune_model": _ody_qwen_finetune_model,
"qwen38_tool_router": _qwen38_tool_router,
"ody_doc_finetune_mode": _ody_doc_finetune_mode,
@@ -26012,10 +26308,19 @@ async def stream_agent_loop(
_active_route_state["request_messages"] = _initial_route_request_messages
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
if turn_contract is not None:
_relevant_tools = set(turn_contract.offered)
_relevant_tools = (
set(_route_state.get("spec_offered"))
if _route_state.get("spec_offered")
else set(turn_contract.offered)
)
agent_stream_timeout = int(get_setting("agent_stream_timeout_seconds", 300) or 300)
_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])
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'}")
_routing_intentional_exclusions = set(disabled_tools)
if (
@@ -27605,7 +27910,11 @@ async def stream_agent_loop(
),
active_document=active_document,
last_user=_last_user,
offered_tool_names=(set(turn_contract.offered) if turn_contract is not None else set(_tool_names_sent)),
offered_tool_names=(
set(turn_contract.offered)
if turn_contract is not None and not _active_route_state.get("spec_offered")
else set(_tool_names_sent)
),
recover_unoffered_tool_names=_recover_unoffered_local_tools,
passthrough_tool_names={
schema["function"]["name"]
@@ -32169,6 +32478,29 @@ async def stream_agent_loop(
budget_hit = False
local_network_budget_hit = False
local_inspection_budget_hit = False
if _active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and tool_blocks and not used_native and _is_api_model:
# Ajax Spec with a native-API model: a round without a native call
# carries only harness-synthesized recovery blocks. Drop them all.
logger.info("[spec-harness] dropped %d synthesized tool block(s) (no native call): %s",
len(tool_blocks), [b.tool_type for b in tool_blocks])
tool_blocks = []; converted_calls = []
if (
_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and used_native and tool_blocks
and len(converted_calls) == len(tool_blocks)
):
# Ajax Spec: only the model's own native calls run; harness-
# synthesized recovery blocks (no native call) are dropped. When
# converted_calls was reset (singleton collapse) keep every block.
_spec_keep = [
i for i in range(len(tool_blocks))
if i < len(converted_calls) and converted_calls[i] is not None
]
if len(_spec_keep) != len(tool_blocks):
logger.info("[spec-harness] dropped %d synthesized tool block(s): %s",
len(tool_blocks) - len(_spec_keep),
[tool_blocks[i].tool_type for i in range(len(tool_blocks)) if i not in _spec_keep])
tool_blocks = [tool_blocks[i] for i in _spec_keep]
converted_calls = [converted_calls[i] for i in _spec_keep]
for i, block in enumerate(tool_blocks):
native_call = converted_calls[i] if i < len(converted_calls) else None
tool_call_id = _resolved_tool_call_id(
@@ -33463,7 +33795,8 @@ async def stream_agent_loop(
),
}
if (
(_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
not (_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and block.tool_type == "private_browser")
and (_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
and not _artifact_creation_requested
and (block.tool_type == "web_search" or _web_execution_budget.searches)
and not _web_execution_budget.admit(
@@ -33589,6 +33922,7 @@ 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,
+12
View File
@@ -230,6 +230,18 @@ def classify(capture: DispatchCapture) -> dict[str, Any] | None:
if capture.backend is not None:
from src.agent_runtime.resources import ExternalResource
if isinstance(capture.backend.resource, ExternalResource):
# A read through an external backend is still a read. The tool
# capability registry is sealed by the dispatcher, and unknown or
# mixed-effect calls continue to receive an external effect claim.
if all(bound is None for bound in (capture.browser, capture.filesystem,
capture.process, capture.owned)):
from src.tool_capabilities import ToolEffect, capabilities_for_action
capabilities = capabilities_for_action(capture.backend.transport_tool,
capture.backend.exact_input)
reads = frozenset({ToolEffect.READ_PUBLIC, ToolEffect.READ_WORKSPACE,
ToolEffect.READ_PRIVATE, ToolEffect.BROKERED_NETWORK_READ})
if capabilities.known and capabilities.effects and capabilities.effects <= reads:
return None
external = True
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -2465,6 +2465,100 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
return str(e), False
async def _spec_triage_email_urgency(item: dict, *, triage_version: int) -> dict | None:
"""Classify one email's urgency with the trained jeff ``triage`` adapter.
Returns an email-urgency verdict (same shape as the LLM path) or None when
the per-job flag is off or the adapter is unavailable, so the caller falls
through to the LLM candidate chain. Only the ``urgency`` question is sent:
jeff scores each question in its own forward pass, and that is the question
this adapter was trained on.
"""
from src.jeff_decision import jeff_job_enabled, jeff_systemone, score_value
if not jeff_job_enabled("EMAIL_URGENCY"):
return None
message = f"Subject: {item.get('subject','')}\n\n{item.get('body','')}".strip()
if not message:
return None
answers = await jeff_systemone(
"triage",
{"channel": "email", "message": message[:8000]},
{"urgency": {
"type": "score",
"instructions": "How urgently does this need a response?",
"criteria": [
"No action needed",
"Can wait a few days",
"Handle within a day",
"Handle within hours",
"Handle immediately",
],
}},
)
if not answers:
return None
exp = score_value(answers.get("urgency"))
if exp is None:
return None
# Adapter's 0-4 urgency scale -> the app's 0-3 score.
score = max(0, min(3, int(round(exp))))
reason = "triage adapter"
# Same high-precision correction the prompt path applies: a person waiting
# in person is always urgent, whatever the model thinks.
blob = f"{item.get('headers','')}\n{item.get('subject','')}\n{item.get('body','')}".lower()
if re.search(r"\b(i'?m|i am|im|we'?re|we are)\s+outside\b", blob) or re.search(
r"\b(waiting outside|at the door|locked out|can'?t get in|cannot get in)\b", blob
):
if score < 3:
reason = "person is waiting outside"
score = 3
# Same bulk/marketing guard the prompt path applies: a newsletter or
# promotion is not urgent no matter how time-limited it sounds.
bulkish = bool(re.search(
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
blob,
))
# The adapter was trained on support tickets, so it over-rates promotions.
# Require a clear bulk header or several marketing terms before zeroing.
marketing_hits = sum(1 for t in (
"advertisement", "sponsored", "promo", "promotion", "sale", "discount",
"offer", "limited time", "deal", "coupon", "shop now", "buy now",
"sold out", "low tickets",
) if t in blob)
if bulkish or marketing_hits >= 2:
score = 0
reason = "bulk mail; no personal reply needed"
tags = []
if score >= 3:
tags.append("urgent")
elif score >= 2:
tags.append("reply-soon")
_from_raw = item.get("from", "") or ""
if "<" in _from_raw:
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
else:
_from_short = _from_raw
return {
"score": score,
"tags": tags[:4],
"spam": False,
"reason": reason,
"subject": (item.get("subject") or "")[:200],
"from": _from_short[:120],
"triage_version": triage_version,
"message_id": (item.get("message_id") or "").strip(),
"unread": bool(item.get("unread")),
"ts": time.time(),
}
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
"""Scan unread emails across all accounts, LLM-triage new ones, cache
per-UID verdicts, tag the inbox, and fire a reminder when a previously
@@ -2852,6 +2946,16 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
per_uid_scores[key] = verdict
saved_classifications += 1
continue
# ── Spec decision adapter (triage): a calibrated urgency score
# with no generated-text prompt. Enabled per-job via
# ODYSSEUS_JEFF_EMAIL_URGENCY; any failure returns None and the
# LLM candidate chain below still runs.
_tri = await _spec_triage_email_urgency(item, triage_version=TRIAGE_VERSION)
if _tri is not None:
cache.setdefault("uids", {})[item["uid"]] = _tri
per_uid_scores[key] = _tri
saved_classifications += 1
continue
# ── LLM-classify. JSON-only response; bullet-proof parse.
llm_attempts += 1
prompt = (
+90 -2
View File
@@ -81,6 +81,16 @@ def is_vision_model(model_name: str) -> bool:
drops the image entirely. See issue #124.
"""
m = (model_name or "").lower()
if not m:
return False
# Per-model override (comma-separated substrings), e.g. a local model whose
# name carries no vision/VL hint but answers images natively.
override = os.getenv("ODYSSEUS_VISION_MODELS", "")
if override:
for token in override.split(","):
token = token.strip().lower()
if token and token in m:
return True
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
return True
return bool(_VISION_VL_RE.search(m))
@@ -164,8 +174,10 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
"""Whether a model accepts images, using the endpoint's reported
capability when available (LM Studio) and falling back to name-based
detection otherwise."""
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:
try:
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
@@ -173,9 +185,85 @@ def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
advertised = None
if advertised is not None:
return advertised
try:
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
except Exception:
probed = None
if probed is not None:
return probed
return is_vision_model(model_name)
# 1x1 red PNG — the cheapest possible image probe payload.
_PROBE_IMAGE_DATA_URL = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
_VISION_PROBE_TTL = 3600.0
# (host, model) -> (bool | None, expiry); None = undecidable (auth/network).
_vision_probe_cache: dict = {}
def _vision_probe_enabled() -> bool:
return os.getenv("ODYSSEUS_VISION_PROBE", "1").strip().lower() not in ("0", "false", "off", "no")
def _chat_completions_url(endpoint_url: str) -> str:
base = (endpoint_url or "").rstrip("/")
if base.endswith("/chat/completions"):
return base
return base + "/chat/completions"
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
an image_url content part for `model_name`?
Returns True on a 200, False when the endpoint rejects the image (400/413/
415/422), and None when we cannot decide (remote host, auth required,
unreachable, or probing disabled) so the caller falls back to name rules.
"""
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
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
now = time.time()
cached = _vision_probe_cache.get(key)
if cached is not None and cached[1] > now:
return cached[0]
payload = {
"model": model_name,
"max_tokens": 1,
"stream": False,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Reply with one word."},
{"type": "image_url", "image_url": {"url": _PROBE_IMAGE_DATA_URL}},
],
}],
}
verdict: Optional[bool] = None
try:
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
if r.status_code == 200:
verdict = True
elif r.status_code in (400, 413, 415, 422):
body = (r.text or "").lower()
if any(tok in body for tok in ("image", "vision", "multimodal", "content type")):
verdict = False
else:
verdict = False
except Exception:
verdict = None
_vision_probe_cache[key] = (verdict, now + _VISION_PROBE_TTL)
if verdict is not None:
logger.info("vision probe %s@%s -> %s", model_name, parsed.hostname, verdict)
return verdict
def validate_message(message: str) -> str:
"""Validate message input."""
if not message:
+644 -14
View File
@@ -20,7 +20,11 @@ from pathlib import Path
import httpx
import jsonschema
from src.context_compactor import prune_multimodal_images, trim_for_context
from src.context_compactor import (
keep_recent_image_turns,
prune_multimodal_images,
trim_for_context,
)
from src.agent_runtime.runtime_selection import COMPACT_PREVIEW_MODE
from src import agent_runs
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
@@ -42,6 +46,7 @@ from src.turn_contract import (
_REQUEST_PREFIX,
calendar_retiming_request,
FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_request,
note_read_intent,
targets_bound_editor_request, inline_text_transformation, editor_request_instructions,
_bound_editor_requests_web_verification, scheduled_automation_request, creation_container_tool,
)
@@ -206,9 +211,17 @@ CONTRACT_REQUIRED_TOOLS = frozenset({
})
from src.turn_contract import CONTRACT_CORE_TOOLS
# Scoped NL photo-editor surface. These are offered only when the
# ``photo_editor`` capability family is active (editor Quick Edit popup or an
# image-edit chat turn); they drive the in-process, owner-scoped editor bridge
# and the existing AI image ops.
EDITOR_AGENT_TOOLS = frozenset({
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
})
PREVIEW_TOOLS = (
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
| EDITOR_AGENT_TOOLS
)
# Keep a small recovery-capable surface on every interactive compact agent
# turn. Routing still adds domain tools, while policy and action guards remain
@@ -1035,6 +1048,22 @@ def notes_terminal_response(raw, *, user_text='', max_items=20):
return _bounded_structured_list(summary, user_text=user_text)
def note_locator_followup(raw, *, user_text=''):
"""Require a body read before answering a note-content request."""
_, needs_body = note_read_intent(user_text)
if not needs_body or note_search_result_empty(raw):
return ''
listing = notes_terminal_response(raw, user_text=user_text, max_items=5)
match = re.search(r'\(#note-([A-Za-z0-9_-]+)\)', listing)
if not match:
return ''
return (
'The notes result contains titles only, not the body. '
f'Call manage_notes with action="view" and id="{match.group(1)}" now. '
'Use the returned body to answer the request; do not infer content from the title.'
)
def documents_terminal_response(raw, *, user_text='', max_items=8):
"""Render linked document rows under the user's explicit global cap."""
from src.agent_loop import _document_list_summary_from_tool_output
@@ -1368,17 +1397,26 @@ def contentless_final_response(content):
def action_promise_response(content):
"""Recognize a short promise to do work that contains no result yet."""
text = re.sub(r'\s+', ' ', str(content or '')).strip()
if not text or len(text.split()) > 48:
"""Recognize a short promise to do work that contains no result yet.
Fenced code blocks are stripped before the word count and the verb match,
so a promise followed by a code block (e.g. "I will browse Google Maps ..."
plus a requests/BeautifulSoup snippet and no tool call) still counts as an
action promise. The caller only reaches this when no tool call was made.
"""
without_code = re.sub(
r'\s+', ' ', re.sub(r'```.*?```', ' ', str(content or ''), flags=re.DOTALL)
).strip()
if not without_code or len(without_code.split()) > 48:
return False
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', text, re.I):
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', without_code, re.I):
return False
return bool(re.match(
r"^(?:okay[,.:]?\s*)?(?:let me|i(?:'ll| will)|next[,.:]?\s+i(?:'ll| will))\s+"
r"(?:now\s+)?(?:inspect|extract|read|check|review|analy[sz]e|verify|open|"
r"search|look|create|write|render|run|use|continue|finish|provide)\b",
text,
r"search|look|create|write|render|run|use|continue|finish|provide|"
r"browse|find|fetch|visit|navigate|go|get|scrape)\b",
without_code,
re.I,
))
@@ -1407,6 +1445,191 @@ def progressive_thinking_for_turn(model, offered_schemas, thinking_mode=None):
return uses_odysseus_progressive_thinking(model) and not bool(offered_schemas)
def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_user_text):
"""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
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."""
if (round_number == 1 and not existing_deliberate
and isinstance(decision, dict) and decision.get('think')):
return 'router_think'
return ''
def spec_direct_mode():
"""Which direct-answer behaviour to use when the gate is on.
'tool_choice_none' (default) keeps the tools list but forbids a tool call
for the round; 'empty_menu' sends no tools at all. Any other value falls
back to the default.
"""
mode = str(
os.getenv('ODYSSEUS_SPEC_DIRECT_MODE', 'tool_choice_none') or ''
).strip().lower()
return 'empty_menu' if mode == 'empty_menu' else 'tool_choice_none'
def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
"""Env-gated confidence for a spec 'answer_directly' turn.
Returns the answer_directly probability when the spec router just decided
to answer directly with enough confidence, else None. Default OFF, so the
caller behaves exactly as before unless ODYSSEUS_SPEC_DIRECT_NOTOOLS is set.
"""
if str(os.getenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '')).strip().lower() not in (
'1', 'true', 'yes', 'on'):
return None
try:
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
import sys as _sys
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]
if not isinstance(decision, dict):
return None
# Guard against a stale decision from a previous turn.
msg = str(user_message or '')
decided_msg = str(decision.get('message') or '')
if decided_msg and msg and decided_msg[:40] != msg[:40]:
return None
if decision.get('choice') != 'answer_directly':
return None
try:
p = float(decision.get('answer_directly') or 0.0)
except (TypeError, ValueError):
return None
try:
min_p = float(os.getenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.9') or 0.9)
except (TypeError, ValueError):
min_p = 0.9
return p if p >= min_p else None
except Exception:
logging.getLogger(__name__).warning(
'spec direct-answer decision failed; using rules only', exc_info=True)
return None
def spec_driver_active(endpoint_url, model, owner, headers):
"""True when ODYSSEUS_SPEC_MODE=driver and the turn uses the spec surface."""
if str(os.getenv('ODYSSEUS_SPEC_MODE', '') or '').strip().lower() != 'driver':
return False
try:
from src.agent_loop import _configured_model_tool_surface_raw
return _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers) == 'spec'
except Exception:
return False
def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
"""Per-round ODYSSEUS_SPEC_MODE=driver decision: the router drives tools.
Ask the router once for this round and return its TOP-1 choice at ANY
confidence: ``{'choice': 'tool'|'answer_directly'|'ask_user', 'p': float,
'tool_name': str|None}``. Returns None on any router error/timeout or an
unusable pick, so the caller keeps the normal menu for that round.
``schemas`` is the round's offered menu; the router only ever picks from
it, so a top-1 tool is always one the turn can actually execute (passing
every known tool here made the driver name tools outside the offered set,
which then looped on permission errors).
"""
try:
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
menu = list(schemas) if schemas else list(FUNCTION_TOOL_SCHEMAS)
picks, _decided = spec_router.spec_route_tools(
str(user_text or ''), menu,
set(disabled_tools or ()), list(conversation or []),
return_decision=True,
)
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
decision = decisions[-1] if decisions else {}
if not isinstance(decision, dict) or decision.get('error'):
return None
choice = decision.get('choice')
top = decision.get('top') or []
if choice == 'answer_directly':
return {'choice': 'answer_directly',
'p': float(decision.get('answer_directly') or 0.0),
'tool_name': None}
if choice == 'ask_user':
return {'choice': 'ask_user',
'p': float(decision.get('ask_user') or 0.0),
'tool_name': None}
# Driver mode takes the router's TOP-1 at ANY confidence, so read the
# ranked ``top`` (not the MIN_CONF-filtered ``picks``/``added``).
if top and isinstance(top[0], (list, tuple)) and top[0]:
name = str(top[0][0] or '')
p = None
if len(top[0]) >= 2:
try:
p = float(top[0][1])
except (TypeError, ValueError):
p = None
if name:
return {'choice': 'tool', 'p': p, 'tool_name': name}
if picks:
try:
name = str(next(iter(picks)))
except TypeError:
name = str(picks[0]) if len(picks) else ''
if name:
return {'choice': 'tool', 'p': None, 'tool_name': name}
return None
except Exception:
logging.getLogger(__name__).warning(
'spec driver decision failed; using the normal menu', exc_info=True)
return None
def tool_deliberation_reason(name, args):
"""Classify selected operations, never infer intent from tool availability."""
name = canonical(name)
if name in {'python', 'edit_file', 'apply_patch'}:
return 'code'
if name in {'edit_document', 'suggest_document', 'update_document'}:
return 'document_edit'
if name in {'draft_email', 'draft_email_reply', 'ai_draft_email_reply'}:
return 'email_draft'
action = str(args.get('action') or '').lower()
if name == 'manage_tasks' and action in {'create', 'add', 'update', 'edit'}:
return 'task_design'
if name == 'manage_skills' and action in {'add', 'edit', 'patch', 'publish', 'audit'}:
return 'skill_evaluation'
return ''
def visible_content_after_qwen_thinking(content):
"""Remove private Qwen reasoning when the server lacks a reasoning parser."""
@@ -1422,6 +1645,48 @@ def visible_content_after_qwen_thinking(content):
return text.strip()
def qwen_think_split_events(content, state):
"""Split a no-reasoning-parser thinking round into live deltas.
Some thinking servers (no --reasoning-parser) return the whole
``<think>…</think>`` trace as ordinary ``content``. Buffering that round
made the UI show nothing while the model thought and then dump the whole
answer at once. Stream the trace as ``{'delta': ..., 'thinking': True}``
events (the same shape a reasoning-parser server emits) and the text after
``</think>`` as normal answer deltas. ``state`` is a mutable per-round dict
carrying the emission bookkeeping; the stored answer is unchanged (see
``visible_content_after_qwen_thinking``).
"""
events = []
text = str(content or '')
if not text:
return events
lowered = text.casefold()
lead = len('<think>') if lowered.startswith('<think>') else 0
close = lowered.find('</think>')
if close < 0:
# The opening <think> is injected by the chat template, so a progressive
# trace can arrive with no marker until </think>. Emit the buffered text
# as thinking - holding back a possible partial close tag - so the trace
# streams live instead of dumping in one block at the end.
think_region = text[lead:max(lead, len(text) - len('</think>'))]
answer_region = ''
else:
think_region = text[lead:close]
answer_region = text[close + len('</think>'):]
emitted_think = int(state.get('think_emitted', 0) or 0)
if len(think_region) > emitted_think:
events.append({'delta': think_region[emitted_think:], 'thinking': True})
state['think_emitted'] = len(think_region)
if close >= 0:
state['closed'] = True
emitted_answer = int(state.get('answer_emitted', 0) or 0)
if len(answer_region) > emitted_answer:
events.append({'delta': answer_region[emitted_answer:]})
state['answer_emitted'] = len(answer_region)
return events
def prior_short_answer_for_no_tool_summary(user_text, history):
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
text = str(user_text or '')
@@ -2714,6 +2979,18 @@ def normalize_preview_entity_anchor_args(name, args):
value = args.get(field)
if isinstance(value, str) and value.startswith(prefix):
args[field] = value[len(prefix):]
if canonical(name) == 'manage_notes':
# Some models copy the rendered note href without its leading '#'.
# Decode only a complete UUID; partial selectors retain the existing
# owner-bound resolution policy instead of gaining a new alias.
value = args.get('note_id')
if isinstance(value, str):
match = re.fullmatch(
r'note-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
value, re.I,
)
if match:
args['note_id'] = match.group(1)
return args
@@ -2754,6 +3031,9 @@ def normalize_preview_function_args(name, args, *, user_text=''):
args.pop('filter', None)
if canonical(name) == 'manage_notes':
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
if action == 'read' and (args.get('id') or args.get('note_id')):
args['action'] = 'view'
action = 'view'
if action == 'list':
request = str(user_text or '').casefold()
# The notes backend interprets title/query/content on ``list`` as
@@ -2770,6 +3050,10 @@ def normalize_preview_function_args(name, args, *, user_text=''):
args.pop('archived', None)
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
args.pop('note_type', None)
query, _ = note_read_intent(user_text)
if query:
args['action'] = 'search'
args['query'] = query
if canonical(name) == 'manage_skills':
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
if action in {'update', 'change', 'revise'}:
@@ -2897,8 +3181,9 @@ def scope_preview_contract(preview_contract, routed_contract, active_capabilitie
requested_family_tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in active))
available_requested = routed_canonical & offered_canonical & requested_family_tools
routed_canonical.update(canonical(name) for name in extra_tools)
if 'image_generation' not in active:
# A past image request must not keep generation warm on email/doc turns.
if 'image_generation' not in active and 'photo_editor' not in active:
# A past image request must not keep generation warm on email/doc turns,
# unless the photo_editor family (which bundles generate_image) is active.
routed_canonical.discard('generate_image')
missing_active = {
f'capability:{family}' for family in active
@@ -3230,7 +3515,11 @@ def _successful_email_identifiers(history):
for message in history or ():
if not isinstance(message, dict):
continue
if message.get('role') == 'system' and 'Message UID:' in str(message.get('content') or ''):
active_reader = (
message.get('_agent_injected') == 'context'
and (message.get('metadata') or {}).get('source') == 'active email reader'
)
if (message.get('role') == 'system' or active_reader) and 'Message UID:' in str(message.get('content') or ''):
extracted = _email_identifiers_from_text(message.get('content'))
known['uid'].update(extracted['uid'])
known['message_id'].update(extracted['message_id'])
@@ -3518,6 +3807,20 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
runtime_tools = PREVIEW_TOOLS | (
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
) | ({canonical(tool) for tool in external_runtime_tools} if allow_native_workspace else set())
# Spec turns are driven by the router: the Spec contract (its offered set)
# IS the model runtime, so a Spec pick is runnable. Keyed off the same
# marker the spec hook sets (`routing_experiment == 'spec-router'`), never
# a new flag, and never applied to compact/native turns.
try:
from src.turn_contract import active_turn_contract as _atc
_spec_contract = _atc()
if _spec_contract is not None and getattr(
_spec_contract, 'routing_experiment', '') == 'spec-router':
runtime_tools = runtime_tools | {
canonical(tool) for tool in getattr(_spec_contract, 'offered', ()) or ()
}
except Exception:
pass
if bare not in runtime_tools:
return decision(False, 'tool_not_in_model_runtime')
if bare in {canonical(tool) for tool in external_runtime_tools}:
@@ -3619,6 +3922,18 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
# editor is offered only for an explicit image-editing turn.
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
if (
(contract_required or family in turn_authorized_families)
and bare in {'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image'}
):
# Scoped photo_editor agent tools. They are never in the general tool
# list; the immutable turn contract only selects them for an explicit
# editor request (Quick Edit popup / image-edit turn), so their UI and
# brokered-network effects are covered by the contract seal.
blocked_effects.discard(ToolEffect.UI_SIDE_EFFECT)
allowed_effects.add(ToolEffect.UI_SIDE_EFFECT)
blocked_effects.discard(ToolEffect.NETWORK_EGRESS)
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
if (
contract_required
and bare in {'download_model', 'serve_preset', 'stop_served_model'}
@@ -3849,6 +4164,29 @@ def conversation(history_session, messages, *, owner=None, diagnostics=None):
from src.turn_contract import result_reference_followup
source_context = (recent_source_reference_context(groups[-2])
if len(groups) > 1 and result_reference_followup(current_text) else None)
# Tool output can dwarf the dialogue (especially a web search). Keep the
# visible exchange before enforcing the history cap, so one search-heavy
# turn cannot erase the subject of the next follow-up.
for index, group in enumerate(groups[:-1]):
if len(json.dumps(group)) <= 5000 or len(group) < 2:
continue
# Keep non-web tool evidence intact: a note/list follow-up may need
# exact result identifiers, and shortening it would be misleading.
if not any(
canonical((call.get('function') or {}).get('name', '')) in {'web_search', 'web_fetch'}
for message in group if message.get('role') == 'assistant'
for call in message.get('tool_calls') or ()
):
continue
answer = next((message.get('content') for message in reversed(group)
if message.get('role') == 'assistant'
and str(message.get('content') or '').strip()), None)
if answer is None:
continue
answer_text = str(answer)
if len(answer_text) > 3500:
answer_text = answer_text[:3500] + '\n[Earlier answer shortened for context.]'
groups[index] = [group[0], {'role': 'assistant', 'content': answer_text}]
# Drop whole turns only, never orphan tool results from their native calls.
groups = groups[-8:]
while len(groups) > 1 and len(json.dumps(groups)) > 22000:
@@ -4207,6 +4545,26 @@ def denied_response():
return 'I can’t perform that operation in this preview. No changes were made.'
WORKSPACE_REQUIRED_REFUSAL = (
'Shell and Python commands only run inside a workspace. '
'Open the code workspace (or set a working directory) and try again.'
)
def workspace_process_denial(executions) -> bool:
"""True when a blocked call was refused because process execution needs a
sealed workspace (a plain chat turn carries none)."""
for entry in executions or ():
blob = ' '.join(
str(entry.get(key) or '') for key in ('error', 'output', 'failure_kind')
)
# Resource identity also covers missing note and document selectors.
# Those denials must not turn into instructions to open a code workspace.
if 'sealed workspace' in blob.casefold():
return True
return False
def execution_has_write_effect(tool_name, content, capability, *, native_workspace_enabled):
"""Recognize successful native code that visibly mutates the workspace."""
@@ -4226,7 +4584,7 @@ _MUTATION_REQUEST = re.compile(
r'\b(?:add|creat(?:e|ing)?|make|writ(?:e|ing)|sav(?:e|ing)|updat(?:e|ing)|edit(?:ing)?|chang(?:e|ing)|'
r'set|schedul(?:e|ing)|reschedul(?:e|ing)|paus(?:e|ing)|resum(?:e|ing)|toggle|mark|'
r'pin|archive|delet(?:e|ing)|remov(?:e|ing)|send|reply|draft|publish|run|launch|serve|start|stop|'
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|feedback|reserve|block)\b|'
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|reserve|block)\b|'
r'\bgo\s+deeper\b',
re.I,
)
@@ -4262,7 +4620,7 @@ def mutation_action_requested(user_text):
actionable = re.sub(
r"\b(?:do\s+not|don't|never|without)\s+"
r"(?:(?:chang(?:e|ing)|modif(?:y|ying)|edit(?:ing)?|delet(?:e|ing)|"
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing))\b)"
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing)|schedul(?:e|ing))\b)"
r"[^.;\n]{0,120}?(?=(?:[.;\n]|\bbut\b|\binstead\b|$))",
'', text, flags=re.I,
)
@@ -5622,6 +5980,38 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'multimodal_image_count': image_context_count,
'attachment_reference_count': attachment_refs,
'image_rehydration': conversation_diagnostics.get('image_rehydration')})
_photo_editor_active = 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ())
_photo_editor_open = bool(
((client_runtime_context or {}).get('web_ui_state') or {}).get('photo_editor_open')
)
if _photo_editor_active and not _photo_editor_open:
_editor_guidance = (
"This image isn't open in the editor yet, so I can't apply edits to the "
"canvas from chat. Open the attached image in the editor (Quick Edit) and "
"send this request there \u2014 I'll apply it to the canvas."
)
history.append({'role': 'assistant', 'content': _editor_guidance})
yield event({'delta': _editor_guidance})
_action_prompt = str(direct_user_text or '')
for _annotation in ('\n\n[Image:', '\n[Image:', '\n\n[Attached', '\n[No vision model'):
_cut = _action_prompt.find(_annotation)
if _cut != -1:
_action_prompt = _action_prompt[:_cut]
_action_prompt = _action_prompt.strip() or str(direct_user_text or '').strip()
yield event({
'type': 'final_response',
'content': _editor_guidance,
'photo_editor_action': {
'kind': 'open_and_apply',
'prompt': _action_prompt,
},
})
yield event({'type': 'metrics', 'data': {
'model': model, 'rounds': 0, 'round_texts': [_editor_guidance],
'tool_events': [], 'tool_calls': 0, 'agent_rounds': 0,
}})
yield 'data: [DONE]\n\n'
return
try:
async with httpx.AsyncClient(
timeout=preview_http_timeout(
@@ -5703,6 +6093,35 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
usage_in += intent_accounting.get('input_tokens', 0)
usage_out += intent_accounting.get('output_tokens', 0)
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(
endpoint_url, model, owner, headers, direct_user_text)
if _spec_route_think is not None:
logging.getLogger(__name__).info(
'spec think router: think=%s p_think=%s ms=%s',
_spec_route_think.get('think'),
_spec_route_think.get('p_think'),
_spec_route_think.get('ms'))
# Optional env-gated direct-answer override (spec surface only):
# when the spec router chose answer_directly with high confidence,
# 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)
# 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 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.
_driver_base_contract = turn_contract
# Per-round Spec pick (driver mode): 'answer_directly' | 'ask_user'
# | 'tool' | None (router fallback). Set inside the round loop.
_driver_round_kind = None
_driver_round_pick = None
# One extra iteration is available only when a provider emits raw
# tool markup during the normal final no-tools round. Ordinary
# turns still obey ``round_limit`` exactly.
@@ -5839,7 +6258,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'completion_reserve_seconds': artifact_completion_reserve_seconds,
})
request_messages = provider_request_messages(
prune_multimodal_images(history, max_images=3)
prune_multimodal_images(
keep_recent_image_turns(history, keep=2), max_images=3
)
)
if prior_summary_answer or force_no_tools_next_round or reserve_final_synthesis:
round_offered = []
@@ -6036,6 +6457,94 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
break
request['tools'] = source_tools
request['tool_choice'] = 'required'
# ODYSSEUS_SPEC_MODE=driver: the router is the tool driver.
# Ask it for this round's single next action (any confidence);
# the 9B then only writes the arguments (a named tool_choice) or
# the answer (tools key omitted; vLLM rejects an empty array).
# Router error/timeout keeps the normal menu for this round.
if _spec_driver_on and round_offered:
# The router chooses from the FULL Ajax Spec surface (~all
# built-ins minus disabled), not the compact round menu, so
# a Spec pick is never silently out of reach. The 9B then
# only writes the arguments (a named tool_choice) or the
# answer (tools key omitted; vLLM rejects an empty array).
# Router error/timeout keeps the normal menu this round.
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS as _SPEC_SURFACE
_drv = spec_driver_pick(history, disabled_tools, direct_user_text)
if _drv is not None:
_drv_choice = _drv.get('choice')
if _drv_choice == 'answer_directly':
_driver_round_kind = 'answer_directly'
request.pop('tools', None)
request.pop('tool_choice', None)
elif _drv_choice == 'ask_user':
_driver_round_kind = 'ask_user'
_ask = [s for s in _SPEC_SURFACE
if (s.get('function') or {}).get('name') == 'ask_user']
if _ask:
request['tools'] = [_ask[0]]
request['tool_choice'] = {
'type': 'function',
'function': {'name': 'ask_user'},
}
if _ask[0] not in round_offered:
round_offered = list(round_offered) + [_ask[0]]
elif _drv_choice == 'tool' and _drv.get('tool_name'):
_name = _drv['tool_name']
_sel = [s for s in list(round_offered) + list(_SPEC_SURFACE)
if (s.get('function') or {}).get('name') == _name]
if _sel:
_driver_round_kind = 'tool'
_driver_round_pick = _name
request['tools'] = [_sel[0]]
request['tool_choice'] = {
'type': 'function',
'function': {'name': _name},
}
# The per-call validator looks the schema up in
# `round_offered` (not `request['tools']`), so the
# Spec pick must be present there too.
round_offered = [_sel[0]]
# Driver mode narrows the whole contract to the
# Spec pick for this round: the router is the
# authority, and the executor must be able to run
# the picked tool. Build a valid contract (the
# TurnContract invariant requires
# required <= offered <= executable and
# schema_json names == offered).
try:
from src.turn_contract import (
_ACTIVE_CONTRACT as _TC,
canonical_tool as _ct,
)
_pick = _ct(_name)
turn_contract = replace(
_driver_base_contract,
required=frozenset(),
offered=frozenset({_pick}),
executable=frozenset(
set(_driver_base_contract.executable) | {_pick}),
schema_json=(json.dumps(_sel[0]),),
required_read_operation=None,
)
_TC.set(turn_contract)
except Exception:
turn_contract = _driver_base_contract
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s contract admit failed; '
'normal contract kept', round_number, _name)
else:
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s not executable; normal menu this round',
round_number, _name)
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s p=%s', round_number,
(_drv.get('tool_name') if _drv_choice == 'tool'
else _drv_choice),
_drv.get('p'))
else:
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=fallback p=None', round_number)
pending, content, round_reasoning = {}, '', ''
document_preview_index = None
document_preview_content = ''
@@ -6056,6 +6565,49 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
request = search_tool_choice_request(request)
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
request = provider_compatible_tool_choice_request(request, model)
# Log the tool surface actually sent to the provider: the
# metrics `tool_schema_names` reports the pre-narrowing
# `offered` set, which hid the fact that a forced choice had
# shrunk the provider tools array to one schema.
logging.getLogger(__name__).info(
"clean preview round %s sending %d tool(s): %s",
round_number,
len(request.get('tools') or []),
[
(schema.get('function') or {}).get('name')
for schema in (request.get('tools') or [])
][:40],
)
if round_number == 1 and _spec_direct_p is not None:
# The router decided to answer directly with high
# confidence.
if _spec_direct_empty_menu:
# Option B: an empty menu — send no tools at all, so
# nothing can be executed and no markup is recovered.
request['tools'] = []
request.pop('tool_choice', None)
tool_call_requested = False
# Keep the thinking router's decision; never let the
# no-tools progressive rule turn thinking on when the
# router said off.
if not (_spec_route_think or {}).get('think'):
ctk = dict(request.get('chat_template_kwargs') or {})
ctk['enable_thinking'] = False
request['chat_template_kwargs'] = ctk
logging.getLogger(__name__).info(
'[spec-harness] direct answer: empty menu p=%s',
_spec_direct_p)
else:
# Default: keep the same tools list (prefix-cache
# friendly) but forbid a tool call this round. Thinking
# is left to the thinking router; offered stays
# non-empty so this does not switch progressive
# thinking on.
request['tool_choice'] = 'none'
tool_call_requested = False
logging.getLogger(__name__).info(
'[spec-harness] direct answer: tool_choice none p=%s',
_spec_direct_p)
if active_editor_target and request.get('tools'):
# Edits share mutable document state. Await a saved result
# before asking for another call; one call may still batch
@@ -6144,6 +6696,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
and not progressive_thinking
and not source_lookup_pending
and not tool_call_requested
and not _spec_direct_empty_menu
):
text_event = {'delta': text}
if replace_streamed_draft_on_finish and not streamed_round_text:
@@ -6197,6 +6750,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if content and not prior_summary_answer and not source_lookup_pending:
yield event({'delta': content})
streamed_round_text = True
if _spec_direct_empty_menu and pending:
# Option B: nothing is offered, so never execute a tool
# call the model still produced (native shape).
logging.getLogger(__name__).info(
'[spec-harness] direct answer: dropped %d tool call(s)',
len(pending))
pending = {}
proposed = [pending[i] for i in sorted(pending)]
# A lead-in emitted before a tool call is live progress, not
# part of the terminal answer. Replace that draft when the
@@ -6204,7 +6764,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if proposed and streamed_round_text:
replace_streamed_draft_on_finish = True
unexecutable_dsml_completion = False
if not proposed and 'DSML' in content:
if (not proposed and not _spec_direct_empty_menu
and _driver_round_kind != 'answer_directly'
and 'DSML' in content):
offered_by_canonical = {
canonical(schema['function']['name']): schema['function']['name']
for schema in round_offered
@@ -6252,6 +6814,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
})
else:
unexecutable_dsml_completion = True
if _spec_direct_empty_menu and content:
# Option B: show the answer text without any tool-call
# markup the model may still have written (nothing offered).
_stripped = re.sub(
r'<tool_call\b[^>]*>.*?</tool_call>', '',
content, flags=re.DOTALL | re.IGNORECASE).strip()
if _stripped != content.strip():
content = _stripped
replace_streamed_draft_on_finish = True
if source_lookup_pending and not proposed:
if source_answer_retries < 1 and round_number < round_limit:
source_answer_retries += 1
@@ -6276,6 +6847,37 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
)
if active_editor_target:
proposed = drop_redundant_editor_noops(proposed)
# ODYSSEUS_SPEC_MODE=driver: Spec is the tool driver, so execute
# ONLY the call Spec picked this round. Drop (never execute) any
# other native call and any text/DSML-recovered call; when Spec
# said answer_directly, keep no calls and strip tool markup so
# the remaining text is the answer.
if _driver_round_kind == 'answer_directly':
if proposed:
for _c in proposed:
logging.getLogger(__name__).info(
'[spec-driver] dropped off-pick call name=%s',
(_c.get('function') or {}).get('name'))
proposed = []
if content:
_stripped = strip_tool_blocks(content, skip_fenced=True).strip()
_stripped = re.sub(
r'<tool_call\b[^>]*>.*?</tool_call>', '',
_stripped, flags=re.DOTALL | re.IGNORECASE).strip()
if _stripped != content:
content = _stripped
replace_streamed_draft_on_finish = True
elif _driver_round_kind in ('tool', 'ask_user'):
_want = 'ask_user' if _driver_round_kind == 'ask_user' else _driver_round_pick
_keep = []
for _c in proposed:
_cn = (_c.get('function') or {}).get('name')
if canonical(str(_cn or '')) == canonical(str(_want or '')):
_keep.append(_c)
else:
logging.getLogger(__name__).info(
'[spec-driver] dropped off-pick call name=%s', _cn)
proposed = _keep
if model_choice_experiment:
for proposal in proposed:
yield event({'type': 'model_tool_proposal', 'round': round_number,
@@ -6695,6 +7297,25 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
# that draft and persisted history with the evidence-backed result.
history.pop()
refusal = denied_response()
# A photo-editor turn with no canvas command actually executed
# (no browser editor attached) cannot claim the edit. Point the
# user at the editor instead of the generic no-op refusal.
if 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ()):
touched_canvas = any(
str(e.get('tool') or '') in {
'run_editor_command', 'get_editor_screenshot',
'ai_edit_image', 'generate_image',
}
and not e.get('error') and e.get('exit_code', 0) == 0
for e in executions
)
if not touched_canvas:
refusal = (
'This photo is not open on the editor canvas, so I cannot '
'apply edits to it from chat. Open the attached image in the '
'editor (Quick Edit) and send the request there, and I will '
'apply it to the canvas.'
)
history.append({'role': 'assistant', 'content': refusal})
yield event({'type': 'final_response', 'content': refusal})
break
@@ -7948,6 +8569,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
output, user_text=latest_user,
max_items=contract_item_limit(turn_contract, 20),
)
followup = note_locator_followup(output, user_text=latest_user)
if followup:
if round_number < round_limit:
structured_terminal_response = ''
round_recovery_messages.append(followup)
else:
structured_terminal_response = (
'I found a note, but could not read its content in this turn.'
)
action = str(args.get('action') or '').replace('-', '_').casefold()
if (
action in {'search', 'find'}
+80 -11
View File
@@ -92,6 +92,83 @@ def prune_multimodal_images(
return pruned
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
def _image_turn_label(message: Dict) -> str:
"""Best-effort '[Image: name]' label for a message whose image parts are dropped."""
name = ""
content = message.get("content") if isinstance(message, dict) else None
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")
if candidate:
name = str(candidate)
break
if not name:
text = _content_as_text(content)
match = _IMAGE_TURN_LABEL_RE.search(text)
if match:
name = match.group(1).strip()
return f"[Image: {name}]" if name else "[Image]"
def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict]:
"""Keep image parts only for the last `keep` messages that carry an image.
Older image blocks are dropped and replaced with a short ``[Image: name]``
text label so the model still knows a picture was shared (and its name)
without paying the image tokens on every later turn. Non-text, non-image
multimodal blocks (e.g. audio) are left untouched.
"""
limit = max(0, int(keep))
image_messages = [
index for index, message in enumerate(messages)
if isinstance(message, dict)
and isinstance(message.get("content"), list)
and any(
isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES
for item in message["content"]
)
]
if len(image_messages) <= limit:
return list(messages)
selected = set(image_messages[len(image_messages) - limit:]) if limit else set()
trimmed: List[Dict] = []
for message_index, message in enumerate(messages):
if message_index in selected or not isinstance(message, dict):
trimmed.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
trimmed.append(message)
continue
kept_blocks = [
item for item in content
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
]
if len(kept_blocks) == len(content):
trimmed.append(message)
continue
label = _image_turn_label(message)
# Attach the label to the trailing text block when possible so we do not
# introduce an extra content block; otherwise append a fresh text block.
labelled = False
for item in reversed(kept_blocks):
if isinstance(item, dict) and item.get("type") == "text":
item["text"] = (item.get("text") or "").rstrip() + f"\n{label}"
labelled = True
break
if not labelled:
kept_blocks.append({"type": "text", "text": label})
cloned = dict(message)
cloned["content"] = kept_blocks
trimmed.append(cloned)
return trimmed
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
SUMMARY_MAX_TOKENS = 1024
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
@@ -106,7 +183,7 @@ def auto_compact_threshold_percent() -> int:
return max(50, min(95, value))
# Cursor-style self-summarization prompt — produces structured, dense summaries
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly.
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
Use this format:
@@ -557,16 +634,8 @@ async def maybe_compact(
)
# Use utility model if configured, otherwise fall back to session model
util_url, util_model, util_headers = resolve_endpoint(
"utility",
fallback_url=endpoint_url,
fallback_model=model,
fallback_headers=headers,
owner=owner,
)
compact_url = util_url or endpoint_url
compact_model = util_model or model
compact_headers = util_headers if util_url else headers
# Compact with the chat model itself (the one being evaluated), never a separate utility model.
compact_url, compact_model, compact_headers = endpoint_url, model, headers
prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace(
"{count}", str(len(older))
+47 -4
View File
@@ -2,6 +2,7 @@
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
import os
import io
import logging
import mimetypes
import base64
@@ -496,6 +497,49 @@ def analyze_image_with_vl(image_path: str, owner: str | None = None) -> str:
return analyze_image_with_vl_result(image_path, owner=owner).get("text", "")
def _vision_max_side() -> int:
"""Longest-side cap for images inlined into the chat request (tokens)."""
try:
value = int(os.getenv("ODYSSEUS_VISION_MAX_SIDE", "1280") or 1280)
except (TypeError, ValueError):
value = 1280
return max(0, value)
def encode_image_data_url(path: str, fallback_format: str) -> tuple[str, str]:
"""Return (data_url, image_format) for an image, downscaled if large.
Downscale the longest side to ODYSSEUS_VISION_MAX_SIDE (default 1280) and
re-encode as JPEG so the chat model does not pay full-resolution image
tokens. Small/undecodable images fall back to the original bytes so a
real attachment is never dropped.
"""
max_side = _vision_max_side()
try:
from PIL import Image # local import: keep module import cost low
with Image.open(path) as img:
img = img.convert("RGB") if img.mode not in ("RGB", "L") else img
width, height = img.size
if max_side and max(width, height) > max_side:
scale = max_side / float(max(width, height))
img = img.resize(
(max(1, round(width * scale)), max(1, round(height * scale))),
Image.LANCZOS,
)
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=85, optimize=True)
return (
"data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("utf-8"),
"jpeg",
)
except Exception as exc:
logger.debug(f"Image downscale failed for {path}: {exc}; sending original bytes")
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
image_format = (fallback_format or "png").lower()
return f"data:image/{image_format};base64,{encoded_string}", image_format
def build_user_content(
text: str,
attachment_ids: list[str] | None,
@@ -542,15 +586,14 @@ def build_user_content(
if upload_handler.is_image_file(display_name, mime):
try:
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
# so fall back to the resolved MIME subtype rather than emitting
# an invalid "data:image/;base64," with an empty subtype.
image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
fallback_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
data_url, image_format = encode_image_data_url(path, fallback_format)
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
"image_url": {"url": data_url},
})
except Exception as e:
logger.error(f"Failed to encode image {fid}: {e}")
+113 -6
View File
@@ -2,7 +2,8 @@
import json
import time
import copy
from dataclasses import dataclass
import re
from dataclasses import dataclass, replace
@dataclass(frozen=True)
@@ -19,7 +20,7 @@ _DEPENDENCIES = {
'web': {'web_search', 'web_fetch', 'private_browser'},
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
'download_attachment'},
'contacts': {'resolve_contact'},
'contacts': {'resolve_contact', 'manage_contact'},
'documents': {'search_documents', 'read_document'},
}
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
@@ -116,8 +117,9 @@ def parse_email_task_intent(value):
def scope_email_tools(schemas, intent, *, active_editor=False):
if intent.operation == 'read' and intent.dependencies:
allowed = set().union(*(_DEPENDENCIES[d] for d in intent.dependencies))
if active_editor:
allowed = {tool for dependency in intent.dependencies
for tool in _DEPENDENCIES[dependency]}
if active_editor and intent.dependencies:
allowed.update(_OPEN_EDITOR_TOOLS)
if intent.needs_clarification:
allowed.add('ask_user')
@@ -126,6 +128,9 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
if intent.operation not in {'draft', 'revise'}:
return list(schemas)
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
# manage_contact is a read-only runtime source for lookups, not a
# composition tool. Drafts still resolve recipients through resolve_contact.
allowed.discard('manage_contact')
if active_editor:
allowed.update(_OPEN_EDITOR_TOOLS)
if not intent.needs_clarification:
@@ -145,6 +150,62 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
# as though it were the whole task. This byte budget is deliberately conservative.
CLASSIFIER_CONTEXT_BYTES = 24000
# The model can produce a structurally valid classification while forgetting
# the source or mistaking a list of message headers for a request for bodies.
_OWN_MAILBOX = re.compile(
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
r'(?:e-?mails?|mail|inbox|mailbox)\b', re.IGNORECASE,
)
_MAILBOX_HEADER_LIST = re.compile(
r'^\s*(?:list|show|display|what(?:\'s| are))\s+(?:me\s+)?(?:my|our)\s+'
r'(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
r'(?:e-?mails|messages)\s*'
r'(?:with\s+(?:(?:sender|subject|date|time|recipient|status)\s*'
r'(?:(?:,|and)\s*)?)+)?[?.!]*\s*$', re.IGNORECASE,
)
_EMAIL_HEADER_FIELDS = re.compile(r'\b(?:subjects?|senders?|dates?)\b', re.IGNORECASE)
_EMAIL_HEADER_FOLLOWUP = re.compile(r'\b(?:those|these|them|their|same)\b', re.IGNORECASE)
_READ_REQUEST = re.compile(r'^\s*(?:please\s+)?(?:show|list|display|find|search|read|what|how many|are there|do i have)\b', re.IGNORECASE)
_EMAIL_COUNT = re.compile(r'^\s*how many\s+(?:(?:unread|read|new)\s+)?(?:e-?mails|messages)\b',
re.IGNORECASE)
_EMAIL_BODY_QUERY = re.compile(
r'\b(?:mention(?:s|ed|ing)?|body|contents?|amount|owe|says?|said|when|where|why|'
r'starts?|deadline|attachments?)\b|\bhow much\b|\bwhat time\b',
re.IGNORECASE)
_CONTACT_EMAIL_FIELD = re.compile(
r'\bcontacts?\b[^.!?\n]{0,100}\bemail\b', re.IGNORECASE,
)
_MAILBOX_SOURCE = re.compile(
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
r'(?:e-?mails?|inbox|mailbox)\b|\b(?:inbox|mailbox|email\s+history)\b|'
r'\b(?:e-?mails?|messages?)\s+(?:from|to|sent|received)\b|'
r'\bemailed?\s+(?:me|us)\b', re.IGNORECASE,
)
EMAIL_TASK_RESPONSE_FORMAT = {
'type': 'json_schema',
'json_schema': {
'name': 'email_task_intent',
'strict': True,
'schema': {
'type': 'object',
'additionalProperties': False,
'properties': {
'operation': {'type': 'string', 'enum': ['draft', 'revise', 'read', 'send', 'other']},
'requires_content': {'type': 'boolean'},
'dependencies': {'type': 'array', 'items': {
'type': 'string', 'enum': list(_DEPENDENCIES),
}},
'destination': {'type': 'string', 'enum': ['chat', 'mailbox']},
'needs_clarification': {'type': 'boolean'},
'summary': {'type': 'string'},
},
'required': ['operation', 'requires_content', 'dependencies', 'destination',
'needs_clarification', 'summary'],
},
},
}
async def classify_email_task(client, *, endpoint_url, headers, model, history,
supplied_context=None, accounting=None):
@@ -152,6 +213,8 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
# text from multimodal messages, so an attached image cannot hide the latest
# instruction and leave us classifying an earlier task instead.
dialogue = []
latest_user_text = ''
prior_user_text = ''
for row in history:
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
continue
@@ -166,6 +229,9 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
and isinstance(block.get('text'), str))
if isinstance(content, str):
dialogue.append({'role': row['role'], 'content': content})
if row['role'] == 'user':
prior_user_text = latest_user_text
latest_user_text = content
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
ensure_ascii=False)
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
@@ -174,7 +240,7 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
'chat_template_kwargs': {'enable_thinking': False},
'response_format': {'type': 'json_object'},
'response_format': EMAIL_TASK_RESPONSE_FORMAT,
'messages': [{'role': 'system', 'content': (
'Classify the current conversational task. Return JSON only with operation '
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
@@ -250,6 +316,47 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
'response_time': round(time.monotonic() - started, 3),
})
try:
return parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
intent = parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
if intent.operation == 'read' and not intent.needs_clarification:
# In a contact-record question, "email" can name an address field
# rather than the mailbox as a source. Keep both dependencies when
# the request also names a mailbox/history source.
contact_email_field = bool(
_CONTACT_EMAIL_FIELD.search(latest_user_text)
and not _MAILBOX_SOURCE.search(latest_user_text)
)
if contact_email_field:
intent = replace(intent, dependencies=tuple(dict.fromkeys(
(*[d for d in intent.dependencies if d != 'email'], 'contacts')
)))
# Reuse the product's existing source classifier rather than a
# second vocabulary of mailbox phrases. The classifier has a known
# gap for possessive single-message questions, so retain that
# narrow fallback only when it found no competing source family.
from src.turn_contract import requested_capabilities
capabilities = requested_capabilities(latest_user_text)
# A header-only reference can inherit the immediately preceding
# mailbox read, but an intervening request or a competing source
# breaks that link. Never infer a read from an earlier write.
header_followup = (
not capabilities
and bool(_EMAIL_HEADER_FIELDS.search(latest_user_text))
and bool(_EMAIL_HEADER_FOLLOWUP.search(latest_user_text))
and bool(_READ_REQUEST.match(prior_user_text))
and requested_capabilities(prior_user_text) == {'email'}
)
mailbox_read = not contact_email_field and (capabilities == {'email'} or (
not capabilities and bool(_OWN_MAILBOX.search(latest_user_text))
) or header_followup)
else:
mailbox_read = False
if mailbox_read:
intent = replace(intent, dependencies=tuple(dict.fromkeys((*intent.dependencies, 'email'))))
if (not _EMAIL_BODY_QUERY.search(latest_user_text)
and (_MAILBOX_HEADER_LIST.fullmatch(latest_user_text)
or _EMAIL_HEADER_FIELDS.search(latest_user_text)
or _EMAIL_COUNT.match(latest_user_text))):
intent = replace(intent, requires_content=False)
return intent
except (KeyError, IndexError, TypeError) as exc:
raise ValueError('Invalid classifier response') from exc
+120
View File
@@ -0,0 +1,120 @@
"""Thin client for the local jeff-serve decision endpoint (POST /v1/systemone).
Trained decision adapters (triage, spam, guard-email, ...) answer a situation
with calibrated probabilities over named options in a single forward pass, with
no generated text to parse. Background jobs can prefer an adapter and fall back
to the normal LLM candidate chain whenever this client returns None.
Every call fails open (returns None) and is disabled per job unless the job's
flag is set, so importing/enabling this never changes behaviour on its own.
Env:
ODYSSEUS_JEFF_URL base URL of jeff-serve (default http://127.0.0.1:8765)
ODYSSEUS_JEFF_TIMEOUT per-request timeout in seconds (default 4.0)
ODYSSEUS_JEFF_<JOB> per-job enable flag, e.g. ODYSSEUS_JEFF_EMAIL_URGENCY=1
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, Optional
import httpx
logger = logging.getLogger(__name__)
_DEFAULT_URL = "http://127.0.0.1:8765"
_FALSEY = ("", "0", "false", "off", "no")
def jeff_url() -> str:
"""Base URL of the local jeff-serve (no trailing slash)."""
return os.getenv("ODYSSEUS_JEFF_URL", _DEFAULT_URL).strip().rstrip("/")
def jeff_job_enabled(job: str) -> bool:
"""True when ODYSSEUS_JEFF_<JOB> is set to a truthy value. Default off."""
slug = "".join(c if (c.isalnum() or c == "_") else "_" for c in (job or "")).upper()
return os.getenv("ODYSSEUS_JEFF_" + slug, "0").strip().lower() not in _FALSEY
def jeff_timeout(default: float = 4.0) -> float:
try:
return float(os.getenv("ODYSSEUS_JEFF_TIMEOUT", str(default)))
except Exception:
return default
async def jeff_systemone(
model: str,
state: Dict[str, Any],
questions: Dict[str, Any],
*,
timeout: Optional[float] = None,
orders: int = 1,
client: Optional[httpx.AsyncClient] = None,
) -> Optional[Dict[str, Any]]:
"""POST a decision request and return the ``answers`` dict, or None on error.
Each question is scored in its own forward pass, so send only questions the
adapter was trained on. Raises are swallowed and logged (fail open).
"""
url = jeff_url()
if not url:
return None
payload: Dict[str, Any] = {"model": model, "state": state, "questions": questions}
if orders and orders != 1:
payload["orders"] = orders
to = timeout if timeout is not None else jeff_timeout()
try:
if client is None:
async with httpx.AsyncClient(timeout=to) as _c:
resp = await _c.post(url + "/v1/systemone", json=payload)
else:
resp = await client.post(url + "/v1/systemone", json=payload)
resp.raise_for_status()
answers = (resp.json() or {}).get("answers")
return answers if isinstance(answers, dict) else None
except Exception as exc:
logger.warning("jeff decision (%s) failed: %s", model, exc)
return None
async def jeff_available(timeout: float = 1.5) -> bool:
"""Best-effort health probe for the local jeff-serve."""
url = jeff_url()
if not url:
return False
try:
async with httpx.AsyncClient(timeout=timeout) as _c:
resp = await _c.get(url + "/health")
return resp.status_code == 200
except Exception:
return False
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:
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
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
"""The winning key of a jeff ``choice`` answer, or None."""
if isinstance(answer, dict) and answer.get("type") == "choice":
c = answer.get("choice")
return str(c) if c is not None else None
return None
+38
View File
@@ -0,0 +1,38 @@
"""Pre-Wave-3 private_browser schema (agent-browser wrapper), used when ODYSSEUS_PRIVATE_BROWSER_LEGACY is on."""
PRIVATE_BROWSER_SCHEMA = {
"type": "function",
"function": {
"name": "private_browser",
"description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]},
"url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"},
"selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"},
"target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"},
"key": {"type": "string", "description": "Key name for press action, e.g. Enter"},
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"},
"amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"},
"text": {"type": "string", "description": "Text for fill action"},
"value": {"type": "string", "description": "Alternative text/value for fill action"},
"find": {"type": "string", "description": "Visible text to locate for find action"},
"script": {"type": "string", "description": "JavaScript expression for evaluate action"},
"path": {"type": "string", "description": "Optional output path for screenshot"},
"commands": {
"type": "array",
"description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.",
"items": {
"oneOf": [
{"type": "array", "items": {"type": "string"}},
{"type": "object"},
]
},
},
"timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"}
},
"required": ["action"]
}
}
}
+2
View File
@@ -78,6 +78,8 @@ 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,
+7 -5
View File
@@ -67,7 +67,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
"""
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
from src.llm_core import llm_call_async
from src.task_endpoint import resolve_task_endpoint
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
db = SessionLocal()
try:
@@ -164,8 +164,6 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
url, model, headers = resolve_task_endpoint(owner=owner or None)
if not url:
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. No model endpoint available for sorting."
names_text = "\n".join(f' "{s["id"][:8]}": "{s["name"]}"' for s in session_list)
prompt = (
@@ -183,8 +181,12 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
try:
# 16384 (was 4096): large folder JSON + reasoning-model thinking
# overflowed 4096 and truncated the JSON, so it never parsed.
raw = await llm_call_async(url, model, [{"role": "user", "content": prompt}],
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
raw = await task_llm_call_async(
[{"role": "user", "content": prompt}],
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=owner or None,
temperature=0.3, max_tokens=16384, timeout=120,
)
except Exception as e:
logger.warning(f"Auto-sort LLM call failed: {e}")
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. Folder sort skipped (model unreachable)."
+270
View File
@@ -0,0 +1,270 @@
"""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.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
import httpx
from src import spec_router
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
from src.clean_agent_preview import (
event, preview_model_response, preview_tool_result_text,
)
from src.tool_capabilities import ToolRunSecurityContext
from src.tool_execution import execute_tool_block
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS, function_call_to_tool_block
logger = logging.getLogger(__name__)
def _text(content):
if isinstance(content, str):
return content
if isinstance(content, list):
return '\n'.join(str(p.get('text') or '') for p in content
if isinstance(p, dict) and p.get('type') == 'text')
return ''
def _name(schema):
return (schema.get('function') or {}).get('name') or ''
# JSON-schema keywords the provider's guided decoding (named tool_choice) cannot compile.
_GRAMMAR_UNSUPPORTED = frozenset({
'uniqueItems', 'contains', 'minContains', 'maxContains', 'patternProperties', 'propertyNames',
'dependentRequired', 'dependentSchemas', 'if', 'then', 'else',
'unevaluatedProperties', 'unevaluatedItems',
})
def grammar_safe(schema):
"""Copy of a schema without keywords guided decoding rejects (validation still runs at execution)."""
if isinstance(schema, dict):
return {k: grammar_safe(v) for k, v in schema.items() if k not in _GRAMMAR_UNSUPPORTED}
if isinstance(schema, list):
return [grammar_safe(v) for v in schema]
return schema
def spec_surface(disabled_tools):
disabled = set(disabled_tools or ())
return [s for s in FUNCTION_TOOL_SCHEMAS if _name(s) and _name(s) not in disabled]
async def _model_round(client, endpoint_url, headers, request, recovery):
"""Run one chat-model round; return (content, tool_calls, usage)."""
content, calls, usage, raw = '', {}, {}, []
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
async for line in response.aiter_lines():
if len(raw) < 4 and line.strip():
raw.append(line[:400])
if line.strip() and not line.startswith('data: '):
logger.warning('[spec-driver] unexpected stream line: %s', line[:500])
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]}")
usage = chunk.get('usage') or usage
for choice in chunk.get('choices') or []:
delta = choice.get('delta') or {}
text = delta.get('content') or ''
content += text
for frag in delta.get('tool_calls') or []:
call = calls.setdefault(frag.get('index', 0), {'id': '', 'name': '', 'arguments': ''})
call['id'] = frag.get('id') or call['id']
fn = frag.get('function') or {}
call['name'] += fn.get('name') or ''
call['arguments'] += fn.get('arguments') or ''
if not calls:
logger.warning('[spec-driver] no tool call; status=%s max_tokens=%s msgs=%s first lines=%s',
getattr(response, 'status_code', None), request.get('max_tokens'),
[(m.get('role'), type(m.get('content')).__name__,
[p.get('type') for p in m['content']] if isinstance(m.get('content'), list) else len(str(m.get('content') or '')))
for m in request.get('messages', [])], raw[-2:])
return content, [calls[i] for i in sorted(calls)], usage
@with_request_authority
async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id, owner,
disabled_tools, tool_policy, workspace=None, active_document=None,
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,
}})
yield 'data: [DONE]\n\n'
+396
View File
@@ -0,0 +1,396 @@
"""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.
Env:
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
ODYSSEUS_SPEC_ROUTER_MODEL adapter name (default: odysseus-s60)
ODYSSEUS_SPEC_ROUTER_TIMEOUT seconds (default: 0.5)
ODYSSEUS_SPEC_ROUTER_MIN_CONF minimum top-1 probability to act (default: 0.3)
ODYSSEUS_SPEC_ROUTER_SECOND minimum 2nd-choice probability to also add it (default: 0.25)
"""
from __future__ import annotations
import json
import logging
import os
import time
import urllib.request
import urllib.error
from typing import Any, Dict, Iterable, List, Optional, Set
logger = logging.getLogger(__name__)
_HEAD = {
"answer_directly": "No tool is needed: answer the user directly from the conversation",
"ask_user": "A tool is needed but required information is missing: ask the user first",
}
_INSTRUCTIONS = (
"Which tool should the agent call next to handle the user's latest message? "
"Use the conversation for context. If no tool is needed, choose answer directly. "
"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]] = []
_MCP_BROWSER_TOOLS = (
"mcp__builtin_browser__browser_navigate",
"mcp__builtin_browser__browser_snapshot",
"mcp__builtin_browser__browser_click",
"mcp__builtin_browser__browser_type",
"mcp__builtin_browser__browser_press_key",
"mcp__builtin_browser__browser_wait_for",
"mcp__builtin_browser__browser_take_screenshot",
)
def _fn(schema: Dict[str, Any]) -> Dict[str, Any]:
return schema.get("function") if isinstance(schema.get("function"), dict) else schema
def tool_line(schema: Dict[str, Any]) -> str:
fn = _fn(schema)
name = str(fn.get("name") or "")
props = ((fn.get("parameters") or {}).get("properties") or {})
if os.getenv("ODYSSEUS_SPEC_TOOL_FORMAT", "").strip().lower() == "names":
return name + "(" + ", ".join(props) + ")" # names + parameters only (spec_v1n adapters are trained on this)
desc = " ".join(str(fn.get("description") or "").split())
if len(desc) > 220:
desc = desc[:217] + "..."
return f"{name}({', '.join(props)}): {desc}"
def prior_tool_names(messages: Optional[Iterable[Dict[str, Any]]]) -> Set[str]:
"""Tools already called in this conversation (kept offered: no 'forgetting')."""
seen: Set[str] = set()
for m in messages or []:
if not isinstance(m, dict) or m.get("role") != "assistant":
continue
for tc in m.get("tool_calls") or []:
name = (tc.get("function") or tc).get("name") if isinstance(tc, dict) else None
if name:
seen.add(str(name))
return seen
def _conversation(messages: Optional[Iterable[Dict[str, Any]]], limit: int = 4) -> List[Dict[str, str]]:
out: List[Dict[str, str]] = []
for m in list(messages or []):
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip():
out.append({"role": m["role"], "content": m["content"][:600]})
# the last user message is passed separately as user_message
if out and out[-1]["role"] == "user":
out = out[:-1]
return out[-limit:]
def enabled() -> bool:
return bool(os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip())
def spec_route_tools(
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,
):
"""Return tool names the router wants offered (plus tools already used).
With return_decision=True returns (tools, decided) where decided is True when
the router answered confidently (a tool pick, or answer_directly)."""
added: Set[str] = set(prior_tool_names(messages))
decided = False
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}},
}
http = urllib.request.Request(
url + "/v1/systemone", data=json.dumps(req).encode(), headers={"content-type": "application/json"}
)
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
deadline = time.time() + timeout
while True:
try:
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
break
except urllib.error.HTTPError as busy:
# 529 = router busy with another request; retry within budget.
if busy.code != 529 or time.time() + 0.15 >= deadline:
raise
decision["retries"] = decision.get("retries", 0) + 1
time.sleep(0.1)
probs = out.get("probabilities") or {}
choice = out.get("choice")
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:
# This build disables page execution in private_browser (see
# src/browser_identity.py). "mcp": use the connected Playwright MCP
# browser (no run_code/evaluate); "web": fetch/search only.
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
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
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
return (added, decided) if return_decision else added
# ---- Ajax Spec: system prompt trimmed to the tools actually offered ----------
_CORE_PROMPT = (
"You are Ajax, the assistant in Odysseus. Emit one native tool call, then stop. "
"After a successful tool result, answer from it; never repeat the same tool with the same arguments."
)
_CODING_PROMPT = (
"HARD CODING ORDER: for code changes call read_file, then edit_file or apply_patch, then host_shell for tests. "
"Never run tests before the edit succeeds; after a read, your next call is the edit. "
"edit_file JSON is {path,old_string,new_string}; write_file only for new files or full rewrites."
)
_TOOL_NOTES = {
"manage_notes": "manage_notes: notes/checklists.",
"manage_documents": "manage_documents: document library; actions list/read/delete/tidy, use list+search to find by title/content.",
"create_document": "create_document: create editor-panel documents with title/content.",
"edit_document": "edit_document: edit the active editor document using command=<<<FIND>>>old<<<REPLACE>>>new<<<END>>> or edits=[{find,replace}].",
"update_document": "update_document: full active-document rewrite only.",
"manage_calendar": "manage_calendar: calendar events; actions create_event, update_event, delete_event, list_events.",
"manage_tasks": "manage_tasks: scheduled/recurring tasks; create uses action=create, name, prompt, task_type, schedule, scheduled_time, output_target; pause/resume/delete use task_id, so list/search first when only the name is known. Recurring reminders use manage_tasks.",
"manage_memory": "manage_memory: saved memories.",
"manage_contact": "manage_contact: address-book contacts.",
"manage_research": "manage_research: saved research reports.",
"manage_settings": "manage_settings: app settings.",
"manage_endpoints": "manage_endpoints: configured API endpoints.",
"list_models": "list_models: available models.",
"manage_mcp": "manage_mcp: MCP servers.",
"manage_skills": "manage_skills: skills.",
"manage_webhooks": "manage_webhooks: webhooks.",
"manage_bg_jobs": "manage_bg_jobs: running/background jobs.",
"search_chats": "search_chats: past chats.",
"web_search": "web_search: public web lookup, URLs, links.",
"web_fetch": "web_fetch: fetch a concrete URL/domain.",
"private_browser": "private_browser: rendered site interaction. Make separate calls: first action=open with the url, then action=snapshot; read/scroll/find as needed. Do not use action=batch. Only click/fill element refs returned by snapshot (for example @e12), never guessed labels.",
"mcp__builtin_browser__browser_navigate": "Browser: browser_navigate {url} opens a page; then browser_snapshot returns the page with element refs; browser_click/browser_type take ref values from the latest snapshot, never guessed labels.",
"youtube_tool": "youtube_tool: YouTube comments, transcripts, metadata, and latest channel video.",
"read_file": "read_file: workspace text, PDF, or Office document path.",
"edit_file": "edit_file: exact replacement in an existing workspace file.",
"write_file": "write_file: create or fully rewrite a workspace file.",
"apply_patch": "apply_patch: related multi-file workspace edits.",
"host_shell": "host_shell: run tests, builds, and verification in the active workspace.",
}
_EMAIL_NOTES = (
"Email: latest email/emails/inbox -> mcp__email__list_emails {folder:INBOX,max_results:1,unread_only:false}; "
"account list -> list_email_accounts; subject find+read -> mcp__email__list_emails {folder:INBOX,max_results:20}; "
"sender/topic search -> mcp__email__search_emails {max_results:10}; known id -> mcp__email__read_email; "
"new draft -> mcp__email__draft_email; attachment -> mcp__email__download_attachment {uid,index,folder,account}; "
"reply draft -> mcp__email__draft_email_reply {uid,account,body}; only explicit send-now -> mcp__email__reply_to_email {uid,account,body}; "
"archive/delete/mark -> mcp__email__archive_email/mcp__email__delete_email/mcp__email__mark_email_read. Never emit manage_email."
)
_AJAX_PROMPT_START = "You are Ajax, the assistant in Odysseus."
def trimmed_system_prompt(offered: Iterable[str]) -> str:
offered = set(offered or ())
parts = [_CORE_PROMPT]
if offered & {"edit_file", "apply_patch", "host_shell"}:
parts.append(_CODING_PROMPT)
parts += [_TOOL_NOTES[n] for n in sorted(offered) if n in _TOOL_NOTES]
if any(n.startswith("mcp__email__") or n in {"list_email_accounts"} for n in offered):
parts.append(_EMAIL_NOTES)
return " ".join(parts)
def trim_system_prompt(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
"""Swap the generic Ajax tool manual for one covering only the offered tools."""
out: List[Dict[str, Any]] = []
for m in messages:
if (
isinstance(m, dict)
and m.get("role") == "system"
and isinstance(m.get("content"), str)
and m["content"].startswith(_AJAX_PROMPT_START)
):
new = dict(m)
new["content"] = trimmed_system_prompt(offered)
logger.info("[spec-harness] system prompt trimmed %d -> %d chars", len(m["content"]), len(new["content"]))
out.append(new)
else:
out.append(m)
return out
def drop_skills_index(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
"""Ajax Spec: omit the ~1000-token skills index unless the skills tool is offered."""
if "manage_skills" in set(offered or ()):
return messages
out = [
m for m in messages
if not (isinstance(m, dict) and (m.get("metadata") or {}).get("source") == "available skills index")
]
if len(out) != len(messages):
logger.info("[spec-harness] dropped skills index (%d chars)", sum(len(str(m.get("content") or "")) for m in messages if m not in out))
return out
# --- Router-decided thinking (progressive thinking trigger) -------------------------------------------------
# The router classifies the user's request as THINK (needs reasoning: math/logic, tricky planning, subtle explanation or
# weighing options, first-instinct-likely-wrong questions) or DIRECT (plain doing, lookup, chit-chat).
# Zero-shot on 143 DeepSeek-labelled app requests (2026-10-13): odysseus-agentic-v1f acc 0.916, think recall 0.985,
# think precision 0.859, ~33 ms. Off unless ODYSSEUS_SPEC_THINK_ROUTER=1. Never raises; returns None on any error.
_THINK_DEF = ("This request needs genuine reasoning or figuring something out before answering: math and logic puzzles; "
"planning a tricky multi-step approach; explaining something subtle or weighing options; or questions where "
"a quick first instinct is likely wrong.")
_DIRECT_DEF = ("This request is plain doing, lookup, or chit-chat that can be answered directly without reasoning: simple "
"tool actions (set a reminder, search email, open a page, rename a file), simple facts, or small talk.")
_THINK_INSTRUCTIONS = ("Classify whether this single user request needs the assistant to THINK (deep reasoning / figuring "
"something out) before answering, or can be answered DIRECTLY (plain doing, lookup, or chit-chat). "
"Answer with exactly one of the two options.")
# 5-way request kind, exactly as trained into adapter thinkclass5 (2026-10-13; held-out 143: think/direct 96.5%,
# think precision 98.8%, recall 95.2%). Enabled with ODYSSEUS_SPEC_THINK_QUESTION=kind5 + ODYSSEUS_SPEC_THINK_MODEL=thinkclass5.
_KIND5_QUESTION = {"type": "choice", "instructions": "Read the user's request and classify it into exactly one of five kinds: math_logic, tricky_planning, explain_or_weigh, trap_question, or direct. Pick the option whose description fits best.", "criteria": {"math_logic": "Mathematical or logical computation/reasoning: arithmetic, word problems, logic puzzles, counting, proofs.", "tricky_planning": "Planning a multi-step approach with real constraints: schedules, itineraries, sequencing, resource or budget allocation, multi-part procedures.", "explain_or_weigh": "Explaining something subtle or weighing options/tradeoffs: comparisons, 'should I', pros and cons, recommendations with reasoning, nuanced why-explanations.", "trap_question": "A question where the obvious or first-instinct answer is likely wrong: riddles, trick questions, common misconceptions, counterintuitive facts.", "direct": "Plain doing, lookup, or chit-chat: simple tool actions, simple facts, small talk — no deep reasoning needed."}}
def think_router_enabled() -> bool:
return os.getenv("ODYSSEUS_SPEC_THINK_ROUTER", "0").strip() not in ("", "0", "false", "off")
def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
"""Return {'think': bool, 'p_think': float, 'ms': float} or None (disabled / error / empty 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"))
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
headers={"content-type": "application/json"})
ans = json.load(urllib.request.urlopen(http, timeout=timeout))["answers"]["thinking"]
probs = ans.get("probabilities") or {}
# kind5: think = any of the four reasoning kinds (trained adapter thinkclass5); binary: P(think).
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 msg=%r", out, text[:120])
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
# ---- 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,
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
"""Top-1 next action at any confidence: {'kind': 'tool'|'answer_directly'|'ask_user', 'name', 'p'}.
Same request as training (build_agentic_rows.py): sorted tool lines, and mid-turn the latest tool
result appended to the user message as "\\n\\nLatest tool result:\\n...". Returns None on any error."""
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:
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
headers={"content-type": "application/json"})
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
deadline = time.time() + timeout
while True:
try:
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
break
except urllib.error.HTTPError as busy:
if busy.code != 529 or time.time() + 0.15 >= deadline:
raise
time.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 msg=%r", action, (user_message or "")[:80])
return action
except Exception as exc:
logger.warning("spec next action failed: %s", exc)
return None
+28 -1
View File
@@ -1558,8 +1558,29 @@ def _agent_subprocess_env() -> dict:
return base
def _legacy_private_browser_enabled() -> bool:
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):
from src.agent_tools.web_tools_prewave3 import PrivateBrowserTool
ctx = {
"progress_cb": progress_cb,
"subproc_env": _agent_subprocess_env(),
"session_id": session_id,
"owner": owner,
}
try:
result = await PrivateBrowserTool().execute(operation.input, ctx)
except asyncio.CancelledError:
raise
except Exception as error:
logger.warning("legacy private_browser failed: %s", error)
return f"{transport}: ERROR", {"error": f"private_browser: {error}", "exit_code": 1}
if not isinstance(result, dict):
result = {"output": str(result), "exit_code": 0}
label = "ERROR" if result.get("error") or result.get("exit_code") not in (None, 0) else "EXECUTED"
return f"{transport}: {label}", result
async def _direct_fallback(
@@ -1735,6 +1756,12 @@ 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)
if operation.tool == "private_browser":
try:
_, browser_args = parse_browser_operation(operation.input)
@@ -2603,4 +2630,4 @@ def format_tool_result(description: str, result: Dict) -> str:
# External execution bridges are allowed to return canonical result shapes
# without using the built-in tools' output cap. Bound the final model-facing
# text here so one verbose command cannot consume the next request window.
return _truncate("\n".join(parts))
return _truncate("\n".join(parts))
+9 -1
View File
@@ -2000,6 +2000,14 @@ FUNCTION_TOOL_SCHEMAS = [
},
]
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
FUNCTION_TOOL_SCHEMAS[:] = [
_PB_LEGACY if (schema.get("function") or {}).get("name") == "private_browser" else schema
for schema in FUNCTION_TOOL_SCHEMAS
]
# ---------------------------------------------------------------------------
@@ -2460,4 +2468,4 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
else:
content = json.dumps(args)
return ToolBlock(tool_type, content)
return ToolBlock(tool_type, content)
+9 -1
View File
@@ -6149,7 +6149,15 @@ import { invalidateSettings } from './appConfig.js';
cache: 'no-store',
});
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
if (res.status !== 404) return;
// The server now reports "no active stream" as 200 {status:"idle"} (an
// older server used 404); a body with status "streaming" means it is NOT
// stale. Accept both so the watchdog keeps working across deploys.
if (res.status !== 404 && !res.ok) return;
let _probe = null;
try { _probe = await res.json(); } catch (_) { _probe = null; }
const _noActiveStream = res.status === 404 || (res.ok && _probe &&
typeof _probe.status === 'string' && _probe.status !== 'streaming');
if (!_noActiveStream) return;
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
+31
View File
@@ -0,0 +1,31 @@
"""A valid login session cookie must survive a deploy restart of the server.
AuthManager keeps tokens in memory but persists them to sessions.json on create
and reloads them at startup, so a restart must NOT log the user out (the 302 the
bench saw was the restart gap / a client-side artifact, not session loss).
"""
from core import auth
def test_session_token_survives_a_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
assert first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
assert token and first.validate_token(token)
# Simulate a process restart: a brand-new AuthManager on the same data dir.
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is True
assert second.get_username_for_token(token) == "pewds"
def test_revoked_token_does_not_survive_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
first.revoke_token(token)
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is False
+220
View File
@@ -3511,6 +3511,10 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
'web_fetch': ({'url': 'https://example.com'}, 'read this page'),
'web_search': ({'query': 'current AI news'}, 'search the web'),
'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'),
'plan_edit': ({'prompt': 'make it black and white'}, 'make it black and white'),
'run_editor_command': ({'op': 'document.info'}, 'inspect the document'),
'get_editor_screenshot': ({}, 'take a screenshot of the editor'),
'ai_edit_image': ({'op': 'remove-bg'}, 'remove the background'),
}
schemas = {
schema['function']['name']: schema
@@ -3537,6 +3541,9 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
}
else {'image_editing'} if name == 'edit_image'
else {'image_generation'} if name == 'generate_image'
else {'photo_editor'} if name in {
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
}
else frozenset()
),
contract_required_tools={name},
@@ -7284,3 +7291,216 @@ async def test_tool_round_leadin_is_replaced_by_terminal_synthesis(monkeypatch):
"I'll browse IKEA and look at their chairs.",
'The most epic option is the DYVLINGE swivel chair.',
]
def test_spec_router_think_decision_and_reason(monkeypatch):
import sys
import types
from src.clean_agent_preview import (
spec_router_thinking_decision,
spec_router_think_reason,
)
# --- router disabled -> no decision, no reason (default OFF) -------------
fake = types.ModuleType('src.spec_router')
fake.think_router_enabled = lambda: False
fake.route_thinking = lambda text: {'think': True, 'p_think': 0.9, 'ms': 1.0}
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- enabled but not a spec surface -> no decision ----------------------
fake.think_router_enabled = lambda: True
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'native', raising=False,
)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
# --- enabled on spec + think=True -> reason only on round 1 when no other
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
decision = spec_router_thinking_decision('u', 'm', 'o', {}, 'plan the refactor')
assert decision == {'think': True, 'p_think': 0.9, 'ms': 1.0}
assert spec_router_think_reason(1, decision, '') == 'router_think'
# existing rule wins; only round 1; think=False never fires
assert spec_router_think_reason(1, decision, 'code') == ''
assert spec_router_think_reason(2, decision, '') == ''
assert spec_router_think_reason(1, {'think': False}, '') == ''
# --- route_thinking raises -> fail open (None), rules only --------------
def _boom(text):
raise RuntimeError('router down')
fake.route_thinking = _boom
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- wiring present in stream_preview -----------------------------------
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'spec_router_thinking_decision(' in source
assert 'spec_router_think_reason(' in source
def test_spec_direct_no_tools_decision(monkeypatch):
import sys
import types
from src.clean_agent_preview import spec_direct_no_tools_p
fake = types.ModuleType('src.spec_router')
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
]
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
# --- default OFF -> no override, identical behaviour --------------------
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- ON + answer_directly p >= 0.9 -> returns the confidence ------------
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.971
# --- low confidence -> no override --------------------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.5},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- MIN_P override lowers the bar --------------------------------------
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.4')
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.5
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
# --- a non answer_directly choice -> no override ------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'web_search', 'answer_directly': 0.971},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- stale decision for a different message -> no override --------------
fake.LAST_DECISIONS = [
{'message': 'something else entirely', 'choice': 'answer_directly',
'answer_directly': 0.99},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- non-spec surface -> no override ------------------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
]
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'native', raising=False,
)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- router error -> fail open (None) -----------------------------------
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
class _Boom:
@property
def LAST_DECISIONS(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
# --- wiring present in stream_preview -----------------------------------
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'spec_direct_no_tools_p(' in source
assert '[spec-harness] direct answer' in source
def test_action_promise_ignores_fenced_code_and_knows_browse_verbs():
import src.clean_agent_preview as module
gym_reply = (
'I will browse Google Maps to find the closest gym near Todoroki.\n\n'
'```python\n'
'import requests\n'
'from bs4 import BeautifulSoup\n'
'resp = requests.get("https://maps.google.com/search?q=gym+todoroki")\n'
'soup = BeautifulSoup(resp.text, "html.parser")\n'
'for row in soup.select(".section-result"):\n'
' print(row.get_text())\n'
'```\n'
)
assert module.action_promise_response(gym_reply)
assert module.action_promise_response('I will fetch the page and scrape the listing.')
assert module.action_promise_response('Let me search for the nearest gym now.')
assert module.action_promise_response("I'll go check Google Maps for you.")
# A real answer that includes a code block is not an action promise.
assert not module.action_promise_response(
'The closest gym to Todoroki is Gold\u2019s Gym, about 600 m away.\n\n'
'```python\n'
'print("done")\n'
'```\n'
)
assert not module.action_promise_response('Here is the script you asked for:\n```\nprint(1)\n```')
@pytest.mark.parametrize('prompt', [
'please explain how a student should break down work about customer feedback; this is general advice only, do not create or schedule anything.',
'Give general advice about customer feedback and task planning; do not create anything.',
'Explain feedback tasks without scheduling anything.',
])
def test_feedback_advice_does_not_claim_write_authority(prompt):
assert not requests_mutation(prompt)
def test_feedback_subject_does_not_hide_real_write_request():
assert requests_mutation('Schedule a customer feedback task for tomorrow.')
assert requests_mutation('Do not create anything; schedule a customer feedback task tomorrow.')
@pytest.mark.asyncio
async def test_direct_feedback_advice_is_not_replaced_with_preview_denial(monkeypatch):
import src.clean_agent_preview as module
answer = 'Set a goal, group the customer comments by theme, and summarize what you learned.'
class Response:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': answer}}]})
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
assert 'tools' not in kwargs['json']
return Response()
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
prompt = ('please explain how a student should break down work about customer feedback; '
'this is general advice only, do not create or schedule anything.')
chunks = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='ajax-ablated500-base',
messages=[{'role': 'user', 'content': prompt}], headers={},
turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(),
)]
events = [json.loads(chunk[6:]) for chunk in chunks if '[DONE]' not in chunk]
assert ''.join(event.get('delta', '') for event in events) == answer
assert not any(event.get('type') == 'final_response' and event.get('content') == denied_response()
for event in events)
@@ -0,0 +1,26 @@
from src.agent_evidence import infer_completion_requirements
from src.clean_agent_preview import active_email_context_message, email_identifier_error
def test_open_reader_uid_is_valid_email_evidence():
reader = active_email_context_message({
'uid': '3971', 'folder': 'INBOX', 'account': 'account-1',
})
assert email_identifier_error(
'mcp__email__read_email',
{'uid': '3971', 'folder': 'INBOX', 'account': 'account-1'},
user_text='Summarize this email', history=[reader],
) is None
assert email_identifier_error(
'mcp__email__read_email', {'uid': '3972'},
user_text='Summarize this email', history=[reader],
)
def test_pasted_email_address_does_not_become_required_artifact():
instruction = (
'Summarize this email\r\n'
'If you do not wish to make use of this service, please send a mail '
'to news.alerts@example.test.\r\n'
)
assert infer_completion_requirements(instruction).required_artifacts == ()
+1 -1
View File
@@ -386,7 +386,7 @@ def test_gallery_image_endpoint_lookups_are_owner_scoped():
assert body.count("_first_visible_image_endpoint(db, user)") >= 4
assert body.count("_visible_image_endpoint_for_base(db,") >= 2
assert "def _current_user_is_admin" in body
assert body.count('raise HTTPException(403, "Choose a registered image endpoint")') == 2
assert body.count('raise HTTPException(403, "Choose a registered image endpoint")') == 3
for marker in (
"async def gallery_ai_upscale",
"async def gallery_style_transfer",
+2 -1
View File
@@ -148,6 +148,7 @@ def _chat_stream_endpoint(
endpoint_url="https://selected.example/v1",
session_model="selected-model",
session_history=(),
attachment_meta=(),
):
def add_message(message):
captured.setdefault("added_messages", []).append(message)
@@ -172,7 +173,7 @@ def _chat_stream_endpoint(
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "hello"},
],
preprocessed=SimpleNamespace(attachment_meta=[]),
preprocessed=SimpleNamespace(attachment_meta=list(attachment_meta)),
auto_opened_docs=[],
rag_sources=[],
web_sources=[],
+92
View File
@@ -0,0 +1,92 @@
"""Tests for the jeff-serve decision client and the email-urgency adapter path.
Covers the calibrated-score mapping plus the two high-precision overrides
(promotions and "waiting outside") that the prompt path also applies. All
network is mocked, so these run offline.
"""
import asyncio
from src import builtin_actions as ba
from src import jeff_decision as jd
def _answer(score):
return {"urgency": {"type": "score", "score": score, "probabilities": {},
"legend": {}, "confidence": 0.9}}
def _run(coro):
return asyncio.run(coro)
def _enable(monkeypatch, score):
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "1")
async def fake(*a, **k):
return _answer(score)
monkeypatch.setattr(jd, "jeff_systemone", fake)
def test_score_value_and_choice_of():
assert jd.score_value(_answer(2.5)["urgency"]) == 2.5
assert jd.score_value({"type": "choice", "probabilities": {"a": 0.25, "b": 0.75}}) == 0.75
assert jd.choice_of({"type": "choice", "choice": "b"}) == "b"
assert jd.score_value(None) is None
assert jd.choice_of(None) is None
def test_disabled_job_does_not_call_adapter(monkeypatch):
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "0")
calls = {"n": 0}
async def fake(*a, **k):
calls["n"] += 1
return _answer(4)
monkeypatch.setattr(jd, "jeff_systemone", fake)
out = _run(ba._spec_triage_email_urgency({"subject": "x", "body": "y"}, triage_version=11))
assert out is None
assert calls["n"] == 0
def test_high_score_maps_to_urgent(monkeypatch):
_enable(monkeypatch, 4)
v = _run(ba._spec_triage_email_urgency(
{"subject": "Locked out", "from": "A <a@b>", "body": "help now", "uid": "1", "message_id": "m"},
triage_version=11))
assert v["score"] == 3 and v["tags"] == ["urgent"] and v["triage_version"] == 11
def test_low_score_maps_to_zero(monkeypatch):
_enable(monkeypatch, 0)
v = _run(ba._spec_triage_email_urgency({"subject": "FYI", "body": "no action needed", "uid": "2"},
triage_version=11))
assert v["score"] == 0 and v["tags"] == []
def test_promotion_is_overridden_to_zero(monkeypatch):
_enable(monkeypatch, 4)
v = _run(ba._spec_triage_email_urgency(
{"subject": "FLASH SALE", "body": "discount, limited time, shop now, coupon", "uid": "3"},
triage_version=11))
assert v["score"] == 0 and v["reason"].startswith("bulk")
def test_waiting_outside_forces_urgent(monkeypatch):
_enable(monkeypatch, 0.2)
v = _run(ba._spec_triage_email_urgency(
{"subject": "Deliveries", "body": "I am waiting outside, can't get in", "uid": "4"},
triage_version=11))
assert v["score"] == 3
def test_adapter_unavailable_returns_none(monkeypatch):
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "1")
async def fake(*a, **k):
return None
monkeypatch.setattr(jd, "jeff_systemone", fake)
assert _run(ba._spec_triage_email_urgency({"subject": "x", "body": "y"}, triage_version=11)) is None
+29
View File
@@ -0,0 +1,29 @@
import pytest
from services.memory.memory_extractor import _has_user_fact_evidence, _is_text_duplicate
@pytest.mark.parametrize('content,evidence,expected', [
('I live in Lisbon.', 'I live in Lisbon', True),
('I live in Lisbon. Can you find cafes?', 'I live in Lisbon', True),
('Can you tell me where I live?', 'I live in Lisbon', False),
('I live in Lisbon?', 'I live in Lisbon', False),
('Please read my records.', 'My home is in Lisbon', False),
])
def test_extracted_memories_require_user_assertion(content, evidence, expected):
assert _has_user_fact_evidence(
{'text': 'User lives in Lisbon', 'evidence': evidence},
[{'role': 'user', 'content': content}],
) is expected
def test_assistant_statement_does_not_ground_memory():
assert not _has_user_fact_evidence(
{'text': 'User lives in Lisbon', 'evidence': 'I live in Lisbon'},
[{'role': 'assistant', 'content': 'I live in Lisbon'}],
)
def test_name_dedup_accepts_refinement_but_preserves_different_slots():
assert _is_text_duplicate("User's name is Alex.", [{'text': 'Name is Alex Morgan'}])
assert not _is_text_duplicate("User's username is alex.", [{'text': "User's name is Alex."}])
@@ -54,7 +54,7 @@ class FakeSession:
def get_context_messages(self):
return [
{"role": "user", "content": "Tell me where I live."},
{"role": "user", "content": "My home is in Lisbon. My role is architect."},
{"role": "assistant", "content": "Noted."},
]
@@ -118,7 +118,7 @@ def test_vector_match_from_other_tenant_does_not_drop_users_fact(monkeypatch):
])
# The vector store reports user B's new fact as a near-duplicate of a1.
vec = FakeVector(match_id="a1")
_install_llm_stub(monkeypatch, '["My home is in Lisbon"]')
_install_llm_stub(monkeypatch, '[{"text":"My home is in Lisbon","evidence":"My home is in Lisbon"}]')
memory_extractor = _load_extractor()
@@ -147,7 +147,7 @@ def test_identity_auto_pin_stops_after_first_five_for_owner(monkeypatch):
])
_install_llm_stub(
monkeypatch,
'[{"text": "New identity that should not stay pinned", "category": "identity"}]',
'[{"text": "User works as an architect", "category": "identity", "evidence": "My role is architect"}]',
)
memory_extractor = _load_extractor()
@@ -158,7 +158,7 @@ def test_identity_auto_pin_stops_after_first_five_for_owner(monkeypatch):
))
rows = mm.load(owner="userA")
added = next(r for r in rows if r["text"] == "New identity that should not stay pinned")
added = next(r for r in rows if r["text"] == "User works as an architect")
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
@@ -31,7 +31,7 @@ class _FakeSession:
def get_context_messages(self):
return [
{"role": "user", "content": "Hi, a few things about me."},
{"role": "user", "content": "I live in Lisbon. I prefer tea over coffee."},
{"role": "assistant", "content": "Noted."},
]
@@ -54,8 +54,8 @@ def _run(coro):
def test_extraction_persists_facts_when_vector_store_fails_at_runtime(monkeypatch):
facts_json = (
'[{"text": "Alice lives in Lisbon", "category": "fact"}, '
'{"text": "Alice prefers tea over coffee", "category": "preference"}]'
'[{"text": "Alice lives in Lisbon", "category": "fact", "evidence": "I live in Lisbon"}, '
'{"text": "Alice prefers tea over coffee", "category": "preference", "evidence": "I prefer tea over coffee"}]'
)
async def _fake_llm(url, model, messages, **kwargs):
@@ -94,7 +94,7 @@ def test_healthy_vector_store_still_dedups_normally(monkeypatch):
memory, so the dedup must still fire."""
async def _fake_llm(url, model, messages, **kwargs):
return '[{"text": "Alice lives in Lisbon", "category": "fact"}]'
return '[{"text": "Alice lives in Lisbon", "category": "fact", "evidence": "I live in Lisbon"}]'
monkeypatch.setattr(src.llm_core, "llm_call_async", _fake_llm)
monkeypatch.setattr(src.event_bus, "fire_event", lambda *a, **k: None)
+145
View File
@@ -0,0 +1,145 @@
"""Model-picker backend: a slow/dead endpoint must not stall /api/models refresh.
Covers the fixes in routes/model_routes.py:
- the local endpoint refresh timeout default is 2s (was 10s);
- a recently failed/timed-out endpoint keeps a ~60s cooldown even on a
user-forced refresh, so a forced refresh does not re-probe a dead endpoint
on every picker open;
- probing is parallel, so one slow endpoint does not push the whole refresh
past its own timeout.
"""
import json
import sys
import time
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.import_state import (
clear_fake_endpoint_resolver_modules,
preserve_import_state,
)
def _load_model_routes():
with preserve_import_state(
"core.database", "src.database", "core.session_manager", "routes.model_routes"
):
clear_fake_endpoint_resolver_modules()
if "core.database" not in sys.modules:
stub = types.ModuleType("core.database")
for name in (
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note", "CalendarCal",
"CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
"ProviderAuthSession", "Base",
):
setattr(stub, name, MagicMock())
stub.utcnow_naive = MagicMock()
sys.modules["core.database"] = stub
import routes.model_routes as model_routes
return model_routes
def _endpoint(**kw):
base = dict(
id="ep", base_url="http://127.0.0.1:9/v1", api_key="k",
provider_auth_id=None, cached_models="[]", pinned_models=[],
is_enabled=True, model_refresh_mode="auto",
)
base.update(kw)
return SimpleNamespace(**base)
def test_local_refresh_timeout_is_two_seconds():
model_routes = _load_model_routes()
ep = _endpoint(base_url="http://100.67.207.85:8071/v1")
assert model_routes._endpoint_refresh_timeout(ep, "local") == 2.0
assert model_routes._endpoint_refresh_timeout(ep, "remote") == 2.0
def test_recent_failure_is_not_reprobed_even_when_forced():
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
should = router._should_refresh_endpoint
ep = _endpoint(id="remote-dead", base_url="http://100.67.207.85:8071/v1")
now = time.time()
ok, info = should(ep, now, force=True)
assert ok is True and info.get("base")
key = info["key"]
# A failure one second ago keeps a 60s cooldown even on a forced refresh.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 1.0, "inflight": False,
}
ok_recent, _ = should(ep, time.time(), force=True)
assert ok_recent is False
# A failure long past the 60s floor is re-probed again.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 120.0, "inflight": False,
}
ok_old, _ = should(ep, time.time(), force=True)
assert ok_old is True
class _FakeQuery:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
def filter(self, *args, **kwargs):
return self
def all(self):
return list(self._rows)
def first(self):
return self._first
class _FakeDB:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
self.committed = False
def query(self, model):
return _FakeQuery(self._rows, self._first)
def commit(self):
self.committed = True
def close(self):
pass
def test_one_slow_endpoint_does_not_stall_the_forced_refresh(monkeypatch):
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
healthy = _endpoint(id="healthy", base_url="http://127.0.0.1:7021/v1")
dead = _endpoint(id="dead", base_url="http://127.0.0.1:9/v1")
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDB([healthy, dead], healthy))
monkeypatch.setattr(
model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False
)
def _fake_probe(base, api_key=None, timeout=5):
if "127.0.0.1:9" in base:
time.sleep(min(float(timeout or 2), 2.0))
raise RuntimeError("timed out")
return ["healthy-model"]
monkeypatch.setattr(model_routes, "_probe_endpoint", _fake_probe)
started = time.time()
done = router._refresh_caches_bg(force=True)
assert done is not None
assert done.wait(5) is True
elapsed = time.time() - started
assert elapsed <= 3.0, f"forced refresh took {elapsed:.2f}s"
assert json.loads(healthy.cached_models) == ["healthy-model"]
+108
View File
@@ -0,0 +1,108 @@
"""Session-load 404 regression (passover item #9).
Loading a chat session used to fire two benign 404s in the browser console:
GET /api/research/status/<id> (no research for this session)
GET /api/chat/stream_status/<id> (no active stream for this session)
Both are now a normal "empty" state: HTTP 200 with an explicit status
("none" / "idle"). The clients already branch on the body status, so this is
backward compatible (an older server's 404 is still tolerated).
"""
import asyncio
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock
from routes import chat_routes
from routes import research_routes
def _route_endpoint(router, path, method="GET"):
for route in router.routes:
if getattr(route, "path", "") != path:
continue
if method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"{method} {path} not registered")
def _req(user="alice"):
return SimpleNamespace(state=SimpleNamespace(current_user=user))
# --- /api/research/status/<id> -------------------------------------------
def test_research_status_returns_200_none_when_absent(tmp_path, monkeypatch):
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
handler = MagicMock()
handler._active_tasks = {}
router = research_routes.setup_research_routes(handler)
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
assert out == {"status": "none"}
def test_research_status_returns_status_when_present(tmp_path, monkeypatch):
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
handler = MagicMock()
handler._active_tasks = {"sess-abc": {"owner": "alice"}}
handler.get_status.return_value = {"status": "running", "progress": {"phase": "searching"}}
handler.get_source_state.return_value = None
handler.get_source_coverage.return_value = None
handler.get_navigation_trace.return_value = None
handler.get_action_trace.return_value = None
router = research_routes.setup_research_routes(handler)
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
assert out["status"] == "running"
# --- /api/chat/stream_status/<id> ----------------------------------------
def _chat_stream_status_endpoint(monkeypatch):
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda request, session_id: None)
router = chat_routes.setup_chat_routes(
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
)
return _route_endpoint(router, "/api/chat/stream_status/{session_id}")
def test_stream_status_returns_200_idle_when_no_stream(monkeypatch):
endpoint = _chat_stream_status_endpoint(monkeypatch)
chat_routes._active_streams.pop("sess-idle", None)
out = asyncio.run(endpoint(SimpleNamespace(), "sess-idle"))
assert out == {"status": "idle"}
def test_stream_status_returns_record_when_streaming(monkeypatch):
endpoint = _chat_stream_status_endpoint(monkeypatch)
chat_routes._active_streams["sess-live"] = {"status": "streaming", "mode": "agent"}
try:
out = asyncio.run(endpoint(SimpleNamespace(), "sess-live"))
finally:
chat_routes._active_streams.pop("sess-live", None)
assert out["status"] == "streaming"
# --- client compatibility (source-level) ---------------------------------
def test_chat_watchdog_accepts_200_idle_body():
src = inspect.getsource(chat_routes) # ensure chat_routes imported
chatjs = open("static/js/chat.js", encoding="utf-8").read()
# The stale-stream watchdog must key off the body status, not only a 404.
assert "_noActiveStream" in chatjs
assert "_probe.status !== 'streaming'" in chatjs
assert src # keep flake happy
def test_sessions_check_uses_body_status_not_ok_only():
sj = open("static/js/sessions.js", encoding="utf-8").read()
assert "info.status !== 'streaming'" in sj
assert "status !== 'streaming'" in sj
+243
View File
@@ -0,0 +1,243 @@
import json
import sys
import types
from contextlib import asynccontextmanager
import pytest
from src import clean_agent_preview as runner
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.turn_contract import (
requested_capabilities,
resolve_turn_contract,
selected_tools_for_request,
)
PROMPT = 'Search the web for the Ajax population.'
def _contract(prompt=PROMPT):
policy = ToolPolicy()
selected = selected_tools_for_request(prompt)
return resolve_turn_contract(
capabilities=requested_capabilities(prompt),
schemas=FUNCTION_TOOL_SCHEMAS,
policy=policy,
selected_tools=selected,
message=prompt,
), policy
def _install_spec_router(monkeypatch, *, p):
fake = types.ModuleType('src.spec_router')
fake.LAST_DECISIONS = [
{'message': PROMPT, 'choice': 'answer_directly', 'answer_directly': p},
]
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
def _fake_response(captured):
class Response:
def raise_for_status(self):
pass
async def aiter_lines(self):
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': 'Hi there.'}}]})
yield 'data: [DONE]'
@asynccontextmanager
async def response(*args, **kwargs):
request = args[3] if len(args) > 3 else kwargs.get('request')
captured.append(request)
yield Response()
return response
async def _run(monkeypatch, captured):
monkeypatch.setattr(runner, 'preview_model_response', _fake_response(captured))
contract, policy = _contract()
events = []
async for chunk in runner.stream_preview(
endpoint_url='http://fixture', model='Ajax', headers={},
messages=[{'role': 'user', 'content': PROMPT}], turn_contract=contract,
session_id='spec-direct', owner='fixture', disabled_tools=set(),
tool_policy=policy, thinking_mode='off', max_rounds=1,
):
if chunk.startswith('data: ') and '[DONE]' not in chunk:
events.append(json.loads(chunk[6:]))
return contract, events
def _sent_tools(captured):
first = captured[0]
return {(s.get('function') or {}).get('name') for s in (first.get('tools') or [])}
async def _control_tools(monkeypatch):
"""Tools offered for the identical request with the spec feature disabled.
Upstream's harness applies its own round-1 tool policy (bounded research,
permitted-schema filtering), so the spec feature is only required to leave
that list untouched rather than match the raw contract.offered set.
"""
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
control = []
await _run(monkeypatch, control)
assert control
return _sent_tools(control)
@pytest.mark.asyncio
async def test_spec_direct_round1_forces_tool_choice_none_with_same_tools(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
_install_spec_router(monkeypatch, p=0.971)
captured = []
contract, _events = await _run(monkeypatch, captured)
assert captured, 'stream_preview made a provider round'
first = captured[0]
assert first.get('tool_choice') == 'none'
sent = _sent_tools(captured)
assert sent == await _control_tools(monkeypatch) # tools list unchanged by spec
assert sent # the offered set is non-empty here
@pytest.mark.asyncio
async def test_spec_direct_low_confidence_is_unchanged(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
_install_spec_router(monkeypatch, p=0.5)
captured = []
await _run(monkeypatch, captured)
assert captured
assert captured[0].get('tool_choice') != 'none'
@pytest.mark.asyncio
async def test_spec_direct_env_off_is_identical(monkeypatch):
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
_install_spec_router(monkeypatch, p=0.971)
captured = []
await _run(monkeypatch, captured)
assert captured
assert captured[0].get('tool_choice') != 'none'
@pytest.mark.asyncio
async def test_spec_direct_router_error_is_unchanged(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
class _Boom:
@property
def LAST_DECISIONS(self):
raise RuntimeError('router down')
monkeypatch.setitem(sys.modules, 'src.spec_router', _Boom())
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
captured = []
await _run(monkeypatch, captured)
assert captured
assert captured[0].get('tool_choice') != 'none'
def _fake_response_with_text(captured, text):
class Response:
def raise_for_status(self):
pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(
{'choices': [{'delta': {'content': text}}]})
yield 'data: [DONE]'
@asynccontextmanager
async def response(*args, **kwargs):
request = args[3] if len(args) > 3 else kwargs.get('request')
captured.append(request)
yield Response()
return response
def test_spec_direct_mode_helper_defaults_to_tool_choice_none(monkeypatch):
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MODE', raising=False)
assert runner.spec_direct_mode() == 'tool_choice_none'
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'empty_menu')
assert runner.spec_direct_mode() == 'empty_menu'
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'nonsense')
assert runner.spec_direct_mode() == 'tool_choice_none'
@pytest.mark.asyncio
async def test_spec_direct_empty_menu_sends_no_tools_and_thinking_off(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'empty_menu')
_install_spec_router(monkeypatch, p=0.971)
captured = []
await _run(monkeypatch, captured)
assert captured, 'stream_preview made a provider round'
first = captured[0]
assert not (first.get('tools') or []) # no tools offered at all
assert first.get('tool_choice') in (None, 'none')
# Think router did not decide -> thinking stays off for the round.
assert first.get('chat_template_kwargs', {}).get('enable_thinking') is False
@pytest.mark.asyncio
async def test_spec_direct_default_mode_keeps_tool_choice_none(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'tool_choice_none')
_install_spec_router(monkeypatch, p=0.971)
captured = []
contract, _events = await _run(monkeypatch, captured)
assert captured
first = captured[0]
assert first.get('tool_choice') == 'none'
sent = _sent_tools(captured)
assert sent == await _control_tools(monkeypatch) # tools list unchanged by spec
@pytest.mark.asyncio
async def test_spec_direct_empty_menu_does_not_execute_text_tool_call(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'empty_menu')
_install_spec_router(monkeypatch, p=0.971)
executed = []
async def _recorder(*args, **kwargs):
executed.append(args)
return ('', {'exit_code': 0})
monkeypatch.setattr(runner, 'execute_tool_block', _recorder, raising=False)
markup = ('Sure thing. '
'<tool_call>{"name": "web_search", "arguments": {"query": "Ajax"}}'
'</tool_call> Anything else?')
monkeypatch.setattr(
runner, 'preview_model_response', _fake_response_with_text([], markup))
contract, policy = _contract()
events = []
async for chunk in runner.stream_preview(
endpoint_url='http://fixture', model='Ajax', headers={},
messages=[{'role': 'user', 'content': PROMPT}], turn_contract=contract,
session_id='spec-direct-empty', owner='fixture', disabled_tools=set(),
tool_policy=policy, thinking_mode='off', max_rounds=1,
):
if chunk.startswith('data: ') and '[DONE]' not in chunk:
events.append(json.loads(chunk[6:]))
assert executed == [] # nothing executed: nothing was offered
visible = ''.join(
str(e.get('content') or e.get('delta') or '') for e in events)
assert '<tool_call' not in visible # markup stripped from the answer
assert 'web_search' not in visible
+334
View File
@@ -0,0 +1,334 @@
import json
import sys
import types
from contextlib import asynccontextmanager
from pathlib import Path
import pytest
from src import clean_agent_preview as runner
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.turn_contract import (
requested_capabilities,
resolve_turn_contract,
selected_tools_for_request,
)
PROMPT = 'Search the web for the Ajax population.'
def _contract(prompt=PROMPT):
policy = ToolPolicy()
selected = selected_tools_for_request(prompt)
return resolve_turn_contract(
capabilities=requested_capabilities(prompt),
schemas=FUNCTION_TOOL_SCHEMAS,
policy=policy,
selected_tools=selected,
message=prompt,
), policy
def _install_driver_router(monkeypatch, decision, picks=None):
"""Fake src.spec_router exposing spec_route_tools + LAST_DECISIONS."""
fake = types.ModuleType('src.spec_router')
fake.think_router_enabled = lambda: False
fake.LAST_DECISIONS = [decision]
def _spec_route_tools(user_message, schemas, disabled_tools=None,
messages=None, return_decision=False):
if picks is not None:
chosen = picks
elif decision.get('tool_name'):
chosen = [decision['tool_name']]
else:
chosen = list(decision.get('tools') or [])
return (chosen, True) if return_decision else chosen
fake.spec_route_tools = _spec_route_tools
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
def _fake_response(captured, scripts):
pending = list(scripts)
class Response:
def __init__(self, script=None):
self._s = script or {}
def raise_for_status(self):
pass
async def aiter_lines(self):
calls = self._s.get('tool_calls')
if calls:
for i, c in enumerate(calls):
yield 'data: ' + json.dumps({'choices': [{'delta': {'tool_calls': [
{'index': i, 'id': f'c{i}', 'type': 'function',
'function': {'name': c['name'],
'arguments': c.get('arguments') or '{}'}}]}}]})
else:
content = self._s.get('content', 'Hi.')
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': content}}]})
yield 'data: [DONE]'
@asynccontextmanager
async def response(*args, **kwargs):
captured.append(args[3] if len(args) > 3 else kwargs.get('request'))
yield Response(pending.pop(0) if pending else None)
return response
async def _run(monkeypatch, captured, model='odysseus-agentic-v4', scripts=None,
max_rounds=1):
monkeypatch.setattr(
runner, 'preview_model_response', _fake_response(captured, scripts or []))
contract, policy = _contract()
events = []
async for chunk in runner.stream_preview(
endpoint_url='http://fixture', model=model, headers={},
messages=[{'role': 'user', 'content': PROMPT}], turn_contract=contract,
session_id='spec-driver', owner='fixture', disabled_tools=set(),
tool_policy=policy, thinking_mode='off', max_rounds=max_rounds,
):
if chunk.startswith('data: ') and '[DONE]' not in chunk:
events.append(json.loads(chunk[6:]))
return contract, events
def _install_recorder(monkeypatch):
executed = []
async def _rec(block, *args, **kwargs):
executed.append(getattr(block, 'tool_type', None))
return 'ok', {'exit_code': 0, 'output': 'ok'}
monkeypatch.setattr(runner, 'execute_tool_block', _rec)
return executed
def test_spec_driver_active_gate(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False)
assert runner.spec_driver_active('http://x', 'm', 'o', {}) is True
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'strict')
assert runner.spec_driver_active('http://x', 'm', 'o', {}) is False
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'compact', raising=False)
assert runner.spec_driver_active('http://x', 'm', 'o', {}) is False
def test_spec_driver_pick_shapes(monkeypatch):
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
# tool pick at any confidence
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 't3', 'tools': ['web_fetch'],
'top': [('web_fetch', 0.12)], 'answer_directly': 0.2, 'ask_user': 0.1,
})
pick = runner.spec_driver_pick([{'role': 'user', 'content': PROMPT}], set(), PROMPT)
assert pick == {'choice': 'tool', 'tool_name': 'web_fetch', 'p': 0.12}
# tool pick below MIN_CONF: the router return is empty but top-1 is a tool
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 't5', 'tools': [],
'top': [('web_fetch', 0.12)], 'answer_directly': 0.2, 'ask_user': 0.1,
}, picks=[])
pick = runner.spec_driver_pick([], set(), PROMPT)
assert pick == {'choice': 'tool', 'tool_name': 'web_fetch', 'p': 0.12}
# answer_directly
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 'answer_directly', 'tools': [],
'top': [('answer_directly', 0.4)], 'answer_directly': 0.4, 'ask_user': 0.1,
})
pick = runner.spec_driver_pick([], set(), PROMPT)
assert pick['choice'] == 'answer_directly' and pick['p'] == 0.4
# ask_user
_install_driver_router(monkeypatch, {
'message': PROMPT, 'choice': 'ask_user', 'tools': [],
'top': [('ask_user', 0.3)], 'answer_directly': 0.1, 'ask_user': 0.3,
})
pick = runner.spec_driver_pick([], set(), PROMPT)
assert pick['choice'] == 'ask_user'
# router error / empty decision -> fall back to the normal menu
_install_driver_router(monkeypatch, {'message': PROMPT, 'error': 'timeout'})
assert runner.spec_driver_pick([], set(), PROMPT) is None
@pytest.mark.asyncio
async def test_spec_driver_tool_pick_sends_only_that_tool(monkeypatch):
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'])
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)
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]
def test_spec_driver_wired_in_stream_preview():
src = Path(runner.__file__).read_text()
assert '[spec-driver] round=' in src
assert 'spec_driver_pick(' in src
assert '[spec-driver] dropped off-pick call name=' in src
def test_spec_contract_offered_extends_the_model_runtime(monkeypatch):
"""A Spec pick outside PREVIEW_TOOLS must still be runnable: on a
spec-router turn the contract's offered set is part of the runtime."""
import types
from src import turn_contract as tc
from src.clean_agent_preview import PREVIEW_TOOLS, evaluate_preview_call
assert 'get_weather' not in PREVIEW_TOOLS
class _C:
routing_experiment = 'spec-router'
offered = frozenset({'get_weather'})
monkeypatch.setattr(tc, 'active_turn_contract', lambda: _C())
allowed = evaluate_preview_call(
'get_weather', {'location': 'Tokyo'}, 'weather',
turn_authorized_families=frozenset(),
)
assert allowed.allowed, allowed
class _Plain:
routing_experiment = 'baseline'
offered = frozenset({'get_weather'})
monkeypatch.setattr(tc, 'active_turn_contract', lambda: _Plain())
denied = evaluate_preview_call(
'get_weather', {'location': 'Tokyo'}, 'weather',
turn_authorized_families=frozenset(),
)
assert not denied.allowed
assert denied.reason == 'tool_not_in_model_runtime'
+175
View File
@@ -0,0 +1,175 @@
"""The Spec router may select a tool the request policy has withheld."""
import json
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 resolve_turn_contract
PROMPT = "open twitter and see what elon tweeted last"
@pytest.fixture(autouse=True)
def isolate_active_contract():
from src.turn_contract import _ACTIVE_CONTRACT
token = _ACTIVE_CONTRACT.set(None)
yield
_ACTIVE_CONTRACT.reset(token)
def _route(monkeypatch, disabled, *, subset="1", base_browser=True, hidden=(),
mode="strict", inherited_unavailable=()):
fake = types.ModuleType("src.spec_router")
fake.LAST_DECISIONS = [{
"choice": "t62", "top": [("private_browser", 0.859)],
}]
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")
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setenv("ODYSSEUS_SPEC_MODE", mode)
monkeypatch.setenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", subset)
# Full schema inventory and an actual request policy are present. The
# positive case can execute the browser; the negative case cannot.
policy = ToolPolicy(disabled_tools=frozenset(disabled), hidden_tools=frozenset(hidden))
contract = resolve_turn_contract(
capabilities={"search_browser"}, required_capabilities=set(),
schemas=FUNCTION_TOOL_SCHEMAS,
policy=policy,
selected_tools={"private_browser"} if base_browser else {"ask_user"},
message=PROMPT,
)
if inherited_unavailable:
contract = replace(contract, unavailable=frozenset(inherited_unavailable))
return agent_loop._spec_router_contract(
contract, [{"role": "user", "content": PROMPT}],
set(disabled), "http://fixture", "Ajax", "fixture", {}, tool_policy=policy,
)
@pytest.mark.parametrize("subset", ["0", "1"])
def test_spec_browser_withheld_by_web_policy_never_reaches_model(monkeypatch, subset):
routed = _route(monkeypatch, {"private_browser"}, subset=subset)
assert routed.routing_experiment == "spec-policy-denied"
assert routed.unavailable == frozenset({"private_browser"})
assert routed.offered == frozenset()
assert routed.schema_json == ()
assert "Enable Web search" in agent_loop._spec_unavailable_reply(routed.unavailable)
assert "have not opened" in agent_loop._spec_unavailable_reply(routed.unavailable)
@pytest.mark.asyncio
async def test_spec_unavailable_contract_finishes_before_provider_call(monkeypatch):
routed = _route(monkeypatch, {"private_browser"})
async def unexpected_provider_path(*args, **kwargs):
raise AssertionError("the provider/runtime must not see a denied tool")
yield # pragma: no cover
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", unexpected_provider_path)
events = []
async for chunk in agent_loop.stream_agent_loop(
endpoint_url="http://fixture", model="Ajax",
messages=[{"role": "user", "content": PROMPT}],
turn_contract=routed, disabled_tools={"private_browser"},
tool_policy=ToolPolicy(disabled_tools=frozenset({"private_browser"})),
_is_teacher_run=True,
):
events.append(chunk)
parsed = [json.loads(chunk[6:]) for chunk in events if chunk.startswith("data: {")]
assert any(event.get("type") == "turn_contract" for event in parsed)
assert any("Enable Web search" in event.get("delta", "") for event in parsed)
assert not any(event.get("type") == "tool_start" for event in parsed)
assert events[-1] == "data: [DONE]\n\n"
@pytest.mark.parametrize("subset,base_browser", [("0", False), ("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"
assert not routed.unavailable
assert "private_browser" in routed.offered
assert "private_browser" in routed.executable
assert any(json.loads(schema)["function"]["name"] == "private_browser"
for schema in routed.schema_json)
def test_subset_control_does_not_confuse_base_omission_with_policy_denial(monkeypatch):
routed = _route(monkeypatch, set(), subset="1", base_browser=False)
assert not routed.unavailable
assert "private_browser" not in routed.offered
assert "private_browser" in routed.executable
@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.unavailable == frozenset({"manage_notes"})
async def fake_preview(**kwargs):
yield 'data: {"delta": "preview continued"}\n\n'
yield 'data: [DONE]\n\n'
monkeypatch.setattr("src.clean_agent_preview.stream_preview", fake_preview)
monkeypatch.setattr(agent_loop, "is_compact_preview_contract", lambda contract: True)
events = [chunk async for chunk in agent_loop.stream_agent_loop(
endpoint_url="http://fixture", model="Ajax",
messages=[{"role": "user", "content": PROMPT}],
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)
def test_policy_hidden_browser_is_denied_even_without_disabled_snapshot(monkeypatch):
routed = _route(monkeypatch, set(), subset="0", hidden={"private_browser"})
assert routed.unavailable == frozenset({"private_browser"})
assert not routed.offered
def test_guide_only_policy_keeps_its_existing_control_path(monkeypatch):
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: "spec")
contract = resolve_turn_contract(
capabilities=set(), required_capabilities=set(), schemas=FUNCTION_TOOL_SCHEMAS,
policy=ToolPolicy(), message="guide me only",
)
routed = agent_loop._spec_router_contract(
contract, [{"role": "user", "content": "guide me only"}],
set(), "http://fixture", "Ajax", "fixture", {},
tool_policy=ToolPolicy(block_all_tool_calls=True, mode="guide_only"),
)
assert routed is contract
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.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")
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setenv("ODYSSEUS_SPEC_MODE", "strict")
contract = resolve_turn_contract(
capabilities=set(), required_capabilities=set(),
schemas=FUNCTION_TOOL_SCHEMAS,
policy=ToolPolicy(disabled_tools=frozenset({"private_browser"})),
message="hello",
)
routed = agent_loop._spec_router_contract(
contract, [{"role": "user", "content": "hello"}],
{"private_browser"}, "http://fixture", "Ajax", "fixture", {},
)
assert "private_browser" not in routed.unavailable
@@ -0,0 +1,33 @@
"""Health audit: SSE client disconnects (e.g. /api/editor/bridge/events) surface
as anyio.EndOfStream through Starlette's BaseHTTPMiddleware. The slow-request
middleware must map that to a client-side 499, not a logged 500 traceback."""
import asyncio
import anyio
from starlette.requests import Request
from app import _SlowRequestLogMiddleware
def _request(path):
return Request({"type": "http", "method": "GET", "path": path, "headers": []})
def test_stream_disconnect_is_mapped_to_499():
async def call_next(_request):
raise anyio.EndOfStream
mw = _SlowRequestLogMiddleware(lambda *a: None)
resp = asyncio.run(mw.dispatch(_request("/api/editor/bridge/events"), call_next))
assert resp.status_code == 499
def test_real_responses_pass_through():
from starlette.responses import JSONResponse
async def call_next(_request):
return JSONResponse({"ok": True}, status_code=200)
mw = _SlowRequestLogMiddleware(lambda *a: None)
resp = asyncio.run(mw.dispatch(_request("/api/health"), call_next))
assert resp.status_code == 200
+151
View File
@@ -0,0 +1,151 @@
"""Inline-image vision path: direct image_url parts, downscale, probe, trimming.
Covers the change that sends attached images straight to the chat model as an
OpenAI image_url content part (instead of a separate vision-describe pre-step)
when the endpoint/model accepts images.
"""
from __future__ import annotations
import base64
import io
import pytest
from PIL import Image
from src import chat_helpers as ch
from src import document_processor as dp
from src.context_compactor import keep_recent_image_turns
def _png_bytes(width: int, height: int) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (width, height), (200, 40, 40)).save(buf, format="PNG")
return buf.getvalue()
# --- vision detection -------------------------------------------------------
def test_vision_model_env_override(monkeypatch):
monkeypatch.delenv("ODYSSEUS_VISION_MODELS", raising=False)
assert ch.is_vision_model("ajax-ablated500-base") is False
monkeypatch.setenv("ODYSSEUS_VISION_MODELS", "ajax, some-other")
assert ch.is_vision_model("ajax-ablated500-base") is True
assert ch.is_vision_model("SOME-OTHER-9B") is True
assert ch.is_vision_model("plain-text-model") is False
def test_probe_endpoint_supports_vision_cached(monkeypatch):
ch._vision_probe_cache.clear()
calls = []
class _Resp:
def __init__(self, status, text=""):
self.status_code = status
self.text = text
def _fake_post(url, json=None, timeout=None):
calls.append(url)
return _Resp(200, "")
monkeypatch.setattr(ch.httpx, "post", _fake_post)
assert ch.probe_endpoint_supports_vision("http://127.0.0.1:18607/v1", "ajax-ablated500-base") is True
# Second call is served from the cache (no extra POST).
assert ch.probe_endpoint_supports_vision("http://127.0.0.1:18607/v1", "ajax-ablated500-base") is True
assert len(calls) == 1
assert calls[0].endswith("/v1/chat/completions")
def test_probe_endpoint_rejects_image(monkeypatch):
ch._vision_probe_cache.clear()
class _Resp:
status_code = 400
text = '{"error":"this model does not support image content"}'
monkeypatch.setattr(ch.httpx, "post", lambda *a, **k: _Resp())
assert ch.probe_endpoint_supports_vision("http://127.0.0.1:18607/v1", "text-only-9b") is False
def test_probe_never_probes_remote_hosts(monkeypatch):
ch._vision_probe_cache.clear()
calls = []
monkeypatch.setattr(ch.httpx, "post", lambda *a, **k: calls.append(a) or None)
assert ch.probe_endpoint_supports_vision("https://api.openai.com/v1", "gpt-x") is None
assert calls == []
def test_model_supports_vision_uses_probe_for_local(monkeypatch):
monkeypatch.delenv("ODYSSEUS_VISION_MODELS", raising=False)
monkeypatch.setattr(ch, "lmstudio_supports_vision", lambda *a, **k: None)
monkeypatch.setattr(ch, "probe_endpoint_supports_vision", lambda url, model: True)
assert ch.model_supports_vision("ajax-ablated500-base", "http://127.0.0.1:18607/v1") is True
# --- downscale --------------------------------------------------------------
def test_encode_image_data_url_downscales_to_jpeg(tmp_path):
path = tmp_path / "big.png"
path.write_bytes(_png_bytes(2400, 600))
data_url, fmt = dp.encode_image_data_url(str(path), "png")
assert fmt == "jpeg"
assert data_url.startswith("data:image/jpeg;base64,")
raw = base64.b64decode(data_url.split(",", 1)[1])
with Image.open(io.BytesIO(raw)) as img:
assert max(img.size) <= 1280
# aspect ratio preserved
assert round(img.size[0] / img.size[1], 2) == 4.0
def test_encode_image_data_url_small_image_ok(tmp_path):
path = tmp_path / "small.png"
path.write_bytes(_png_bytes(64, 48))
data_url, fmt = dp.encode_image_data_url(str(path), "png")
assert fmt == "jpeg"
raw = base64.b64decode(data_url.split(",", 1)[1])
with Image.open(io.BytesIO(raw)) as img:
assert img.size == (64, 48)
# --- keep last N image turns ------------------------------------------------
def _user_with_image(caption: str, text: str) -> dict:
return {
"role": "user",
"content": [
{"type": "text", "text": text},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
],
"_caption": caption,
}
def test_keep_recent_image_turns_labels_old_images():
msgs = [
{"role": "user", "content": "hello"},
_user_with_image("a.png", "[Image attached: a.png]\nlook at a"),
{"role": "assistant", "content": "sure"},
_user_with_image("b.png", "[Image attached: b.png]\nlook at b"),
_user_with_image("c.png", "[Image attached: c.png]\nlook at c"),
]
out = keep_recent_image_turns(msgs, keep=2)
def _img_count(m):
c = m.get("content")
return sum(1 for b in c if isinstance(b, dict) and b.get("type") == "image_url") if isinstance(c, list) else 0
# first (oldest) image turn lost its image part, gained the label
assert _img_count(out[1]) == 0
assert "[Image: a.png]" in "".join(
b.get("text", "") for b in out[1]["content"] if isinstance(b, dict)
)
# two most recent image turns keep their image parts
assert _img_count(out[3]) == 1
assert _img_count(out[4]) == 1
# non-image message untouched (identity preserved for strings)
assert out[0]["content"] == "hello"
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
+27
View File
@@ -0,0 +1,27 @@
from types import SimpleNamespace
from src.clean_agent_preview import conversation
def test_web_heavy_followup_keeps_conversation_subject():
def answer(question, text):
trace = [
{'role': 'assistant', 'tool_calls': [{'id': 'search', 'function': {'name': 'web_search', 'arguments': '{}'}}]},
{'role': 'tool', 'tool_call_id': 'search', 'content': 'Search result. ' * 2500},
{'role': 'assistant', 'content': text},
]
return [
{'role': 'user', 'content': question},
{'role': 'assistant', 'content': text, 'metadata': {'clean_v3_turn': trace}},
]
session = SimpleNamespace(history=(
answer('Look up news Ethan and Hila', 'The H3 podcast has paused.')
+ answer('Tell me more', 'The H3 podcast crew made a statement.')
+ [{'role': 'user', 'content': 'What did the crew members say?'}]
))
result = conversation(session, [{'role': 'user', 'content': 'What did the crew members say?'}])
assert [m['content'] for m in result if m['role'] == 'user'] == [
'Look up news Ethan and Hila', 'Tell me more', 'What did the crew members say?'
]
assert any('H3 podcast crew' in m.get('content', '') for m in result)
+29 -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 **124** `ODYSSEUS_*` variables: 87 an operator may want to set, and 37 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 **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.
> This page is generated. Edit `scripts/generate_env_reference.py` and
> re-run it; `tests/test_env_reference.py` enforces that the committed page
@@ -69,6 +69,8 @@ The source tree reads **124** `ODYSSEUS_*` variables: 87 an operator may want to
| `ODYSSEUS_MISTRAL_REASONING_EFFORT` | `'high'` | `src/llm_core.py` | Reasoning effort sent to Mistral thinking-capable models. The API accepts high, medium, low and none. |
| `ODYSSEUS_MLX_IMAGE_VLM_MODEL` | *unset* | `scripts/mlx_image_server.py` | Vision-language model id for the MLX image server script. Required unless `--vlm-model` is passed on the command line. |
| `ODYSSEUS_QWEN_ROUTE_THINKING` | `'auto'` | `src/agent_loop.py` | Thinking policy for the Qwen routing step. An unrecognized value falls back to `auto`. |
| `ODYSSEUS_VISION_MODELS` | `''` | `src/chat_helpers.py` | Comma-separated model-name fragments that explicitly support inline images. |
| `ODYSSEUS_VISION_PROBE` | `'1'` | `src/chat_helpers.py` | Allow a cached vision-capability probe against local model endpoints. |
### Agent loop and tool execution
@@ -91,9 +93,9 @@ The source tree reads **124** `ODYSSEUS_*` variables: 87 an operator may want to
| `ODYSSEUS_BROWSER_MCP_CACHE` | `os.path.join(base_dir, 'data', 'local', 'playwright-mcp-cache')` | `src/builtin_mcp.py` | Cache directory handed to the browser MCP server, so its npm download survives a container rebuild. |
| `ODYSSEUS_BROWSER_MCP_CALL_TIMEOUT_S` | `'90'` | `src/mcp_manager.py` | Upper bound in seconds for one browser MCP tool call. A call that exceeds it fails without being retried. |
| `ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE` | `''` | `src/builtin_mcp.py` | Truthy refuses to start the browser MCP server unless its npm package is already in the npx cache, instead of installing it at startup. |
| `ODYSSEUS_BROWSER_NAMESPACE` | `'odysseus-ui'` | `src/agent_tools/web_tools.py` | Namespace for the detached agent-browser daemon's pid files, so two runtimes on one machine do not terminate each other's browsers. |
| `ODYSSEUS_BROWSER_NAMESPACE` | `'odysseus-ui'` | `src/agent_tools/web_tools.py` (+1 more) | Namespace for the detached agent-browser daemon's pid files, so two runtimes on one machine do not terminate each other's browsers. |
| `ODYSSEUS_BROWSER_NO_SANDBOX` | `'1'` | `src/builtin_mcp.py` | Security-relevant. On by default, adding `--no-sandbox` because the Docker image cannot use the Chromium sandbox. Set 0, false or no to keep it. |
| `ODYSSEUS_BROWSER_SCREENSHOT_DIR` | *unset* | `src/agent_tools/web_tools.py` | Where private-browser screenshots are written. Falls back to the container path, then the system temp directory. |
| `ODYSSEUS_BROWSER_SCREENSHOT_DIR` | *unset* | `src/agent_tools/web_tools.py` (+1 more) | Where private-browser screenshots are written. Falls back to the container path, then the system temp directory. |
### Container and workspace mounts
@@ -147,6 +149,7 @@ The source tree reads **124** `ODYSSEUS_*` variables: 87 an operator may want to
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10 * 1024 * 1024` | `src/upload_limits.py` (+1 more) | Maximum bytes accepted for a memory import file. |
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py` (+1 more) | Maximum bytes accepted for a personal-documents upload. |
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py` | Maximum bytes accepted for an audio file submitted for transcription. |
| `ODYSSEUS_VISION_MAX_SIDE` | `'1280'` | `src/document_processor.py` | Maximum pixel side for image attachments sent directly to a vision model. |
### Search
@@ -211,6 +214,28 @@ Listed for completeness. Setting one of these on a real install is either a no-o
|---|---|---|---|
| `ODYSSEUS_CAPTURE_MODEL_REQUESTS` | `''` | `src/agent_loop.py` | Truthy writes model-request snapshots for local debugging. The marker file `/tmp/odysseus_capture_model_requests` enables the same thing. |
| `ODYSSEUS_EXPOSE_RAW_BROWSER_MCP` | `''` | `src/agent_loop.py` | Truthy stops hiding the raw Playwright MCP tools from agent prompts when the private-browser tool is available. |
| `ODYSSEUS_JEFF_` | *unset* | `src/jeff_decision.py` | Prefix for per-job decision adapter opt-ins; each job is disabled by default. |
| `ODYSSEUS_JEFF_TIMEOUT` | `str(default)` | `src/jeff_decision.py` | Timeout in seconds for a decision adapter request. |
| `ODYSSEUS_JEFF_URL` | `'http://127.0.0.1:8765'` | `src/jeff_decision.py` | Base URL of the optional decision adapter service. |
| `ODYSSEUS_PRIVATE_BROWSER_LEGACY` | `'0'` | `src/tool_execution.py` (+1 more) | Explicit opt-in to the legacy browser compatibility transport; disabled by default. |
| `ODYSSEUS_SPEC_BROWSER_FALLBACK` | `'0'` | `src/spec_router.py` | Optional browser routing fallback: mcp, web, or 0 to disable. |
| `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. |
| `ODYSSEUS_SPEC_THINK_ROUTER` | `'0'` | `src/spec_router.py` | Enable router thinking decisions for explicitly configured Spec models. |
| `ODYSSEUS_SPEC_TOOL_FORMAT` | `''` | `src/spec_router.py` | Use names for compact router tool signatures, otherwise include descriptions. |
| `ODYSSEUS_SPEC_TRIM_PROMPT` | `'1'` | `src/agent_loop.py` | Enable prompt trimming for the selected Spec tool menu. |
| `ODYSSEUS_TOOL_CONTRACT_ROOT` | `str(Path(__file__).resolve().parents[1] / 'scripts')` | `src/clean_agent_preview.py` (+1 more) | Directory holding the tool-contract scripts the clean-agent preview loads. The default resolves to the bundled scripts directory relative to the installed/source tree. |
### Email
@@ -277,7 +302,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 94 of the 124 variables on this
`os.environ.get("ODYSSEUS_...` call finds 119 of the 149 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