diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py
index ed8d5acff..b43eeca87 100644
--- a/mcp_servers/email_server.py
+++ b/mcp_servers/email_server.py
@@ -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":
diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py
index eb0bfc310..8635e2c76 100644
--- a/routes/chat_helpers.py
+++ b/routes/chat_helpers.py
@@ -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)
diff --git a/routes/chat_routes.py b/routes/chat_routes.py
index 5a42d3351..065fdb6c4 100644
--- a/routes/chat_routes.py
+++ b/routes/chat_routes.py
@@ -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:])
diff --git a/routes/document/document_routes.py b/routes/document/document_routes.py
index 9a7743e8a..2b987e140 100644
--- a/routes/document/document_routes.py
+++ b/routes/document/document_routes.py
@@ -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,
)
diff --git a/routes/research/research_routes.py b/routes/research/research_routes.py
index 98897a875..e8a41a065 100644
--- a/routes/research/research_routes.py
+++ b/routes/research/research_routes.py
@@ -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"
diff --git a/routes/session_routes.py b/routes/session_routes.py
index 9f6cc3659..1c903f194 100644
--- a/routes/session_routes.py
+++ b/routes/session_routes.py
@@ -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 blocks, and trailing commas.
diff --git a/routes/shell_routes.py b/routes/shell_routes.py
index 8e2e969e0..b4684f779 100644
--- a/routes/shell_routes.py
+++ b/routes/shell_routes.py
@@ -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:
diff --git a/scripts/generate_env_reference.py b/scripts/generate_env_reference.py
index ff0fe8974..91100656e 100644
--- a/scripts/generate_env_reference.py
+++ b/scripts/generate_env_reference.py
@@ -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 "
diff --git a/services/memory/memory_extractor.py b/services/memory/memory_extractor.py
index 6b911b4d5..5b2c5bfe6 100644
--- a/services/memory/memory_extractor.py
+++ b/services/memory/memory_extractor.py
@@ -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)
diff --git a/src/agent_evidence.py b/src/agent_evidence.py
index b3c7e2b22..e012ebe66 100644
--- a/src/agent_evidence.py
+++ b/src/agent_evidence.py
@@ -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:
diff --git a/src/agent_loop.py b/src/agent_loop.py
index 257cc441d..ebf9a88b6 100644
--- a/src/agent_loop.py
+++ b/src/agent_loop.py
@@ -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,
diff --git a/src/agent_runtime/effect_adapters.py b/src/agent_runtime/effect_adapters.py
index 0e1defd2b..34c3772cb 100644
--- a/src/agent_runtime/effect_adapters.py
+++ b/src/agent_runtime/effect_adapters.py
@@ -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}
diff --git a/src/agent_tools/web_tools_prewave3.py b/src/agent_tools/web_tools_prewave3.py
new file mode 100644
index 000000000..700ab4a85
--- /dev/null
+++ b/src/agent_tools/web_tools_prewave3.py
@@ -0,0 +1,3863 @@
+import asyncio
+import base64
+import contextlib
+import contextvars
+import inspect
+import io
+import json
+import os
+import re
+import signal
+import shutil
+import sys
+import tempfile
+import time
+import html
+import hashlib
+import pwd
+import urllib.parse
+import urllib.request
+import uuid
+from pathlib import Path
+from typing import Dict, Any
+
+from core import platform_compat
+from src import browser_lifecycle, process_lifecycle
+from src.constants import MAX_OUTPUT_CHARS
+
+PDF_EXTRACT_MAX_BYTES = 80_000_000
+_ACTIVE_BROWSER_SESSIONS: set[str] = set()
+
+
+def _service_home() -> Path:
+ """Return the account home even when a task overrides ``HOME``."""
+
+ try:
+ return Path(pwd.getpwuid(os.getuid()).pw_dir)
+ except (KeyError, OSError):
+ return Path.home()
+
+
+def _host_npm_roots() -> list[Path]:
+ """Return user npm roots visible to a runtime using an isolated HOME."""
+
+ roots = [_service_home() / ".npm"]
+ roots.extend(Path("/home").glob("*/.npm"))
+ roots.append(Path("/root/.npm"))
+ return list(dict.fromkeys(roots))
+
+
+def _accessible_glob(roots: list[Path], pattern: str):
+ """Yield matches while ignoring roots unreadable by the service account."""
+
+ for root in dict.fromkeys(roots):
+ try:
+ yield from root.glob(pattern)
+ except (OSError, PermissionError):
+ continue
+
+
+def _browser_executable_candidates() -> list[Path]:
+ """Return executable Chromium builds from standard host cache layouts."""
+
+ homes = [_service_home()]
+ homes.extend(path for path in _accessible_glob([Path("/home")], "*") if path.is_dir())
+ homes.append(Path("/root"))
+ patterns = (
+ ".cache/ms-playwright/chromium-*/chrome-linux64/chrome",
+ ".cache/ms-playwright/chromium_headless_shell-*/"
+ "chrome-headless-shell-linux64/chrome-headless-shell",
+ ".chromium-browser-snapshots/chromium/linux-*/chrome-linux/chrome",
+ )
+ candidates = [
+ path
+ for pattern in patterns
+ for path in _accessible_glob(list(dict.fromkeys(homes)), pattern)
+ if path.is_file() and os.access(path, os.X_OK)
+ ]
+ return sorted(dict.fromkeys(candidates), reverse=True)
+
+
+def _bounded_browser_identity(value: str, *, max_length: int = 20) -> str:
+ """Return an agent-browser identity safe for Unix socket paths."""
+
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "")).strip("-._")
+ if safe == value and 0 < len(safe) <= max_length:
+ return safe
+ return hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:max_length]
+
+
+def _scoped_browser_session(namespace: str, session_id: str) -> str:
+ """Build one upstream-compatible session key for runtime isolation."""
+
+ scope = f"{str(namespace or 'odysseus-ui')}\0{str(session_id or '')}"
+ return f"ody-{_bounded_browser_identity(scope)}"
+
+
+def _browser_namespace(env: dict[str, str] | None) -> str:
+ return str(
+ (env or {}).get("ODYSSEUS_BROWSER_NAMESPACE")
+ or os.getenv("ODYSSEUS_BROWSER_NAMESPACE", "odysseus-ui")
+ ).strip() or "odysseus-ui"
+
+
+# Browser CLI processes started by the current private_browser call, so a
+# cancellation can stop every client it spawned, not only the main command.
+_BROWSER_CALL_PROCS: contextvars.ContextVar[list | None] = contextvars.ContextVar(
+ "_BROWSER_CALL_PROCS", default=None
+)
+
+
+async def _spawn_browser_cli(*command, **kwargs):
+ proc = await asyncio.create_subprocess_exec(*command, **kwargs)
+ # Identity taken while we hold the unreaped child: teardown later signals
+ # its group only while this identity still verifies, never on the pid
+ # alone (the event loop may reap it before returncode is observed).
+ pid = getattr(proc, "pid", None)
+ if isinstance(pid, int) and pid > 0:
+ with contextlib.suppress(Exception):
+ proc._ody_identity = process_lifecycle.ProcessIdentity.capture(pid, pgid=pid)
+ tracked = _BROWSER_CALL_PROCS.get()
+ if tracked is not None:
+ tracked.append(proc)
+ return proc
+
+
+def _browser_pid_file_candidates(
+ runtime_dir: Path, namespace: str, session_id: str | None
+) -> list[Path]:
+ """Return only the daemon pid files owned by one browser runtime.
+
+ Current agent-browser stores ``--session`` state directly below its
+ runtime directory. Older builds used a namespace/run subdirectory, so
+ retain that layout as a compatibility fallback. When no session is
+ supplied, only the legacy namespace directory is eligible; never sweep
+ all upstream sessions from the shared root.
+ """
+
+ browser_root = runtime_dir / "agent-browser"
+ legacy_run = (
+ browser_root / "namespaces" / _bounded_browser_identity(namespace) / "run"
+ )
+ if not session_id:
+ return list(legacy_run.glob("ody-*.pid")) if legacy_run.is_dir() else []
+
+ scoped = _scoped_browser_session(namespace, session_id)
+ candidates = [browser_root / f"{scoped}.pid"]
+ if legacy_run.is_dir():
+ candidates.extend(
+ [
+ legacy_run / f"ody-{_bounded_browser_identity(session_id)}.pid",
+ legacy_run / f"{scoped}.pid",
+ ]
+ )
+ return list(dict.fromkeys(candidates))
+
+
+# Linux exposes one command line per pid under /proc; macOS and Windows do not.
+# Kept as a module attribute so the procfs-dependent paths stay testable on a
+# host that has no procfs, and on one that does.
+
+
+def _process_command_line(pid: int) -> str | None:
+ """Command line of a running process, or ``None`` when it cannot be read.
+
+ ``None`` means "this host cannot tell", not "the process is gone". Off
+ Linux there is no procfs to read a command line from, so callers must not
+ treat it as proof that the process exited.
+ """
+
+ try:
+ return (platform_compat.PROC_ROOT / str(pid) / "cmdline").read_bytes().replace(
+ b"\0", b" "
+ ).decode("utf-8", errors="replace")
+ except (OSError, UnicodeError):
+ return None
+
+
+def _process_is_alive(pid: int) -> bool:
+ """Whether a pid currently exists.
+
+ Delegates to ``core.platform_compat.pid_alive`` rather than probing with
+ ``os.kill(pid, 0)`` directly. That probe is POSIX-only: CPython's Windows
+ ``os.kill`` calls ``TerminateProcess(handle, sig)`` for any signal other
+ than CTRL_C / CTRL_BREAK, so it would *kill* the daemon it is asked about.
+ Windows is also where there is no procfs, which is precisely when this
+ function gets called at all.
+
+ ``pid_alive`` reads False for a pid that ``os.kill`` reports with
+ ``PermissionError`` — a live process owned by another user. Neither caller
+ here wants a different answer: the sweep only unlinks a pid file it wrote
+ itself, and treating somebody else's pid as "not our daemon" is the safe
+ reading in both.
+ """
+
+ return platform_compat.pid_alive(pid)
+
+_SCHOLARLY_METADATA_CUE_RE = re.compile(
+ r"\b(?:accept(?:ed|ance)?|publish(?:ed|ing|cation)?|venue|conference|"
+ r"journal|proceedings|doi)\b",
+ re.IGNORECASE,
+)
+_EXPLICIT_ARXIV_ID_RE = re.compile(
+ r"\barxiv(?:\.org)?\b.{0,24}\b\d{4}\.\d{4,5}(?:v\d+)?\b",
+ re.IGNORECASE,
+)
+_SCHOLARLY_SUBJECT_CUE_RE = re.compile(
+ r"\b(?:paper|preprint|arxiv|benchmark|language model|vision-language|"
+ r"ICLR|ICML|CVPR|NeurIPS|ACL|EMNLP|AAAI|IEEE)\b",
+ re.IGNORECASE,
+)
+_DISTINCTIVE_SCHOLARLY_NAME_RE = re.compile(
+ r"\b(?:[A-Za-z][A-Za-z-]*\d[A-Za-z0-9.-]*|"
+ r"[A-Z][A-Za-z0-9]*-[A-Z][A-Za-z0-9]*)\b"
+)
+
+
+def _is_scholarly_metadata_query(query: str) -> bool:
+ text = str(query or "")
+ cues = {
+ match.group(0).casefold()
+ for match in _SCHOLARLY_METADATA_CUE_RE.finditer(text)
+ }
+ # Source discovery for a named paper should return ranked URLs/snippets,
+ # not download every result page. Full-page fetching can spend the entire
+ # tool timeout on one blocked publisher before the model ever sees the
+ # arXiv/official result it needs for pdf_extract.
+ if (
+ re.search(r"\b(?:paper|preprint|arxiv)\b", text, re.IGNORECASE)
+ and re.search(
+ r"\b(?:table|figure|benchmark|results?|pdf|source|url)\b",
+ text,
+ re.IGNORECASE,
+ )
+ ):
+ return True
+ if (
+ _DISTINCTIVE_SCHOLARLY_NAME_RE.search(text)
+ and re.search(r"\b(?:table|figure|benchmark|scores?|results?)\b", text, re.IGNORECASE)
+ ):
+ return True
+ if not cues:
+ return False
+ return bool(
+ _EXPLICIT_ARXIV_ID_RE.search(text)
+ or _SCHOLARLY_SUBJECT_CUE_RE.search(text)
+ or len(cues) >= 3
+ )
+
+
+def _is_official_site_metadata_query(query: str) -> bool:
+ """Canonical-homepage lookup needs ranked URLs, not downloaded pages."""
+
+ value = re.sub(r"\s+", " ", str(query or "")).strip()
+ return bool(re.fullmatch(
+ r"(?:the\s+)?official\s+.+?\s+(?:website|web\s*site|site|homepage)"
+ r"|.+?\s+official\s+(?:website|web\s*site|site|homepage)",
+ value,
+ re.IGNORECASE,
+ ))
+
+
+def _format_search_metadata(query: str, results: list[dict]) -> tuple[str, list[dict]]:
+ sources = [
+ {"url": str(item.get("url") or ""), "title": str(item.get("title") or "")}
+ for item in results
+ if item.get("url")
+ ]
+ parts = [f"WEB SEARCH METADATA RESULTS\nQuery: {query}"]
+ for index, item in enumerate(results, 1):
+ parts.extend([
+ "",
+ f"[{index}] {item.get('title') or 'Untitled result'}",
+ f"URL: {item.get('url') or ''}",
+ f"Snippet: {str(item.get('snippet') or '')[:1200]}",
+ ])
+ if not results:
+ parts.append("\nNo search results found.")
+ return "\n".join(parts), sources
+
+
+def _looks_like_youtube_video_id(value: str) -> bool:
+ return bool(re.fullmatch(r"[A-Za-z0-9_-]{10,16}", str(value or "").strip()))
+
+
+def _arxiv_listing_api_hint(url: str, error: str) -> str:
+ """Return an explicit recovery path for a blocked arXiv date listing."""
+ if "406" not in str(error or ""):
+ return ""
+ parsed = urllib.parse.urlsplit(str(url or ""))
+ if parsed.hostname not in {"arxiv.org", "www.arxiv.org"}:
+ return ""
+ match = re.fullmatch(r"/list/([A-Za-z0-9.-]+)", parsed.path.rstrip("/"))
+ date = urllib.parse.parse_qs(parsed.query).get("date", [""])[0]
+ if not match or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
+ return ""
+ day = date.replace("-", "")
+ query = f"cat:{match.group(1)} AND submittedDate:[{day}0000 TO {day}2359]"
+ api_url = "https://export.arxiv.org/api/query?" + urllib.parse.urlencode({
+ "search_query": query, "start": "0", "max_results": "100",
+ })
+ return (
+ " arXiv rejected its static listing (HTTP 406). The public Atom API is "
+ f"available for this category/day; call web_fetch on {api_url} to read "
+ "the dated feed, then inspect individual paper sources for details."
+ )
+
+
+class WebSearchTool:
+ async def execute(self, content: str, ctx: dict) -> dict:
+ from src.search import comprehensive_web_search, searxng_search_results
+ progress_cb = ctx.get("progress_cb") if isinstance(ctx, dict) else None
+ raw = content.strip()
+ query = raw
+ time_filter = None
+ max_pages = 5
+ if raw.startswith("{"):
+ try:
+ parsed = json.loads(raw)
+ if isinstance(parsed, dict) and "query" in parsed:
+ query = str(parsed.get("query", "")).strip()
+ tf = parsed.get("time_filter") or parsed.get("freshness")
+ if isinstance(tf, str) and tf.lower() in ("day", "week", "month", "year"):
+ time_filter = tf.lower()
+ mp = parsed.get("max_pages")
+ if isinstance(mp, int) and 1 <= mp <= 10:
+ max_pages = mp
+ except json.JSONDecodeError:
+ pass
+ if not query:
+ query = raw.split("\n")[0].strip()
+ if time_filter is None:
+ from src.search_intent import inferred_search_publication_window
+ time_filter = inferred_search_publication_window(query)
+ loop = asyncio.get_running_loop()
+ if progress_cb:
+ await progress_cb({
+ "elapsed_s": 0,
+ "tail": f"Searching web for: {query[:160]}",
+ })
+ try:
+ if _is_scholarly_metadata_query(query) or _is_official_site_metadata_query(query):
+ results = await asyncio.wait_for(
+ loop.run_in_executor(
+ None,
+ lambda: searxng_search_results(query, max_pages, **({'time_filter': time_filter} if time_filter else {})),
+ ),
+ timeout=30,
+ )
+ text, sources = _format_search_metadata(query, results)
+ else:
+ text, sources = await asyncio.wait_for(
+ loop.run_in_executor(
+ None,
+ lambda: comprehensive_web_search(
+ query,
+ max_pages=max_pages,
+ time_filter=time_filter,
+ return_sources=True,
+ ),
+ ),
+ timeout=30,
+ )
+ except asyncio.TimeoutError:
+ # Comprehensive search also downloads several result pages. A
+ # slow or hostile publisher must not erase the ranked search
+ # evidence that was already available. Fall back to the metadata
+ # path so the agent can choose a source and continue with
+ # web_fetch/private_browser. Keep this bounded independently: the
+ # abandoned executor thread may still be winding down.
+ try:
+ results = await asyncio.wait_for(
+ loop.run_in_executor(
+ None,
+ lambda: searxng_search_results(query, max_pages, **({'time_filter': time_filter} if time_filter else {})),
+ ),
+ timeout=12,
+ )
+ text, sources = _format_search_metadata(query, results)
+ if sources:
+ output = text[:MAX_OUTPUT_CHARS] if len(text) > MAX_OUTPUT_CHARS else text
+ output += "\n\n"
+ return {
+ "output": output,
+ "exit_code": 0,
+ "evidence_status": "available",
+ "degraded_mode": "metadata_after_content_timeout",
+ }
+ except Exception:
+ pass
+ return {
+ "error": f"web_search timed out after 30s: {query[:200]}",
+ "exit_code": 1,
+ }
+ except Exception as e:
+ return {
+ "error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
+ "exit_code": 1,
+ "untrusted_content": True,
+ }
+ from .weather_tools import WeatherTool, weather_location_from_query
+ weather_location = weather_location_from_query(query)
+ if not sources and time_filter and weather_location:
+ # A forecast or current fact need not live on a newly published page.
+ try:
+ text, sources = await asyncio.wait_for(
+ loop.run_in_executor(
+ None,
+ lambda: comprehensive_web_search(
+ query, max_pages=max_pages, time_filter=None,
+ return_sources=True,
+ ),
+ ),
+ timeout=20,
+ )
+ except Exception:
+ pass
+ if not sources:
+ from src.turn_contract import active_turn_contract
+ contract = active_turn_contract()
+ policy = ctx.get("tool_policy") if isinstance(ctx, dict) else None
+ weather_allowed = (
+ weather_location
+ and "get_weather" not in (ctx.get("disabled_tools") or ())
+ and not (policy and policy.blocks("get_weather"))
+ and not (contract and not contract.permits("get_weather"))
+ )
+ if weather_allowed:
+ weather = await WeatherTool().execute(json.dumps({"location": weather_location}), ctx)
+ if weather.get("exit_code") == 0:
+ return weather
+ if progress_cb:
+ await progress_cb({
+ "elapsed_s": 30,
+ "tail": "Search completed; preparing sources.",
+ })
+ # Compact the complete report before the transport cap. Otherwise
+ # repeated summaries from early sources permanently erase later pages.
+ from src.search_passages import bounded_search_observation
+ text = bounded_search_observation(text, MAX_OUTPUT_CHARS)
+ output = text[:MAX_OUTPUT_CHARS] if len(text) > MAX_OUTPUT_CHARS else text
+ if sources:
+ output += "\n\n"
+ return {"output": output, "exit_code": 0,
+ "evidence_status": "available" if sources else "empty"}
+
+class WebFetchTool:
+ _MAX_BATCH_URLS = 12
+ _MAX_BATCH_CONCURRENCY = 4
+
+ @staticmethod
+ def _query_from_request(request_text: str) -> str:
+ """Extract distinctive document/table terms from the active request."""
+ candidates = re.findall(r"[A-Za-z0-9][A-Za-z0-9_.+-]{2,}", request_text or "")
+ generic = {
+ "http", "https", "arxiv.org", "pdf", "pdfs", "www", "com", "org",
+ "please", "download", "read", "create", "save", "write", "workspace",
+ "table", "tables", "report", "benchmark", "benchmarks", "score", "scores",
+ "model", "models", "file", "chart", "data", "using", "from", "with",
+ }
+ selected: list[str] = []
+ for token in candidates:
+ folded = token.casefold()
+ distinctive = (
+ any(ch.isdigit() for ch in token)
+ or any(ch.isupper() for ch in token[1:])
+ or "-" in token
+ or folded.endswith("qa")
+ )
+ if distinctive and folded not in generic and token not in selected:
+ selected.append(token)
+ return ", ".join(selected[:20])
+
+ @staticmethod
+ def _focused_passages(text: str, query: str, max_chars: int) -> str:
+ """Select bounded page/line passages matching long-document terms."""
+ segments = [term.strip() for term in re.split(r"[,|;\n]+", query) if term.strip()]
+ stopwords = {
+ "benchmark", "table", "evaluation", "scores", "score", "model",
+ "paper", "document", "accuracy", "results", "result",
+ }
+ terms: list[str] = []
+ for segment in segments:
+ if len(segments) > 1 and len(segment) >= 2:
+ terms.append(segment)
+ for token in re.findall(r"[A-Za-z0-9][A-Za-z0-9_.+-]{2,}", segment):
+ if token.casefold() not in stopwords and token not in terms:
+ terms.append(token)
+ terms = terms[:16]
+ if not terms:
+ return text
+ lines = text.splitlines()
+ anchors: list[int] = []
+ for index, line in enumerate(lines):
+ low = line.casefold()
+ if any(term.casefold() in low for term in terms):
+ anchors.append(index)
+ if not anchors:
+ return f"[No passages matched query: {query}]"
+ # Keep independent windows around each hit. Merging nearby hits can
+ # accidentally turn a dense PDF table/page into one giant passage and
+ # clip the target row at the output boundary.
+ windows: list[tuple[int, int]] = []
+ for anchor in anchors:
+ table_start = None
+ if len(re.findall(r"\b\d+(?:\.\d+)?\b", lines[anchor])) >= 4:
+ for candidate in range(anchor, max(-1, anchor - 81), -1):
+ if re.match(r"\s*Table\s+\d+", lines[candidate], re.IGNORECASE):
+ table_start = candidate
+ break
+ interval = (
+ (table_start, min(len(lines), anchor + 9))
+ if table_start is not None
+ else (max(0, anchor - 28), min(len(lines), anchor + 15))
+ )
+ if interval not in windows:
+ windows.append(interval)
+ ranked_passages = []
+ for start, end in windows:
+ passage = "\n".join(lines[start:end])
+ low = passage.casefold()
+ coverage = sum(1 for term in terms if term.casefold() in low)
+ occurrences = sum(min(low.count(term.casefold()), 8) for term in terms)
+ numeric_density = min(len(re.findall(r"\b\d+(?:\.\d+)?\b", passage)), 40)
+ table_rows = 0
+ for line in passage.splitlines():
+ line_low = line.casefold()
+ if (
+ any(term.casefold() in line_low for term in terms)
+ and len(re.findall(r"\b\d+(?:\.\d+)?\b", line)) >= 4
+ ):
+ table_rows += 1
+ score = coverage * 100 + occurrences * 4 + numeric_density + min(table_rows, 4) * 200
+ # PDF table extraction can collapse a full page into one enormous
+ # line. Bound each candidate so an earlier broad table cannot
+ # consume the entire result before the exact matching table.
+ passage_cap = max(2000, min(8000, max_chars // 3))
+ if len(passage) > passage_cap:
+ # HTML-to-text can collapse an entire arXiv page into one line.
+ # Choose the densest term/numeric window instead of blindly
+ # retaining the abstract at the head of that line.
+ candidates: list[tuple[int, int, str]] = []
+ passage_low = passage.casefold()
+ for term in terms:
+ term_low = term.casefold()
+ cursor = 0
+ for _ in range(8):
+ position = passage_low.find(term_low, cursor)
+ if position < 0:
+ break
+ window_start = max(0, min(
+ len(passage) - passage_cap,
+ position - passage_cap // 2,
+ ))
+ window = passage[window_start:window_start + passage_cap]
+ window_low = window.casefold()
+ window_coverage = sum(
+ 1 for candidate in terms
+ if candidate.casefold() in window_low
+ )
+ window_numbers = min(
+ len(re.findall(r"\b\d+(?:\.\d+)?\b", window)), 300
+ )
+ candidates.append((
+ window_coverage * 1000
+ + window_numbers
+ + len(term) * 10
+ - window_start // 100,
+ -window_start,
+ window,
+ ))
+ cursor = position + max(1, len(term_low))
+ if candidates:
+ passage = max(candidates, key=lambda item: (item[0], item[1]))[2]
+ else:
+ passage = passage[:passage_cap]
+ passage += "\n[...passage windowed around query terms]"
+ ranked_passages.append((score, start, passage))
+ ranked_passages.sort(key=lambda item: (-item[0], item[1]))
+ passages: list[str] = []
+ for _score, _index, passage in ranked_passages:
+ # Collapse duplicate/near-identical windows without merging them.
+ normalized = re.sub(r"\s+", " ", passage).strip()
+ if any(normalized in re.sub(r"\s+", " ", prior) for prior in passages):
+ continue
+ passages.append(passage)
+ if len(passages) >= 1:
+ break
+ selected = "\n\n--- matching passage ---\n\n".join(passages)
+ if len(selected) > max_chars:
+ selected = selected[:max_chars] + "\n\n[...focused passages truncated]"
+ return f"[Focused passages for: {query}]\n\n{selected}"
+
+ async def _execute_batch(self, payload: dict, ctx: dict) -> dict:
+ """Fetch several known URLs concurrently with bounded combined output."""
+ raw_urls = payload.get("urls")
+ if not isinstance(raw_urls, list) or not raw_urls:
+ return {
+ "error": "web_fetch: urls must be a non-empty array of at most 12 URLs",
+ "exit_code": 1,
+ }
+ if len(raw_urls) > self._MAX_BATCH_URLS:
+ return {
+ "error": "web_fetch: urls must contain at most 12 URLs",
+ "exit_code": 1,
+ }
+
+ shared_query = str(payload.get("query") or "").strip()
+ shared_full = payload.get("full") is True
+ requests: list[dict[str, Any]] = []
+ for index, item in enumerate(raw_urls):
+ if isinstance(item, str):
+ request = {"url": item, "query": shared_query, "full": shared_full}
+ elif isinstance(item, dict):
+ request = {
+ "url": str(item.get("url") or "").strip(),
+ "query": str(item.get("query") or shared_query).strip(),
+ "full": item.get("full") is True or shared_full,
+ }
+ else:
+ return {
+ "error": f"web_fetch: urls item {index + 1} must be a URL string or object",
+ "exit_code": 1,
+ }
+ if not request["url"]:
+ return {
+ "error": f"web_fetch: urls item {index + 1} is missing url",
+ "exit_code": 1,
+ }
+ requests.append(request)
+
+ semaphore = asyncio.Semaphore(self._MAX_BATCH_CONCURRENCY)
+ child_ctx = dict(ctx) if isinstance(ctx, dict) else {}
+ child_ctx.pop("progress_cb", None)
+
+ async def fetch_one(request: dict[str, Any]) -> dict:
+ async with semaphore:
+ return await self.execute(json.dumps(request), child_ctx)
+
+ results = await asyncio.gather(*(fetch_one(request) for request in requests))
+ per_item_cap = max(800, min(6000, (MAX_OUTPUT_CHARS - 1000) // len(results)))
+ sections: list[str] = []
+ successful = 0
+ summaries: list[dict[str, Any]] = []
+ for index, (request, result) in enumerate(zip(requests, results), start=1):
+ ok = result.get("exit_code") == 0
+ if ok:
+ successful += 1
+ body = str(result.get("output") or "")
+ else:
+ body = "ERROR: " + str(result.get("error") or "fetch failed")
+ if len(body) > per_item_cap:
+ body = body[:per_item_cap] + "\n[...batch item truncated]"
+ sections.append(f"## URL {index}: {request['url']}\n{body}")
+ summaries.append({
+ "url": request["url"],
+ "exit_code": int(result.get("exit_code", 1)),
+ })
+ output = "\n\n".join(sections)
+ if len(output) > MAX_OUTPUT_CHARS:
+ output = output[:MAX_OUTPUT_CHARS] + "\n\n[...batch output truncated]"
+ return {
+ "output": output,
+ "batch_results": summaries,
+ "successful": successful,
+ "requested": len(requests),
+ "exit_code": 0 if successful else 1,
+ }
+
+ async def execute(self, content: str, ctx: dict) -> dict:
+ from src.search.content import fetch_webpage_content
+ from src.constants import WEB_FETCH_HARD_MAX_BYTES
+ raw = content.strip()
+ if raw.startswith("{"):
+ try:
+ batch_payload = json.loads(raw)
+ except json.JSONDecodeError:
+ batch_payload = None
+ if isinstance(batch_payload, dict) and "urls" in batch_payload:
+ return await self._execute_batch(batch_payload, ctx)
+ url = ""
+ max_bytes = None
+ query = ""
+ if raw.startswith("{"):
+ try:
+ parsed = json.loads(raw)
+ if isinstance(parsed, dict):
+ url = str(parsed.get("url") or "").strip()
+ query = str(parsed.get("query") or "").strip()
+ # Download-budget override (#3812): "full": true raises the
+ # budget to the hard cap; an explicit max_bytes is clamped
+ # to the hard cap downstream. Default stays the soft cap.
+ if parsed.get("full") is True:
+ max_bytes = WEB_FETCH_HARD_MAX_BYTES
+ mb = parsed.get("max_bytes")
+ if isinstance(mb, int) and mb > 0:
+ max_bytes = mb
+ except json.JSONDecodeError:
+ url = ""
+ if not url:
+ url = raw.split("\n")[0].strip()
+ local_path = url
+ if local_path.lower().startswith("file:///workspace/"):
+ local_path = local_path[7:]
+ if local_path.startswith("/workspace/"):
+ suffix = Path(local_path.split("?", 1)[0].split("#", 1)[0]).suffix.lower()
+ if suffix in {".html", ".htm"}:
+ return {
+ "error": (
+ "web_fetch: local HTML requires private_browser so rendered DOM and "
+ "page errors are inspected"
+ ),
+ "exit_code": 1,
+ }
+ if suffix in {
+ ".avi", ".bmp", ".gif", ".jpeg", ".jpg", ".m4v", ".mkv",
+ ".mov", ".mp4", ".mpeg", ".mpg", ".png", ".webm", ".webp",
+ }:
+ return {
+ "error": "web_fetch: local visual media requires inspect_media",
+ "exit_code": 1,
+ }
+ from src.agent_tools.filesystem_tools import ReadFileTool
+
+ result = await ReadFileTool().execute(local_path, ctx)
+ if result.get("exit_code") == 0:
+ result = dict(result)
+ result["output"] = (
+ "Argument note: Read the local workspace path with read_file; "
+ "use web_fetch only for HTTP(S) URLs.\n" + str(result.get("output") or "")
+ )
+ return result
+ if not url or url.startswith("{") or any(c in url for c in (" ", "\t", "\n")):
+ return {"error": "web_fetch: provide a single URL or domain, e.g. example.com", "exit_code": 1}
+ low = url.lower()
+ if "://" in low and not low.startswith(("http://", "https://")):
+ return {"error": f"web_fetch: unsupported URL scheme (only http/https): {url[:80]}", "exit_code": 1}
+ if not low.startswith(("http://", "https://")):
+ url = "https://" + url
+ if re.search(r"(?:\.pdf(?:[?#]|$)|arxiv\.org/pdf/)", url, re.IGNORECASE) and not query:
+ runtime_context = ctx.get("client_runtime_context") if isinstance(ctx, dict) else None
+ request_text = (
+ str(runtime_context.get("request_text") or "")
+ if isinstance(runtime_context, dict) else ""
+ )
+ query = self._query_from_request(request_text)
+ if not query:
+ return {
+ "error": (
+ "web_fetch: this is a PDF. Use pdf_extract with this URL and a query "
+ "that names the target model, metrics, or table; do not download it "
+ "with Python/curl and do not estimate missing values."
+ ),
+ "exit_code": 1,
+ }
+ loop = asyncio.get_running_loop()
+ try:
+ def _fetch():
+ kwargs = {"timeout": 10}
+ try:
+ sig = inspect.signature(fetch_webpage_content)
+ if "max_bytes" in sig.parameters:
+ kwargs["max_bytes"] = max_bytes
+ except (TypeError, ValueError):
+ # Some deployed/test shims may not expose a signature.
+ # Prefer compatibility over failing the whole fetch.
+ pass
+ return fetch_webpage_content(url, **kwargs)
+
+ result = await asyncio.wait_for(
+ loop.run_in_executor(None, _fetch),
+ timeout=30,
+ )
+ except asyncio.TimeoutError:
+ return {"error": f"web_fetch: timed out fetching {url}", "exit_code": 1}
+ except Exception as e:
+ return {"error": f"web_fetch: {url}: {e}", "exit_code": 1}
+ err = result.get("error")
+ text = (result.get("linked_content") or result.get("content") or "").strip()
+ title = result.get("title") or ""
+
+ if not text:
+ if err:
+ arxiv_hint = _arxiv_listing_api_hint(url, str(err))
+ return {
+ "error": f"web_fetch: {url}: {err}{arxiv_hint}",
+ "exit_code": 1,
+ "untrusted_content": True,
+ }
+ return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1}
+
+ # Tell the model when the download budget cut the body short and how
+ # to get the rest, instead of silently presenting a partial page as
+ # the whole thing.
+ size_note = ""
+ if result.get("truncated"):
+ fetched = result.get("fetched_bytes") or 0
+ total = result.get("total_bytes")
+ total_txt = f" of {total:,} bytes" if total else ""
+ size_note = (
+ f"[partial content: download stopped at {fetched:,} bytes{total_txt}. "
+ f'Re-call with {{"url": "{url}", "full": true}} to fetch up to '
+ f"{WEB_FETCH_HARD_MAX_BYTES:,} bytes.]\n\n"
+ )
+
+ # The notice must lead the output so the MAX_OUTPUT_CHARS trim below can
+ # never drop it. The title is untrusted, uncapped page content, so a
+ # giant title ahead of the notice could push it out of range; keep the
+ # notice first and cap the title as a second guard.
+ if len(title) > 300:
+ title = title[:300] + "..."
+ header = (f"# {title}\n" if title else "") + f"Source: {url}\n\n"
+ if query:
+ text = self._focused_passages(text, query, MAX_OUTPUT_CHARS - len(header) - 500)
+ output = size_note + header + text
+ if len(output) > MAX_OUTPUT_CHARS:
+ output = output[:MAX_OUTPUT_CHARS] + (
+ "\n\n[...truncated; re-call web_fetch with query terms to retrieve matching passages]"
+ if not query else "\n\n[...truncated]"
+ )
+ return {"output": output, "exit_code": 0, "page_entries": result.get("page_entries") or []}
+
+
+class PdfExtractTool:
+ """Extract focused, source-attributed passages from a PDF URL or workspace file.
+
+ This deliberately builds on Odysseus' native web reader instead of adding
+ a benchmark transport. The separate semantic affordance keeps models from
+ downloading PDFs with ad-hoc Python and then guessing when extraction fails.
+ Local task PDFs use the same positioned reader, but are resolved through the
+ active workspace path policy rather than through an unrestricted filesystem
+ path.
+ """
+
+ @staticmethod
+ def _local_pdf_path(source: str) -> Path | None:
+ """Resolve a task-local PDF through the active workspace policy."""
+ raw = str(source or "").strip()
+ if raw.lower().startswith("file://"):
+ parsed = urllib.parse.urlsplit(raw)
+ if parsed.netloc not in {"", "localhost"}:
+ return None
+ raw = urllib.parse.unquote(parsed.path)
+ if not raw.startswith("/workspace/") and not Path(raw).is_absolute():
+ return None
+ try:
+ from src.tool_execution import _resolve_tool_path
+
+ path = Path(_resolve_tool_path(raw))
+ except (OSError, ValueError):
+ return None
+ return path if path.suffix.casefold() == ".pdf" else None
+
+ @staticmethod
+ def _arxiv_html_url(url: str) -> str:
+ """Return the official HTML companion for an arXiv document URL."""
+ match = re.search(
+ r"https?://(?:www\.)?arxiv\.org/(?:pdf|abs|html)/(?P\d{4}\.\d{4,5}(?:v\d+)?)",
+ str(url or ""),
+ re.IGNORECASE,
+ )
+ return f"https://arxiv.org/html/{match.group('id')}" if match else ""
+
+ @staticmethod
+ def _pdf_row_contains_term(row_text: str, term: str) -> bool:
+ """Match model names even when PDF glyph extraction splits punctuation."""
+ row_folded = str(row_text or "").casefold()
+ term_folded = str(term or "").casefold()
+ if term_folded in row_folded:
+ return True
+ normalize = lambda value: re.sub(r"[^a-z0-9]+", "", value.casefold())
+ normalized_term = normalize(term_folded)
+ return bool(normalized_term) and normalized_term in normalize(row_folded)
+
+ @staticmethod
+ def _pdf_row_exact_term_match(row_text: str, term: str) -> bool:
+ """Match a complete model label without accepting a named variant.
+
+ PDF text extraction may split punctuation (``LLaVA- Onevision``), so a
+ literal equality check is too strict. The broad table matcher above is
+ intentionally useful for locating candidate pages, but it also makes
+ ``DeepSeek-VL2`` match ``DeepSeek-VL2-Tiny``. Row resolution needs the
+ narrower relation: tolerate extracted punctuation while rejecting an
+ attached hyphen/slash variant suffix.
+ """
+ row = str(row_text or "")
+ chunks = re.findall(r"[A-Za-z0-9]+", str(term or ""))
+ if not chunks:
+ return False
+ pattern = re.compile(
+ r"(? list[str]:
+ """Return compact digit-bearing aliases used by shared-family headers.
+
+ Comparison tables often put the vendor/family in the caption and use
+ headers such as ``R1-Zero`` or ``o1``. Restrict aliases to suffixes
+ containing a digit so ordinary trailing words cannot become matches.
+ """
+ parts = [part for part in re.split(r"-+", str(term or "")) if part]
+ aliases: list[str] = []
+ for index in range(1, len(parts)):
+ alias = "-".join(parts[index:])
+ if any(character.isdigit() for character in alias) and len(alias) >= 2:
+ aliases.append(alias)
+ return aliases
+
+ @staticmethod
+ def _pdf_row_contains_model_alias(row_text: str, term: str) -> bool:
+ """Match an exact full model label or a bounded table-header alias."""
+ if PdfExtractTool._pdf_row_exact_term_match(row_text, term):
+ return True
+ return any(
+ PdfExtractTool._pdf_row_exact_term_match(row_text, alias)
+ for alias in PdfExtractTool._pdf_model_suffix_aliases(term)
+ )
+
+ @staticmethod
+ def _bibliography_evidence(
+ page_columns: list[tuple[int, str, str]], query: str
+ ) -> str:
+ """Return ordered reference entries for bibliography-focused queries.
+
+ Academic PDFs commonly use two columns. Reading the whole page in
+ layout order interleaves those columns, while generic query scoring can
+ rank citation-heavy body pages above the actual reference section.
+ ``page_columns`` keeps each column independent so references can be
+ reconstructed in normal reading order without knowing task page
+ numbers or paper titles.
+ """
+ bibliography_intent = re.search(
+ r"\b(?:references?|bibliograph(?:y|ies)|bibtex|citation\s+list)\b",
+ str(query or ""),
+ re.IGNORECASE,
+ )
+ if not bibliography_intent:
+ return ""
+
+ heading_pattern = re.compile(
+ r"(?im)^\s*(?:references|bibliography)(?:\s+\d+)?\s*$"
+ )
+ start_index = -1
+ start_offset = 0
+ for index, (_page_number, left, _right) in enumerate(page_columns):
+ heading = heading_pattern.search(left)
+ if heading:
+ start_index = index
+ start_offset = heading.start()
+ break
+ if start_index < 0:
+ return ""
+
+ ordered_pages: list[tuple[int, str]] = []
+ for index, (page_number, left, right) in enumerate(
+ page_columns[start_index:], start=start_index
+ ):
+ if index == start_index:
+ left = left[start_offset:]
+ text = "\n".join(part.strip() for part in (left, right) if part.strip())
+ if text:
+ ordered_pages.append((page_number, text))
+ if not ordered_pages:
+ return ""
+
+ joined = "\n".join(text for _page_number, text in ordered_pages)
+ starts = list(re.finditer(r"(?m)^\s*\[(\d+)\]\s*", joined))
+ numbered_entries: list[tuple[int, str]] = []
+ for position, match in enumerate(starts):
+ end = starts[position + 1].start() if position + 1 < len(starts) else len(joined)
+ numbered_entries.append((
+ int(match.group(1)),
+ joined[match.start():end].strip(),
+ ))
+ requested_range = re.search(
+ r"\b(?:references?|refs?|citations?)\s*(?:numbers?\s*)?"
+ r"\[?(\d{1,4})\]?\s*(?:through|to|[-–—])\s*\[?(\d{1,4})\]?",
+ str(query or ""),
+ re.IGNORECASE,
+ )
+ if requested_range and numbered_entries:
+ first, last = sorted((
+ int(requested_range.group(1)),
+ int(requested_range.group(2)),
+ ))
+ selected_entries = [
+ entry for number, entry in numbered_entries
+ if first <= number <= last
+ ]
+ if selected_entries:
+ pages = f"{ordered_pages[0][0]}-{ordered_pages[-1][0]}"
+ body = (
+ f"[Local PDF bibliography evidence, pages {pages}; "
+ f"reference entries {first}-{last}]\n"
+ + "\n\n".join(selected_entries)
+ )
+ if len(body) > MAX_OUTPUT_CHARS - 500:
+ body = body[:MAX_OUTPUT_CHARS - 500] + "\n[...bibliography entries truncated]"
+ return body
+
+ wants_arxiv = bool(
+ re.search(r"\b(?:arxiv|preprints?)\b", str(query or ""), re.IGNORECASE)
+ )
+ if wants_arxiv:
+ # Keep complete numbered entries instead of returning every page.
+ # This is both more useful and prevents the bounded output cap from
+ # dropping late matching references.
+ entries: list[str] = []
+ for _number, entry in numbered_entries:
+ if re.search(r"arxiv", entry, re.IGNORECASE):
+ entries.append(entry)
+ if entries:
+ pages = f"{ordered_pages[0][0]}-{ordered_pages[-1][0]}"
+ body = (
+ f"[Local PDF bibliography evidence, pages {pages}; "
+ f"{len(entries)} arXiv/preprint entries]\n"
+ + "\n\n".join(entries)
+ )
+ if len(body) > MAX_OUTPUT_CHARS - 500:
+ body = body[:MAX_OUTPUT_CHARS - 500] + "\n[...bibliography entries truncated]"
+ return body
+
+ body = "\n\n".join(
+ f"[Local PDF bibliography evidence, page {page_number}]\n{text}"
+ for page_number, text in ordered_pages
+ )
+ if len(body) > MAX_OUTPUT_CHARS - 500:
+ body = body[:MAX_OUTPUT_CHARS - 500] + "\n[...bibliography text truncated]"
+ return body
+
+ @staticmethod
+ def _select_positioned_target_model(
+ model_terms: list[str],
+ rows: list[tuple[float, list[dict]]],
+ ) -> str:
+ """Choose the requested model that is actually present in selected PDF rows.
+
+ Broad benchmark prompts often ask for several models in every PDF
+ request. Picking the longest/first requested model contaminates a GLM
+ or Seed table with the Qwen column. Score each requested model against
+ the selected positioned rows and prefer explicit in-table matches.
+ """
+ if not model_terms:
+ return ""
+ table_text = " ".join(
+ str(word["text"])
+ for _top, words in rows
+ for word in words
+ )
+ table_folded = table_text.casefold()
+ table_normalized = re.sub(r"[^a-z0-9]+", "", table_folded)
+ scored: list[tuple[int, int, str]] = []
+ for term in model_terms:
+ folded = str(term or "").casefold()
+ base = re.sub(r"-(?:\d+(?:\.\d+)?[BMK])$", "", term, flags=re.I)
+ score = 0
+ for _top, words in rows:
+ row_text = " ".join(str(word["text"]) for word in words)
+ if PdfExtractTool._pdf_row_contains_term(row_text, term):
+ score += 100
+ elif base and PdfExtractTool._pdf_row_contains_term(row_text, base):
+ score += 70
+ if PdfExtractTool._pdf_row_contains_model_alias(row_text, term):
+ score += 60
+ if "a22b" in folded:
+ row_folded = row_text.casefold()
+ normalized = re.sub(r"[^a-z0-9]+", "", row_folded)
+ if "a22b" in row_folded and (
+ "qwen3" in row_folded
+ or "235b" in row_folded
+ or "qwen3vl" in normalized
+ ):
+ score += 80
+ if "seed" in folded and "1.5" in folded:
+ if (
+ "seed" in row_text.casefold()
+ and ("1.5-vl" in row_text.casefold() or "15vl" in re.sub(r"[^a-z0-9]+", "", row_text.casefold()))
+ ):
+ score += 80
+ if "glm" in folded and "4.6" in folded:
+ if "glm" in row_text.casefold() and "4.6" in row_text.casefold():
+ score += 80
+ if "seed" in folded and "1.5" in folded:
+ if "seed" in table_folded and (
+ "1.5-vl" in table_folded or "15vl" in table_normalized
+ ):
+ score += 90
+ if "thinking" in folded and "thinking" in table_folded:
+ score += 25
+ if "glm" in folded and "4.6" in folded:
+ if "glm" in table_folded and "4.6" in table_folded:
+ score += 90
+ scored.append((score, len(term), term))
+ best = max(scored)
+ return best[2] if best[0] > 0 else max(model_terms, key=len, default="")
+
+ @staticmethod
+ def _looks_like_pdf_metric_term(token: str) -> bool:
+ """Identify benchmark metric labels, including compact table acronyms."""
+ value = str(token or "")
+ folded = value.casefold()
+ if (
+ folded.endswith("qa")
+ or "refcoco" in folded
+ or folded in {
+ "chartqa",
+ "docvqa",
+ "textvqa",
+ "ocrbench",
+ "countbench",
+ "blink",
+ "mmbench",
+ "mmstar",
+ "mmmu",
+ "mathvista",
+ }
+ ):
+ return True
+ # Papers frequently label table columns with short uppercase task
+ # acronyms (TR, AR, AO, AC, ...). Keep transport/document/model
+ # acronyms out so ordinary query prose does not become a fake header.
+ return (
+ value.isupper()
+ and 2 <= len(value) <= 8
+ and value not in {"PDF", "URL", "HTML", "HTTP", "HTTPS", "GPT", "LLM", "VLM"}
+ )
+
+ @staticmethod
+ def _pdf_query_tokens(query: str) -> list[str]:
+ """Tokenize table queries without dropping two-letter metric labels."""
+ return re.findall(r"[A-Za-z0-9][A-Za-z0-9_.+-]{1,}", str(query or ""))
+
+ @staticmethod
+ def _looks_like_pdf_model_term(token: str) -> bool:
+ """Separate compact model identifiers from ordinary hyphenated prose."""
+ value = str(token or "")
+ if (
+ any(character.isdigit() for character in value)
+ and any(character.isalpha() for character in value)
+ ):
+ return True
+ return "-" in value and sum(character.isupper() for character in value) >= 2
+
+ @staticmethod
+ def _pdf_words_contain_metric(words: list[dict], metric: str) -> bool:
+ """Match a metric as a table cell, not inside ordinary prose words."""
+ normalize = lambda value: re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
+ target = normalize(metric)
+ return bool(target) and any(
+ normalize(word.get("text")) == target for word in words
+ )
+
+ @staticmethod
+ def _pdf_rows_contain_table_label(
+ rows: list[tuple[float, list[dict]]], table_number: int
+ ) -> bool:
+ """Return whether positioned rows contain an exact numbered caption."""
+ label = re.compile(
+ rf"^\s*table\s+{int(table_number)}(?:\D|$)",
+ re.IGNORECASE,
+ )
+ return any(
+ label.search(" ".join(str(word.get("text") or "") for word in words))
+ for _top, words in rows
+ )
+
+ @staticmethod
+ def _select_positioned_table_region(
+ rows: list[tuple[float, list[dict]]],
+ metric_terms: list[str],
+ model_terms: list[str],
+ requested_table_number: int | None,
+ ) -> list[tuple[float, list[dict]]]:
+ """Keep one physical table when a page contains adjacent tables.
+
+ Joining coordinates across two tables on the same page can map a
+ header from one table to values in another. Real ``Table N`` caption
+ rows provide a generic boundary. Prefer an explicitly requested
+ table, otherwise rank regions by exact requested-row and metric
+ coverage; named variants receive only weak fallback credit.
+ """
+ caption_pattern = re.compile(r"^\s*table\s+(\d{1,3})(?:\D|$)", re.I)
+ starts: list[tuple[int, int]] = []
+ for index, (_top, words) in enumerate(rows):
+ row_text = " ".join(str(word.get("text") or "") for word in words)
+ match = caption_pattern.search(row_text)
+ if match:
+ starts.append((index, int(match.group(1))))
+ if len(starts) < 2:
+ return rows
+
+ regions: list[tuple[int, list[tuple[float, list[dict]]]]] = []
+ for position, (start, number) in enumerate(starts):
+ end = starts[position + 1][0] if position + 1 < len(starts) else len(rows)
+ regions.append((number, rows[start:end]))
+ if requested_table_number is not None:
+ requested = [region for number, region in regions if number == requested_table_number]
+ if requested:
+ return requested[0]
+
+ def _score(region: list[tuple[float, list[dict]]]) -> tuple[int, int]:
+ row_texts = [
+ " ".join(str(word.get("text") or "") for word in words)
+ for _top, words in region
+ ]
+ exact_models = sum(
+ any(PdfExtractTool._pdf_row_exact_term_match(text, term) for text in row_texts)
+ for term in set(model_terms)
+ )
+ broad_models = sum(
+ any(PdfExtractTool._pdf_row_contains_term(text, term) for text in row_texts)
+ for term in set(model_terms)
+ )
+ metrics = sum(
+ any(PdfExtractTool._pdf_words_contain_metric(words, term) for _top, words in region)
+ for term in set(metric_terms)
+ )
+ numeric = sum(
+ len(re.findall(r"\b\d+(?:\.\d+)?\b", text)) for text in row_texts
+ )
+ return (
+ exact_models * 1000 + broad_models * 100 + metrics * 300 + min(numeric, 99),
+ -len(region),
+ )
+
+ return max((region for _number, region in regions), key=_score)
+
+ @staticmethod
+ def _positioned_table_evidence(url: str, query: str) -> str:
+ """Return compact PDF rows with x coordinates for column-safe reading."""
+ try:
+ import pdfplumber
+ import httpx
+ from services.search.content import _PinnedTransport, _resolve_public_ips
+ from src.constants import WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
+ except ImportError:
+ return ""
+ local_path = PdfExtractTool._local_pdf_path(url)
+ if local_path is not None:
+ if not local_path.is_file():
+ return ""
+ pdf_bytes = local_path.read_bytes()
+ declared_size = len(pdf_bytes)
+ else:
+ ips = _resolve_public_ips(url)
+ headers = {
+ "User-Agent": WEB_FETCH_USER_AGENT,
+ "Accept": "application/pdf,*/*",
+ }
+ with httpx.Client(
+ headers=headers,
+ timeout=30,
+ follow_redirects=True,
+ transport=_PinnedTransport(ips[0]),
+ ) as client:
+ response = client.get(url)
+ response.raise_for_status()
+ pdf_bytes = response.content
+ declared = response.headers.get("content-length")
+ declared_size = int(declared) if declared and declared.isdigit() else 0
+ if declared_size and declared_size > PDF_EXTRACT_MAX_BYTES:
+ return (
+ f"[PDF too large for pdf_extract positioned table reader: "
+ f"{declared_size:,} bytes > {PDF_EXTRACT_MAX_BYTES:,} bytes]"
+ )
+ if len(pdf_bytes) > PDF_EXTRACT_MAX_BYTES:
+ return (
+ f"[PDF too large for pdf_extract positioned table reader: "
+ f"{len(pdf_bytes):,} bytes > {PDF_EXTRACT_MAX_BYTES:,} bytes]"
+ )
+ tokens = PdfExtractTool._pdf_query_tokens(query)
+ requested_table_match = re.search(
+ r"\btable\s+(\d{1,3})\b", str(query or ""), re.IGNORECASE
+ )
+ requested_table_number = (
+ int(requested_table_match.group(1)) if requested_table_match else None
+ )
+ metric_terms = [
+ token for token in tokens
+ if PdfExtractTool._looks_like_pdf_metric_term(token)
+ ]
+ model_terms = [
+ token for token in tokens
+ if token not in metric_terms
+ and PdfExtractTool._looks_like_pdf_model_term(token)
+ ]
+ normalize = lambda value: re.sub(r"[^a-z0-9]+", "", value.casefold())
+
+ def _row_contains_model_alias(row_text: str, term: str) -> bool:
+ row_folded = str(row_text or "").casefold()
+ term_folded = str(term or "").casefold()
+ if PdfExtractTool._pdf_row_contains_model_alias(row_text, term):
+ return True
+ if "a22b" in term_folded:
+ return "a22b" in row_folded and (
+ "qwen3" in row_folded
+ or "235b" in row_folded
+ or "qwen3vl" in normalize(row_folded)
+ )
+ return False
+
+ candidates: list[tuple[int, int, list[tuple[float, list[dict]]]]] = []
+ with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
+ for page_number, page in enumerate(pdf.pages, 1):
+ grouped: dict[float, list[dict]] = {}
+ for word in page.extract_words(x_tolerance=2, y_tolerance=2):
+ grouped.setdefault(round(float(word["top"]) / 3) * 3, []).append(word)
+ rows = [(top, sorted(words, key=lambda item: float(item["x0"]))) for top, words in grouped.items()]
+ anchors: list[float] = []
+ score = 0
+ for top, words in rows:
+ row_text = " ".join(str(word["text"]) for word in words)
+ low = row_text.casefold()
+ metric_hits = sum(
+ PdfExtractTool._pdf_words_contain_metric(words, term)
+ for term in metric_terms
+ )
+ model_hits = sum(
+ PdfExtractTool._pdf_row_exact_term_match(row_text, term)
+ for term in model_terms
+ )
+ alias_model_hits = sum(
+ _row_contains_model_alias(row_text, term)
+ for term in model_terms
+ )
+ numeric_count = len(re.findall(r"\b\d+(?:\.\d+)?\b", row_text))
+ starts_with_metric = bool(words) and any(
+ re.sub(r"[^a-z0-9]+", "", str(words[0]["text"]).casefold())
+ == re.sub(r"[^a-z0-9]+", "", term.casefold())
+ for term in metric_terms
+ )
+ table_header_hit = (
+ "capability" in low
+ and "benchmark" in low
+ )
+ model_table_header_hit = bool(
+ alias_model_hits
+ and (
+ low.strip().startswith("table ")
+ or low.strip().startswith("benchmark")
+ )
+ )
+ if metric_hits >= 2 or (metric_hits and numeric_count >= 3) or (model_hits and numeric_count >= 3):
+ anchors.append(top)
+ score += (
+ metric_hits * 10
+ + model_hits * 5
+ + alias_model_hits * 20
+ + min(numeric_count, 10)
+ + (50 if starts_with_metric else 0)
+ )
+ elif model_table_header_hit:
+ score += alias_model_hits * 40
+ if table_header_hit:
+ score += 15
+ if anchors:
+ # Preserve the continuous table body between matching
+ # anchors. Selecting disjoint +/-100 bands dropped middle
+ # model rows in tall benchmark tables when the queried
+ # model name was split across PDF words and therefore did
+ # not become its own anchor.
+ table_top = max(0.0, min(anchors) - 100)
+ header_tops = [
+ top for top, words in rows
+ if top <= min(anchors)
+ and (
+ (
+ "capability" in " ".join(str(word["text"]) for word in words).casefold()
+ and "benchmark" in " ".join(str(word["text"]) for word in words).casefold()
+ )
+ or (
+ "benchmark" in " ".join(str(word["text"]) for word in words).casefold()
+ and any(
+ _row_contains_model_alias(
+ " ".join(str(word["text"]) for word in words),
+ term,
+ )
+ for term in model_terms
+ )
+ )
+ or (
+ " ".join(str(word["text"]) for word in words).casefold().strip().startswith("table ")
+ and any(
+ _row_contains_model_alias(
+ " ".join(str(word["text"]) for word in words),
+ term,
+ )
+ for term in model_terms
+ )
+ )
+ )
+ ]
+ if header_tops:
+ table_top = min(table_top, max(header_tops) - 20)
+ table_bottom = max(anchors) + 100
+ selected = [
+ row for row in rows
+ if table_top <= row[0] <= table_bottom
+ ]
+ candidates.append((score, page_number, selected))
+ if not candidates:
+ return ""
+ labeled_candidates = (
+ [
+ candidate for candidate in candidates
+ if PdfExtractTool._pdf_rows_contain_table_label(
+ candidate[2], requested_table_number
+ )
+ ]
+ if requested_table_number is not None
+ else []
+ )
+ if labeled_candidates:
+ # A paper can repeat a table number in an appendix. The first
+ # exact caption is the canonical table for an unqualified request.
+ _score, page_number, rows = min(
+ labeled_candidates, key=lambda item: item[1]
+ )
+ else:
+ _score, page_number, rows = max(candidates, key=lambda item: item[0])
+ rows = PdfExtractTool._select_positioned_table_region(
+ rows,
+ metric_terms,
+ model_terms,
+ requested_table_number,
+ )
+ resolved: dict[str, str] = {}
+ metric_positions: dict[str, float] = {}
+ header_candidates = []
+ for _top, words in rows:
+ positions = {
+ metric: float(word["x0"])
+ for word in words for metric in metric_terms
+ if re.sub(r"[^a-z0-9]+", "", str(word["text"]).casefold())
+ == re.sub(r"[^a-z0-9]+", "", metric.casefold())
+ }
+ if positions:
+ header_candidates.append((len(positions), positions))
+ if header_candidates:
+ metric_positions = max(header_candidates, key=lambda item: item[0])[1]
+
+ def _numbers(words: list[dict]) -> list[tuple[float, str]]:
+ return [
+ (float(word["x0"]), str(word["text"]))
+ for word in words
+ if re.fullmatch(r"\d+(?:\.\d+)?", str(word["text"]))
+ ]
+
+ # Row-oriented tables: metric names are columns and the requested
+ # model is a row (for example DeepSeek-VL2).
+ target_model = PdfExtractTool._select_positioned_target_model(
+ model_terms,
+ rows,
+ )
+ target_base = re.sub(r"-(?:\d+(?:\.\d+)?[BMK])$", "", target_model, flags=re.I)
+ exact_model_rows = [
+ (top, words)
+ for top, words in rows
+ if target_base
+ and PdfExtractTool._pdf_row_exact_term_match(
+ " ".join(str(word["text"]) for word in words),
+ target_base,
+ )
+ ]
+ candidate_model_rows = exact_model_rows or [
+ (top, words)
+ for top, words in rows
+ if target_base
+ and PdfExtractTool._pdf_row_contains_term(
+ " ".join(str(word["text"]) for word in words),
+ target_base,
+ )
+ ]
+ for _top, words in candidate_model_rows:
+ row_text = " ".join(str(word["text"]) for word in words)
+ nums = _numbers(words)
+ if len(nums) >= len(metric_terms):
+ for metric, metric_x in metric_positions.items():
+ nearest = min(nums, key=lambda item: abs(item[0] - metric_x))
+ if abs(nearest[0] - metric_x) <= 30:
+ resolved[metric] = nearest[1]
+ if resolved:
+ break
+
+ # A single row-oriented table can contain several models requested in
+ # the same query. Return a coordinate-joined record for each exact row
+ # instead of forcing the model to decode the remaining dense HTML row.
+ additional_row_resolutions: dict[str, dict[str, str]] = {}
+ for requested_model in dict.fromkeys(model_terms):
+ if requested_model == target_model:
+ continue
+ requested_base = re.sub(
+ r"-(?:\d+(?:\.\d+)?[BMK])$", "", requested_model, flags=re.I
+ )
+ requested_rows = [
+ words
+ for _top, words in rows
+ if requested_base
+ and PdfExtractTool._pdf_row_exact_term_match(
+ " ".join(str(word["text"]) for word in words),
+ requested_base,
+ )
+ ]
+ for words in requested_rows:
+ nums = _numbers(words)
+ if len(nums) < len(metric_terms):
+ continue
+ values: dict[str, str] = {}
+ for metric, metric_x in metric_positions.items():
+ nearest = min(nums, key=lambda item: abs(item[0] - metric_x))
+ if abs(nearest[0] - metric_x) <= 30:
+ values[metric] = nearest[1]
+ if values:
+ additional_row_resolutions[requested_model] = values
+ break
+
+ # Column-oriented tables: models are columns and metrics are rows (for
+ # example Qwen2.5-VL variants). Resolve a split size suffix such as
+ # "72B" to its x coordinate, then join each metric row at that x.
+ if len(resolved) < len(metric_terms) and target_model:
+ size_match = re.search(r"(\d+(?:\.\d+)?[BMK])$", target_model, re.I)
+ target_x = None
+ if size_match:
+ suffix = size_match.group(1).casefold()
+ base_hits = [
+ (top, float(word["x0"]))
+ for top, words in rows for word in words
+ if target_base and str(word["text"]).casefold() == target_base.casefold()
+ ]
+ suffix_hits = [
+ (top, float(word["x0"]))
+ for top, words in rows for word in words
+ if str(word["text"]).casefold() == suffix
+ ]
+ pairs = [
+ (abs(base_top - suffix_top) + abs(base_x - suffix_x), suffix_x)
+ for base_top, base_x in base_hits for suffix_top, suffix_x in suffix_hits
+ if abs(base_top - suffix_top) <= 15 and abs(base_x - suffix_x) <= 30
+ ]
+ if pairs:
+ target_x = min(pairs)[1]
+ if target_x is None:
+ normalized_target = normalize(target_model)
+ metric_tops = [
+ top for top, words in rows
+ if any(
+ PdfExtractTool._pdf_words_contain_metric(words, metric)
+ for metric in metric_terms
+ )
+ ]
+ first_metric_top = min(metric_tops) if metric_tops else float("inf")
+ column_fragments: dict[int, list[tuple[float, str]]] = {}
+ for top, words in rows:
+ if top >= first_metric_top:
+ continue
+ for word in words:
+ text = str(word["text"])
+ if not re.search(r"[A-Za-z]", text):
+ continue
+ x_key = round(float(word["x0"]))
+ column_fragments.setdefault(x_key, []).append((top, text))
+ matches: list[tuple[int, int]] = []
+ for x_key, fragments in column_fragments.items():
+ combined = " ".join(text for _top, text in sorted(fragments))
+ normalized_combined = normalize(combined)
+ if (
+ normalized_combined
+ and normalized_target
+ and (
+ normalized_target in normalized_combined
+ or normalized_combined in normalized_target
+ )
+ ):
+ matches.append((len(normalized_combined), x_key))
+ if matches:
+ target_x = float(max(matches)[1])
+ if target_x is None and target_model:
+ target_folded = target_model.casefold()
+ mode_terms = []
+ if "instruct" in target_folded:
+ mode_terms.extend(["instruct", "non-thinking"])
+ if "thinking" in target_folded and "instruct" not in target_folded:
+ mode_terms.append("thinking")
+ if mode_terms:
+ metric_tops = [
+ top for top, words in rows
+ if any(
+ PdfExtractTool._pdf_words_contain_metric(words, metric)
+ for metric in metric_terms
+ )
+ ]
+ first_metric_top = min(metric_tops) if metric_tops else float("inf")
+ mode_hits = [
+ float(word["x0"])
+ for top, words in rows
+ if top < first_metric_top
+ for word in words
+ if str(word["text"]).casefold() in mode_terms
+ ]
+ if mode_hits:
+ target_x = mode_hits[0]
+ if target_x is not None:
+ for metric in metric_terms:
+ for row_index, (_top, words) in enumerate(rows):
+ if not PdfExtractTool._pdf_words_contain_metric(words, metric):
+ continue
+ nums = _numbers(words)
+ if not nums:
+ adjacent_numeric_rows = [
+ (
+ abs(float(rows[candidate_index][0]) - float(_top)),
+ rows[candidate_index][1],
+ )
+ for candidate_index in (row_index - 1, row_index + 1)
+ if 0 <= candidate_index < len(rows)
+ and abs(float(rows[candidate_index][0]) - float(_top)) <= 9
+ and _numbers(rows[candidate_index][1])
+ ]
+ if adjacent_numeric_rows:
+ nums = _numbers(
+ min(adjacent_numeric_rows, key=lambda item: item[0])[1]
+ )
+ if nums:
+ nearest = min(nums, key=lambda item: abs(item[0] - target_x))
+ if abs(nearest[0] - target_x) <= 30:
+ resolved[metric] = nearest[1]
+ break
+
+ rendered = []
+ for top, words in sorted(rows, key=lambda item: item[0]):
+ cells = " | ".join(
+ f"x={float(word['x0']):.0f}:{word['text']}" for word in words
+ )
+ rendered.append(f"y={top:.0f} :: {cells}")
+ body = "\n".join(rendered)
+ if len(body) > MAX_OUTPUT_CHARS - 1000:
+ body = body[:MAX_OUTPUT_CHARS - 1000] + "\n[...positioned rows truncated]"
+ resolved_text = ""
+ all_resolutions: list[tuple[str, dict[str, str]]] = []
+ if resolved:
+ all_resolutions.append((target_model or "requested model", resolved))
+ all_resolutions.extend(additional_row_resolutions.items())
+ resolved_sections: list[str] = []
+ for resolved_model, resolved_values in all_resolutions:
+ missing_metrics = [
+ metric for metric in metric_terms if metric not in resolved_values
+ ]
+ missing_note = (
+ " | requested metrics not found: " + ", ".join(missing_metrics)
+ if missing_metrics else ""
+ )
+ lock_values = {
+ metric: resolved_values[metric]
+ for metric in metric_terms
+ if metric in resolved_values
+ }
+ for metric in missing_metrics:
+ lock_values[metric] = None
+ resolved_sections.append(
+ "Resolved requested values by coordinate join: "
+ + resolved_model
+ + " | "
+ + " | ".join(
+ f"{metric}={resolved_values[metric]}"
+ for metric in metric_terms
+ if metric in resolved_values
+ )
+ + missing_note
+ + "\n"
+ + "Resolved values JSON: "
+ + json.dumps({
+ "model": resolved_model,
+ "values": lock_values,
+ "source": "pdf_extract_positioned_table",
+ "page": page_number,
+ }, sort_keys=True)
+ )
+ if resolved_sections:
+ resolved_text = "\n".join(resolved_sections) + "\n"
+ return (
+ f"[Positioned PDF table evidence, page {page_number}. Values sharing the same "
+ "x coordinate belong to the same column; map the requested model header x to "
+ "metric-row values at that x.]\n" + resolved_text + body
+ )
+
+ @staticmethod
+ def _local_text_evidence(path: Path, query: str) -> str:
+ """Return focused text pages when a local PDF has no positioned rows."""
+ try:
+ import pdfplumber
+ except ImportError:
+ return ""
+ tokens = {
+ token.casefold()
+ for token in re.findall(r"[A-Za-z0-9][A-Za-z0-9_.+-]{2,}", query)
+ }
+ requested_labels = [
+ re.sub(r"\s+", "", label.casefold())
+ for label in re.findall(r"\b(?:table|figure|fig\.?)\s*\d+\b", query, re.I)
+ ]
+ pages: list[tuple[int, int, str]] = []
+ page_columns: list[tuple[int, str, str]] = []
+ try:
+ with pdfplumber.open(path) as pdf:
+ for page_number, page in enumerate(pdf.pages, 1):
+ text = str(page.extract_text(layout=True) or "").strip()
+ if not text:
+ continue
+ midpoint = float(page.width) / 2
+ left = str(
+ page.crop((0, 0, midpoint, page.height)).extract_text() or ""
+ ).strip()
+ right = str(
+ page.crop((midpoint, 0, page.width, page.height)).extract_text() or ""
+ ).strip()
+ page_columns.append((page_number, left, right))
+ folded = text.casefold()
+ compact = re.sub(r"\s+", "", folded)
+ label_hits = sum(label in compact for label in requested_labels)
+ numeric_count = len(re.findall(r"\b\d+(?:\.\d+)?\b", text))
+ table_signal = bool(
+ re.search(r"\b(?:table|figure|fig\.?)\s*\d+\b", folded, re.I)
+ )
+ result_table_signal = bool(
+ re.search(
+ r"(?:table\s*3|evaluation\s+results\s+on\s+groundingme|"
+ r"main\s+results|main\s+experimental\s+results)",
+ folded,
+ re.I,
+ )
+ )
+ appendix_table_signal = bool(
+ re.search(
+ r"(?:supplementary\s+material|detailed\s+subtask|"
+ r"table\s*[67])",
+ folded,
+ re.I,
+ )
+ )
+ requested_result_terms = {
+ "appendix", "average", "baseline", "experimental",
+ "leaderboard", "results", "score", "scores", "thinking",
+ }
+ result_table_boost = (
+ 260
+ if result_table_signal
+ and tokens & requested_result_terms
+ else 0
+ )
+ appendix_table_boost = (
+ 220
+ if appendix_table_signal
+ and ("appendix" in tokens or "thinking" in tokens)
+ else 0
+ )
+ score = (
+ sum(token in folded for token in tokens)
+ + label_hits * 100
+ + (8 if table_signal else 0)
+ + min(numeric_count, 20) // 4
+ + result_table_boost
+ + appendix_table_boost
+ )
+ pages.append((score, page_number, text))
+ except Exception:
+ return ""
+ bibliography = PdfExtractTool._bibliography_evidence(page_columns, query)
+ if bibliography:
+ return bibliography
+ if not pages:
+ return ""
+ selected = sorted(
+ pages, key=lambda item: (item[0], -item[1]), reverse=True
+ )[:4]
+ # Put the most relevant pages first so the bounded output cap cannot
+ # truncate the requested table behind introductory pages. Page labels
+ # remain in each block, so callers can still cite the source page.
+ selected.sort(key=lambda item: (item[0], -item[1]), reverse=True)
+ body = "\n\n".join(
+ f"[Local PDF text evidence, page {page_number}]\n{text}"
+ for _score, page_number, text in selected
+ )
+ if len(body) > MAX_OUTPUT_CHARS - 500:
+ body = body[:MAX_OUTPUT_CHARS - 500] + "\n[...local PDF text truncated]"
+ return body
+
+ async def execute(self, content: str, ctx: dict) -> dict:
+ raw = content.strip()
+ try:
+ args = json.loads(raw) if raw.startswith("{") else {}
+ except json.JSONDecodeError:
+ args = {}
+ if not isinstance(args, dict):
+ args = {}
+ if not args:
+ lines = [line.strip() for line in raw.splitlines() if line.strip()]
+ args = {
+ "url": lines[0] if lines else "",
+ "query": ", ".join(lines[1:]),
+ }
+ url = str(args.get("url") or args.get("path") or "").strip()
+ query = str(args.get("query") or "").strip()
+ if not url:
+ return {"error": "pdf_extract: provide a PDF URL or local PDF path", "exit_code": 1}
+ if not query:
+ return {
+ "error": (
+ "pdf_extract: provide query terms naming the target model, "
+ "metrics, or table so the returned evidence is precise"
+ ),
+ "exit_code": 1,
+ }
+ loop = asyncio.get_running_loop()
+ local_path = self._local_pdf_path(url)
+ if local_path is not None:
+ from src.tool_execution import _display_tool_path
+
+ url = _display_tool_path(str(local_path))
+
+ async def _positioned_evidence() -> str:
+ try:
+ return await asyncio.wait_for(
+ loop.run_in_executor(None, self._positioned_table_evidence, url, query),
+ timeout=30,
+ )
+ except Exception:
+ return ""
+
+ # arXiv's PDF CDN can temporarily serve an older revision than its
+ # current HTML conversion. The HTML table is also substantially more
+ # legible than positioned PDF glyphs. Fetch both official forms in
+ # parallel and return complementary evidence, so a newly-added model
+ # row cannot disappear and trigger browser/shell retry loops.
+ html_url = self._arxiv_html_url(url) if local_path is None else ""
+ positioned_task = _positioned_evidence()
+ if html_url:
+ query_tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9_.+-]{1,}", query)
+ query_models = sorted(
+ {
+ token for token in query_tokens
+ if not self._looks_like_pdf_metric_term(token)
+ and self._looks_like_pdf_model_term(token)
+ },
+ key=len,
+ reverse=True,
+ )
+ # A broad query with two model names often locks onto the first of
+ # several identically numbered tables (for example dev Table 2
+ # instead of test Table 2). Query each requested model separately;
+ # the rarer/longer row is emitted first and anchors the right table.
+ if len(query_models) > 1:
+ # Exact model-only focus keeps the matching row inside the
+ # passage budget. Repeating every metric acronym can rank the
+ # table introduction above a late model row and truncate the
+ # very values the caller requested.
+ html_queries = query_models
+ else:
+ html_queries = [query]
+ html_tasks = [
+ WebFetchTool().execute(
+ json.dumps({"url": html_url, "query": focused, "full": True}), ctx
+ )
+ for focused in html_queries
+ ]
+ gathered = await asyncio.gather(positioned_task, *html_tasks)
+ positioned = gathered[0]
+ html_outputs: list[str] = []
+ for focused, html_result in zip(html_queries, gathered[1:]):
+ if html_result.get("exit_code") != 0:
+ continue
+ html_output = str(html_result.get("output") or "")
+ if html_output and html_output not in html_outputs:
+ html_outputs.append(
+ f"[Focused HTML evidence for: {focused}]\n{html_output}"
+ )
+ evidence = []
+ # Structured coordinate evidence is compact and column-safe. Put
+ # it first so a long prose extraction cannot consume the bounded
+ # response and truncate the exact table rows.
+ if positioned:
+ evidence.append(positioned)
+ if html_outputs:
+ evidence.append(
+ f"[Official arXiv HTML companion: {html_url}]\n"
+ + "\n\n--- additional requested model row ---\n\n".join(html_outputs)
+ )
+ if evidence:
+ output = (
+ f"Source: {url}\n\n"
+ "[Extraction guidance: align every value to the complete table header. "
+ "A row may contain extra unrequested generation metrics (for example VS or SSC); "
+ "skip those columns rather than treating the next contiguous number as the requested metric.]\n\n"
+ + "\n\n--- PDF/HTML corroboration ---\n\n".join(evidence)
+ )
+ if len(output) > MAX_OUTPUT_CHARS:
+ output = output[:MAX_OUTPUT_CHARS] + "\n[...combined evidence truncated]"
+ return {"output": output, "exit_code": 0}
+ else:
+ positioned = await positioned_task
+ if positioned:
+ return {"output": f"Source: {url}\n\n{positioned}", "exit_code": 0}
+ if local_path is not None:
+ text_evidence = await asyncio.wait_for(
+ loop.run_in_executor(
+ None, self._local_text_evidence, local_path, query
+ ),
+ timeout=30,
+ )
+ if text_evidence:
+ return {
+ "output": f"Source: {url}\n\n{text_evidence}",
+ "exit_code": 0,
+ }
+ return {
+ "error": (
+ "pdf_extract: no selectable table/text evidence was found in the local PDF; "
+ "use inspect_media for scanned or visual pages"
+ ),
+ "exit_code": 1,
+ }
+ fetch_args = {"url": url, "query": query, "full": True}
+ result = await WebFetchTool().execute(json.dumps(fetch_args), ctx)
+ if result.get("exit_code") == 0:
+ result["output"] = (
+ "[PDF extraction; the highest-ranked matching table window is below. "
+ "Align row values to the full column header, use only the requested model's "
+ "column, and do not re-query when the target row and header are present.]\n\n"
+ + str(result.get("output") or "")
+ )
+ return result
+
+
+class YouTubeTool:
+ """YouTube-specific read tool for videos, transcripts, comments, channels."""
+
+ _ACTIONS = {"comments", "transcript", "metadata", "latest_channel_video"}
+
+ async def execute(self, content: str, ctx: dict) -> dict:
+ from services.youtube.youtube_handler import (
+ extract_transcript_async,
+ fetch_youtube_comments,
+ init_youtube,
+ )
+
+ args, err = self._parse_args(content)
+ if err:
+ return {"error": err, "exit_code": 1}
+ action = str(args.get("action") or "metadata").strip().lower()
+ if action not in self._ACTIONS:
+ return {
+ "error": "youtube_tool: action must be one of "
+ + ", ".join(sorted(self._ACTIONS)),
+ "exit_code": 1,
+ }
+
+ max_results = args.get("max_results")
+ if not isinstance(max_results, int) or max_results <= 0:
+ max_results = 20
+ max_results = max(1, min(50, max_results))
+
+ progress_cb = ctx.get("progress_cb") if isinstance(ctx, dict) else None
+ if progress_cb:
+ await progress_cb({"elapsed_s": 0, "tail": f"youtube_tool: {action}"})
+
+ if action == "latest_channel_video":
+ channel = str(args.get("channel_url") or args.get("url") or args.get("handle") or "").strip()
+ if not channel:
+ return {"error": "youtube_tool latest_channel_video: provide channel_url or handle", "exit_code": 1}
+ return await self._latest_channel_video(channel, max_results=max_results)
+
+ if action == "metadata" and not any(args.get(k) for k in ("url", "video_url", "video_id")):
+ channel = str(args.get("channel_url") or args.get("handle") or "").strip()
+ if channel:
+ return await self._latest_channel_video(channel, max_results=max_results)
+
+ url_or_id = str(args.get("url") or args.get("video_url") or args.get("video_id") or "").strip()
+ # The shared extractor accepts ID prefixes inside text. At the tool
+ # boundary require the complete target, not a truncated invented ID.
+ if url_or_id.startswith(('http://', 'https://')):
+ parsed = urllib.parse.urlparse(url_or_id)
+ host = (parsed.hostname or '').lower()
+ if host in {'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com'}:
+ parts = parsed.path.strip('/').split('/')
+ candidate = (urllib.parse.parse_qs(parsed.query).get('v', [''])[0]
+ if parsed.path == '/watch' else
+ parts[1] if len(parts) == 2 and parts[0] in {'shorts', 'embed', 'live'} else '')
+ elif host in {'youtu.be', 'www.youtu.be'}:
+ candidate = parsed.path.strip('/')
+ else:
+ candidate = ''
+ else:
+ candidate = url_or_id
+ if (not re.fullmatch(r'[A-Za-z0-9_-]{11}', candidate)
+ or (args.get('video_id') and str(args['video_id']) != candidate)):
+ return {"error": f"youtube_tool {action}: invalid video target. Resolve the actual video URL "
+ "from the user or an observed link. A title or channel page is not a video ID; "
+ "open the referenced video in the browser or use latest_channel_video first.",
+ "exit_code": 1, "failure_kind": "invalid_target"}
+ video_id = candidate
+ url = url_or_id if url_or_id.startswith(("http://", "https://")) else f"https://www.youtube.com/watch?v={video_id}"
+
+ if action == "comments":
+ api_result = await self._comments_from_data_api(video_id, max_results)
+ if api_result.get("success"):
+ return {"output": self._format_comments(api_result, url), "exit_code": 0, "untrusted_content": True}
+ comments_data = await fetch_youtube_comments(video_id, max_comments=max_results, timeout=45)
+ if not comments_data.get("success"):
+ api_error = api_result.get("error")
+ fallback_error = comments_data.get("error") or "unknown error"
+ joined = f"{fallback_error}"
+ if api_error:
+ joined = f"YouTube Data API unavailable: {api_error}; yt-dlp fallback failed: {fallback_error}"
+ return {"error": f"youtube_tool comments: {joined}", "exit_code": 1,
+ "failure_kind": "comments_unavailable", "video_url": url,
+ "untrusted_content": True}
+ return {"output": self._format_comments(comments_data, url), "exit_code": 0, "untrusted_content": True}
+
+ if action == "transcript":
+ init_youtube()
+ transcript_data = await extract_transcript_async(url, video_id)
+ if not transcript_data.get("success"):
+ return {
+ "error": f"youtube_tool transcript: {transcript_data.get('error') or 'transcript unavailable'}",
+ "exit_code": 1,
+ "untrusted_content": True,
+ }
+ return {"output": self._format_transcript(transcript_data, url), "exit_code": 0, "untrusted_content": True}
+
+ return await self._metadata(url)
+
+ def _parse_args(self, content: str) -> tuple[dict, str | None]:
+ raw = (content or "").strip()
+ if not raw:
+ return {}, "youtube_tool: provide JSON with action and url/video_id"
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError:
+ return {"action": "metadata", "url": raw}, None
+ if not isinstance(parsed, dict):
+ return {}, "youtube_tool: arguments must be a JSON object"
+ return parsed, None
+
+ async def _comments_from_data_api(self, video_id: str, max_results: int) -> dict:
+ api_key = os.getenv("YOUTUBE_API_KEY", "").strip()
+ if not api_key:
+ return {"success": False, "error": "YOUTUBE_API_KEY not set", "comments": []}
+ params = urllib.parse.urlencode({
+ "part": "snippet",
+ "videoId": video_id,
+ "maxResults": str(max_results),
+ "order": "relevance",
+ "textFormat": "plainText",
+ "key": api_key,
+ })
+ url = f"https://www.googleapis.com/youtube/v3/commentThreads?{params}"
+
+ def _fetch() -> dict:
+ req = urllib.request.Request(url, headers={"User-Agent": "odysseus-ui/1.0"})
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read().decode("utf-8", errors="replace"))
+
+ try:
+ data = await asyncio.wait_for(asyncio.to_thread(_fetch), timeout=20)
+ except Exception as e:
+ return {"success": False, "error": f"{type(e).__name__}: {e}", "comments": []}
+
+ comments = []
+ for item in data.get("items") or []:
+ snippet = (
+ (item.get("snippet") or {})
+ .get("topLevelComment", {})
+ .get("snippet", {})
+ )
+ text = html.unescape(str(snippet.get("textDisplay") or snippet.get("textOriginal") or "")).strip()
+ if not text:
+ continue
+ comments.append({
+ "author": snippet.get("authorDisplayName") or "Unknown",
+ "text": text,
+ "likes": snippet.get("likeCount") or 0,
+ })
+ return {"success": True, "comments": comments, "count": len(comments), "source": "youtube_data_api"}
+
+ def _format_comments(self, data: dict, url: str) -> str:
+ comments = [c for c in (data.get("comments") or []) if isinstance(c, dict)]
+ if not comments:
+ return f"YouTube comments for {url}: no comments returned."
+ lines = [
+ f"YouTube comments for {url}",
+ f"Source: {data.get('source') or 'yt-dlp'}",
+ f"Count: {len(comments)}",
+ ]
+ title = str(data.get("title") or "").strip()
+ channel = str(data.get("channel") or "").strip()
+ if title:
+ lines.append(f"Title: {title}")
+ if channel:
+ lines.append(f"Channel: {channel}")
+ lines.append("")
+ for idx, comment in enumerate(comments, 1):
+ likes = comment.get("likes") or 0
+ like_text = f" [{likes} likes]" if likes else ""
+ author = str(comment.get("author") or "Unknown").strip().lstrip("@")
+ text = re.sub(r"\s+", " ", str(comment.get("text") or "")).strip()
+ lines.append(f"{idx}. @{author}{like_text}: {text}")
+ output = "\n".join(lines)
+ return output[:MAX_OUTPUT_CHARS] + ("\n\n[...truncated]" if len(output) > MAX_OUTPUT_CHARS else "")
+
+ def _format_transcript(self, data: dict, url: str) -> str:
+ lines = [
+ f"YouTube transcript for {url}",
+ f"Video ID: {data.get('video_id') or ''}",
+ f"Language: {data.get('language') or 'unknown'}",
+ "",
+ ]
+ segments = data.get("segments") or []
+ if segments:
+ for seg in segments:
+ if not isinstance(seg, dict):
+ continue
+ lines.append(f"[{seg.get('timestamp') or '??:??'}] {seg.get('text') or ''}")
+ else:
+ lines.append(str(data.get("transcript") or ""))
+ output = "\n".join(lines)
+ return output[:MAX_OUTPUT_CHARS] + ("\n\n[...truncated]" if len(output) > MAX_OUTPUT_CHARS else "")
+
+ async def _metadata(self, url: str) -> dict:
+ result = await self._ytdlp_json(url, timeout=30)
+ if not result.get("success"):
+ return {"error": f"youtube_tool metadata: {result.get('error')}", "exit_code": 1, "untrusted_content": True}
+ data = result["data"]
+ lines = [
+ f"Title: {data.get('title') or ''}",
+ f"Channel: {data.get('channel') or data.get('uploader') or ''}",
+ f"URL: {data.get('webpage_url') or url}",
+ f"Duration: {data.get('duration_string') or data.get('duration') or ''}",
+ f"View count: {data.get('view_count') or ''}",
+ f"Like count: {data.get('like_count') or ''}",
+ f"Upload date: {data.get('upload_date') or ''}",
+ ]
+ return {"output": "\n".join(lines), "exit_code": 0, "untrusted_content": True}
+
+ async def _latest_channel_video(self, channel: str, *, max_results: int = 5) -> dict:
+ original_channel = channel
+ if channel.startswith("@"):
+ channel = f"https://www.youtube.com/{channel}/videos"
+ elif channel.startswith(("http://", "https://")):
+ if "/videos" not in urllib.parse.urlparse(channel).path:
+ channel = channel.rstrip("/") + "/videos"
+ else:
+ channel = f"https://www.youtube.com/{channel.strip('/')}/videos"
+ result = await self._ytdlp_json(channel, timeout=45, flat_playlist=True, playlist_end=max_results)
+ if not result.get("success"):
+ resolved_channel = await self._resolve_channel_from_search(original_channel)
+ if resolved_channel:
+ retry = await self._ytdlp_json(
+ resolved_channel,
+ timeout=45,
+ flat_playlist=True,
+ playlist_end=max_results,
+ )
+ if retry.get("success"):
+ channel = resolved_channel
+ result = retry
+ else:
+ return {
+ "error": (
+ "youtube_tool latest_channel_video: "
+ f"{result.get('error')}; resolved {resolved_channel} also failed: {retry.get('error')}"
+ ),
+ "exit_code": 1,
+ "untrusted_content": True,
+ }
+ else:
+ return {"error": f"youtube_tool latest_channel_video: {result.get('error')}", "exit_code": 1, "untrusted_content": True}
+ if not result.get("success"):
+ return {"error": f"youtube_tool latest_channel_video: {result.get('error')}", "exit_code": 1, "untrusted_content": True}
+ data = result["data"]
+ entries = [e for e in (data.get("entries") or []) if isinstance(e, dict)]
+ if not entries:
+ return {"error": "youtube_tool latest_channel_video: no videos found", "exit_code": 1, "untrusted_content": True}
+ entries = entries[:max_results]
+ heading = "Latest channel video" if len(entries) == 1 else f"Latest {len(entries)} channel videos"
+ lines = [f"{heading} for {channel}", ""]
+ for idx, entry in enumerate(entries, 1):
+ video_id = entry.get("id") or ""
+ video_url = entry.get("url") or entry.get("webpage_url") or ""
+ if video_id and not str(video_url).startswith("http"):
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
+ lines.extend([
+ f"{idx}. {entry.get('title') or ''}",
+ f" URL: {video_url}",
+ f" Duration: {entry.get('duration_string') or entry.get('duration') or ''}",
+ f" Video ID: {video_id}",
+ ])
+ return {"output": "\n".join(lines), "exit_code": 0, "untrusted_content": True}
+
+ async def _resolve_channel_from_search(self, channel: str) -> str:
+ query = self._channel_search_query(channel)
+ if not query:
+ return ""
+ result = await self._ytdlp_json(
+ f"ytsearch10:{query} official YouTube channel",
+ timeout=30,
+ flat_playlist=True,
+ playlist_end=10,
+ )
+ if not result.get("success"):
+ return ""
+ entries = [e for e in (result.get("data") or {}).get("entries") or [] if isinstance(e, dict)]
+ if not entries:
+ return ""
+ target = self._normalize_channel_name(query)
+
+ def _score(entry: dict) -> tuple[int, int]:
+ channel_name = self._normalize_channel_name(entry.get("channel") or entry.get("uploader") or "")
+ uploader_id = self._normalize_channel_name(entry.get("uploader_id") or "")
+ title = self._normalize_channel_name(entry.get("title") or "")
+ score = 0
+ if channel_name == target:
+ score += 100
+ elif target and target in channel_name:
+ score += 60
+ if uploader_id == target:
+ score += 45
+ elif target and target in uploader_id:
+ score += 25
+ if target and target in title:
+ score += 10
+ if re.search(r"\b(?:clips?|shorts?|two|second|fan|archive)\b", channel_name):
+ score -= 35
+ if re.search(r"\b(?:clips?|shorts?|compilation|reacts?)\b", title):
+ score -= 10
+ return score, int(entry.get("view_count") or 0)
+
+ best = max(entries, key=_score)
+ if _score(best)[0] <= 0:
+ return ""
+ channel_url = str(best.get("channel_url") or "").strip()
+ uploader_url = str(best.get("uploader_url") or "").strip()
+ resolved = channel_url or uploader_url
+ if not resolved:
+ uploader_id = str(best.get("uploader_id") or "").strip()
+ if uploader_id.startswith("@"):
+ resolved = f"https://www.youtube.com/{uploader_id}"
+ if not resolved:
+ return ""
+ parsed = urllib.parse.urlparse(resolved)
+ if parsed.netloc and "/videos" not in parsed.path:
+ resolved = resolved.rstrip("/") + "/videos"
+ return resolved
+
+ @staticmethod
+ def _channel_search_query(channel: str) -> str:
+ value = str(channel or "").strip()
+ if not value:
+ return ""
+ if value.startswith(("http://", "https://")):
+ path = urllib.parse.urlparse(value).path.strip("/")
+ parts = [part for part in path.split("/") if part and part.lower() != "videos"]
+ value = parts[-1] if parts else value
+ value = value.strip().lstrip("@").strip("/")
+ value = re.sub(r"(?i)^(?:c|channel|user)/", "", value)
+ value = re.sub(r"[_-]+", " ", value)
+ return re.sub(r"\s+", " ", value).strip()
+
+ @staticmethod
+ def _normalize_channel_name(value: str) -> str:
+ value = str(value or "").lower().lstrip("@")
+ value = re.sub(r"[^a-z0-9]+", "", value)
+ return value
+
+ async def _ytdlp_json(self, url: str, *, timeout: int, flat_playlist: bool = False, playlist_end: int = 5) -> dict:
+ binary = shutil.which("yt-dlp")
+ if not binary:
+ venv_binary = os.path.join(os.path.dirname(sys.executable), "yt-dlp")
+ binary = venv_binary if os.path.exists(venv_binary) else ""
+ if not binary:
+ return {"success": False, "error": "yt-dlp not installed"}
+ cmd = [binary, "--skip-download", "--no-warnings", "--js-runtimes", "node"]
+ if flat_playlist:
+ playlist_end = max(1, min(50, int(playlist_end or 5)))
+ cmd.extend(["--flat-playlist", "--playlist-end", str(playlist_end), "--dump-single-json"])
+ else:
+ cmd.append("--dump-json")
+ cmd.append(url)
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ with contextlib.suppress(Exception):
+ proc.kill()
+ await proc.wait()
+ return {"success": False, "error": f"yt-dlp timed out after {timeout}s"}
+ except Exception as e:
+ return {"success": False, "error": f"{type(e).__name__}: {e}"}
+ if proc.returncode != 0:
+ return {"success": False, "error": stderr.decode("utf-8", errors="replace")[:300]}
+ try:
+ data = self._parse_ytdlp_json_output(stdout.decode("utf-8", errors="replace"))
+ except json.JSONDecodeError as e:
+ return {"success": False, "error": f"invalid yt-dlp JSON: {e}"}
+ return {"success": True, "data": data}
+
+ @staticmethod
+ def _parse_ytdlp_json_output(output: str) -> dict:
+ """Parse yt-dlp JSON from either a single object or JSON-lines output."""
+ text = (output or "").strip()
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError:
+ entries = []
+ for line in text.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ parsed = json.loads(line)
+ if isinstance(parsed, dict):
+ entries.append(parsed)
+ return {"entries": entries}
+ if isinstance(data, dict):
+ return data
+ if isinstance(data, list):
+ return {"entries": [item for item in data if isinstance(item, dict)]}
+ return {"entries": []}
+
+
+class PrivateBrowserTool:
+ """Small deterministic wrapper around Vercel's agent-browser CLI.
+
+ This is intentionally narrower than handing the model the raw browser MCP
+ schema. Use web_search/web_fetch first; this exists for JS-rendered pages,
+ forms, clicks, screenshots, and logged-in browser state.
+ """
+
+ _ACTIONS = {
+ "open",
+ "read",
+ "snapshot",
+ "find",
+ "evaluate",
+ "click",
+ "fill",
+ "press",
+ "scroll",
+ "wait",
+ "screenshot",
+ "close",
+ "batch",
+ }
+ _AUTO_SCREENSHOT_ACTIONS = {
+ "open",
+ "snapshot",
+ "batch",
+ "click",
+ "fill",
+ "press",
+ "scroll",
+ }
+
+ @staticmethod
+ def _shopping_landing_hint(output: str) -> str:
+ """Expose the actionable store link on global retail landing pages."""
+ text = str(output or "")
+ if not re.search(r"\b(?:global|choose another store|store selector)\b", text, re.IGNORECASE):
+ return ""
+ match = re.search(
+ r'link\s+"(?P`` 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('') if lowered.startswith('') else 0
+ close = lowered.find('')
+ if close < 0:
+ # The opening is injected by the chat template, so a progressive
+ # trace can arrive with no marker until . 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(''))]
+ answer_region = ''
+ else:
+ think_region = text[lead:close]
+ answer_region = text[close + len(''):]
+ 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']*>.*?', '',
+ 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']*>.*?', '',
+ _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'}
diff --git a/src/context_compactor.py b/src/context_compactor.py
index 05b53ce95..1137fe7f6 100644
--- a/src/context_compactor.py
+++ b/src/context_compactor.py
@@ -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))
diff --git a/src/document_processor.py b/src/document_processor.py
index 38643cb5a..a623c1a1b 100644
--- a/src/document_processor.py
+++ b/src/document_processor.py
@@ -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}")
diff --git a/src/email_task_intent.py b/src/email_task_intent.py
index 7c4f0615e..6742425c7 100644
--- a/src/email_task_intent.py
+++ b/src/email_task_intent.py
@@ -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
diff --git a/src/jeff_decision.py b/src/jeff_decision.py
new file mode 100644
index 000000000..52c7162f4
--- /dev/null
+++ b/src/jeff_decision.py
@@ -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_ 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_ 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
diff --git a/src/private_browser_legacy_schema.py b/src/private_browser_legacy_schema.py
new file mode 100644
index 000000000..4753c6271
--- /dev/null
+++ b/src/private_browser_legacy_schema.py
@@ -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"]
+ }
+ }
+ }
diff --git a/src/prompt_security.py b/src/prompt_security.py
index 8330b027a..40bae92e3 100644
--- a/src/prompt_security.py
+++ b/src/prompt_security.py
@@ -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,
diff --git a/src/session_actions.py b/src/session_actions.py
index 072bb4c06..1efa484e3 100644
--- a/src/session_actions.py
+++ b/src/session_actions.py
@@ -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)."
diff --git a/src/spec_driver.py b/src/spec_driver.py
new file mode 100644
index 000000000..5a2597959
--- /dev/null
+++ b/src/spec_driver.py
@@ -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'
diff --git a/src/spec_router.py b/src/spec_router.py
new file mode 100644
index 000000000..8b0008857
--- /dev/null
+++ b/src/spec_router.py
@@ -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=<<>>old<<>>new<<>> 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
diff --git a/src/tool_execution.py b/src/tool_execution.py
index a8f67b60d..330bcedef 100644
--- a/src/tool_execution.py
+++ b/src/tool_execution.py
@@ -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))
\ No newline at end of file
+ return _truncate("\n".join(parts))
diff --git a/src/tool_schemas.py b/src/tool_schemas.py
index 35361e185..13deaf4f7 100644
--- a/src/tool_schemas.py
+++ b/src/tool_schemas.py
@@ -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)
\ No newline at end of file
+ return ToolBlock(tool_type, content)
diff --git a/static/js/chat.js b/static/js/chat.js
index 7b0d70418..62763cabd 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -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) {
diff --git a/tests/test_auth_session_persistence.py b/tests/test_auth_session_persistence.py
new file mode 100644
index 000000000..4cd5390d9
--- /dev/null
+++ b/tests/test_auth_session_persistence.py
@@ -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
diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py
index 2526562b3..39d0dce1d 100644
--- a/tests/test_clean_agent_preview.py
+++ b/tests/test_clean_agent_preview.py
@@ -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)
diff --git a/tests/test_email_reader_completion_regression.py b/tests/test_email_reader_completion_regression.py
new file mode 100644
index 000000000..d230e2789
--- /dev/null
+++ b/tests/test_email_reader_completion_regression.py
@@ -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 == ()
diff --git a/tests/test_endpoint_owner_scope_followup.py b/tests/test_endpoint_owner_scope_followup.py
index 85265ae5f..c76693e30 100644
--- a/tests/test_endpoint_owner_scope_followup.py
+++ b/tests/test_endpoint_owner_scope_followup.py
@@ -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",
diff --git a/tests/test_foreground_model_routing.py b/tests/test_foreground_model_routing.py
index a683595e8..468fe32d7 100644
--- a/tests/test_foreground_model_routing.py
+++ b/tests/test_foreground_model_routing.py
@@ -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=[],
diff --git a/tests/test_jeff_decision.py b/tests/test_jeff_decision.py
new file mode 100644
index 000000000..3ae20759b
--- /dev/null
+++ b/tests/test_jeff_decision.py
@@ -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 ", "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
diff --git a/tests/test_memory_extraction_evidence.py b/tests/test_memory_extraction_evidence.py
new file mode 100644
index 000000000..ac36f73f0
--- /dev/null
+++ b/tests/test_memory_extraction_evidence.py
@@ -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."}])
diff --git a/tests/test_memory_extractor_vector_cross_tenant.py b/tests/test_memory_extractor_vector_cross_tenant.py
index 1b826b55e..640e78770 100644
--- a/tests/test_memory_extractor_vector_cross_tenant.py
+++ b/tests/test_memory_extractor_vector_cross_tenant.py
@@ -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
diff --git a/tests/test_memory_extractor_vector_degraded.py b/tests/test_memory_extractor_vector_degraded.py
index 1b3bd2475..18cca5e88 100644
--- a/tests/test_memory_extractor_vector_degraded.py
+++ b/tests/test_memory_extractor_vector_degraded.py
@@ -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)
diff --git a/tests/test_model_picker_refresh_timeout.py b/tests/test_model_picker_refresh_timeout.py
new file mode 100644
index 000000000..09c725cbb
--- /dev/null
+++ b/tests/test_model_picker_refresh_timeout.py
@@ -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"]
diff --git a/tests/test_session_load_404s.py b/tests/test_session_load_404s.py
new file mode 100644
index 000000000..77e12f085
--- /dev/null
+++ b/tests/test_session_load_404s.py
@@ -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/ (no research for this session)
+ GET /api/chat/stream_status/ (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/ -------------------------------------------
+
+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/ ----------------------------------------
+
+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
diff --git a/tests/test_spec_direct_notools.py b/tests/test_spec_direct_notools.py
new file mode 100644
index 000000000..59567ea0e
--- /dev/null
+++ b/tests/test_spec_direct_notools.py
@@ -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. '
+ '{"name": "web_search", "arguments": {"query": "Ajax"}}'
+ ' 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 ' 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 {"name": "web_search", "arguments": {}} 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 ' 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
diff --git a/tests/test_web_history_retention.py b/tests/test_web_history_retention.py
new file mode 100644
index 000000000..8f1e5029f
--- /dev/null
+++ b/tests/test_web_history_retention.py
@@ -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)
diff --git a/website/configuration-reference.md b/website/configuration-reference.md
index 86baa197d..2ee5588a4 100644
--- a/website/configuration-reference.md
+++ b/website/configuration-reference.md
@@ -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