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 '