diff --git a/.env.example b/.env.example index f1b396a47..edb147aaf 100644 --- a/.env.example +++ b/.env.example @@ -101,9 +101,9 @@ SEARXNG_INSTANCE=http://localhost:8080 # Skip the external-context exact-approval pause for unattended local agents. # Keep false for shared or internet-exposed deployments. -# Post-external-context tool approval gate. On by default; set to 0 only for -# trusted single-user setups that accept injected content reaching side-effect tools. -# ODYSSEUS_TOOL_APPROVAL_GATE=1 +# Optional post-external-context tool approval gate. Off by default because it +# can block normal agent work; enable only for deployments that want this fence. +# ODYSSEUS_TOOL_APPROVAL_GATE=0 # Mark session cookies Secure. Left unset, this follows the request scheme: # an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to diff --git a/.gitignore b/.gitignore index 1ae2878b5..b6fe4d420 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ services/data/ # Test capture artifacts (browser session dumps may contain personal content) .playwright-mcp/ +# Local editor bench/probe artifacts (sessions.json holds session tokens) +bench-out/ # OS .DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..9602c57b0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/photocraft"] + path = vendor/photocraft + url = https://github.com/storytold/photocraft diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 38e184764..3058687c7 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -57,10 +57,14 @@ Vendored in `static/lib/` and served directly: | Library | Purpose | License | |---|---|---| -| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause ([full notice](licenses/highlightjs-BSD-3-Clause.txt)) | -| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) v0.20.3 (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 ([full notice](licenses/SheetJS-Apache-2.0.txt)) | -| [docx](https://github.com/dolanmiu/docx) v8.5.0 (`docx.umd.min.js`) | Generate `.docx` documents | MIT and bundled permissive notices ([full notices](licenses/docx-8.5.0-NOTICES.txt)) | -| [mammoth.js](https://github.com/mwilliamson/mammoth.js) v1.8.0 | Convert `.docx` → HTML | BSD-2-Clause and bundled permissive notices ([full notices](licenses/mammoth-1.8.0-NOTICES.txt)) | +| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause | +| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 | +| [docx](https://github.com/dolanmiu/docx) (`docx.umd.min.js`) | Generate `.docx` documents | MIT | +| [mammoth.js](https://github.com/mwilliamson/mammoth.js) | Convert `.docx` → HTML | BSD-2-Clause | +| [html2pdf.js](https://github.com/eKoopmans/html2pdf.js) | HTML → PDF export (bundles jsPDF + html2canvas) | MIT | +| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT | +| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT | +| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT | | [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) | | [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) | @@ -72,13 +76,6 @@ browser that supports `woff2`. The bundles are the published npm artifacts, unmodified — `.gitattributes` turns the whitespace check off for `static/lib/` so they can stay byte-identical to upstream. -Exact artifact hashes, upstream archive members, local filename mappings and -notice sources are recorded in [THIRD_PARTY_PROVENANCE.json](THIRD_PARTY_PROVENANCE.json). -SheetJS copyright and attribution are preserved in its full distribution license; -highlight.js attribution is Copyright 2006 Ivan Sagalaev. -Browser printing supplies the client Print / save PDF flow. 2FA QR images are -generated by the Python qrcode dependency listed below. - ## Front-end libraries loaded at runtime (CDN) Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored: @@ -94,8 +91,9 @@ Bundled in `static/fonts/`: | Font | License | Author | |---|---|---| -| [Fira Code](https://github.com/tonsky/FiraCode) 6.2 | SIL Open Font License 1.1 ([full notice](licenses/FiraCode-OFL-1.1.txt)) | Nikita Prokopov & contributors | -| [Inter](https://github.com/rsms/inter) 4.1 (hinted WOFF2) | SIL Open Font License 1.1 ([full notice](licenses/Inter-OFL-1.1.txt)) | Rasmus Andersson | +| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors | +| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson | +| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois | | [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez | ## Python dependencies @@ -172,4 +170,12 @@ concerns from earlier are resolved: ## Thanks to +Most of Odysseus's code was written *with* AI models, not just by a human. +The project would not exist without them — credit where credit is due: + +- **gpt-oss-120b** — the legend that kicked this project off. +- **Qwen3-235B** +- **DeepSeek V3.1 · DeepSeek V4 Pro · DeepSeek V4 Flash** +- **Claude** (Anthropic) +- **Codex** (OpenAI) - Friends, for helping me debug. diff --git a/HARNESS_VERSION b/HARNESS_VERSION index e7797346a..b6b54654b 100644 --- a/HARNESS_VERSION +++ b/HARNESS_VERSION @@ -1 +1 @@ -0.20.19 +0.20.84 diff --git a/Odysseus.spec b/Odysseus.spec index 8aab775e8..547460c69 100644 --- a/Odysseus.spec +++ b/Odysseus.spec @@ -5,7 +5,7 @@ a = Analysis( ['launcher.py'], pathex=[], binaries=[], - datas=[('licenses', 'licenses'), ('THIRD_PARTY_PROVENANCE.json', '.'), ('ACKNOWLEDGMENTS.md', '.'), ('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')], + datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index ba4999157..d63d1335b 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -60,11 +60,13 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se **Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug. -### Post-external-context tool approval gate — on by default +### Post-external-context tool approval gate — off by default `src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately. -It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation. +**It is disabled unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set** (`1`/`true`/`yes`/`on`). The default is off because the gate is conservative enough to interrupt ordinary agent work. That is a deliberate usability trade, and it means a default deployment relies on the wrapper above — not on the gate — to contain injected instructions. + +Operators who run the agent against untrusted web or email content with side-effecting tools enabled should turn it on. With the gate off, a successful injection can reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation; with it on, each of those is refused until approved. Two exemptions apply even when the gate is on, both deliberate: @@ -83,7 +85,7 @@ Two exemptions apply even when the gate is on, both deliberate: These are open, acknowledged, and contributor help is welcome: -1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control and is on by default; turning it off leaves this gap unmitigated beyond the untrusted-context wrapper. +1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control, and it is off by default — so on a default deployment this gap is unmitigated beyond the untrusted-context wrapper. 2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this. diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py index ed8d5acff..e7c2ceb95 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], @@ -1198,7 +1201,7 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict: "folder": folder, "is_read": bool(row.get("read")), "is_done": bool(row.get("done") or row.get("answered")), - "is_favorite": bool(row.get("favorite") or row.get("flagged") or row.get("starred")), + "is_favorite": bool(row.get("favorite", row.get("flagged") or row.get("starred"))), "spam_label": str(row.get("spam_label") or ""), "spam_score": int(row.get("spam_score") or 0), "list_unsubscribe": str(row.get("list_unsubscribe") or headers.get("List-Unsubscribe") or ""), @@ -1522,15 +1525,17 @@ def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None) return None if not _fixture_owner_has_rows(_current_owner()): return None - for item in _fixture_email_rows(_current_owner()): - if not _fixture_row_matches_account(item, account): - continue - if not _fixture_folder_matches(item.get("folder"), folder): - continue - if uid and str(item.get("uid")) == str(uid): - return item - if message_id and str(item.get("message_id")) == str(message_id): - return item + if not uid and not message_id: + return {"error": "Email UID or Message-ID is required"} + matches = [item for item in _fixture_email_rows(_current_owner()) + if _fixture_row_matches_account(item, account) + and _fixture_folder_matches(item.get("folder"), folder) + and (not uid or str(item.get("uid")) == str(uid)) + and (not message_id or str(item.get("message_id")) == str(message_id))] + if len(matches) > 1: + return {"error": "Email identity is ambiguous; specify its account and folder"} + if matches: + return matches[0] return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"} @@ -1557,6 +1562,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 +1574,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 +2936,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 +3191,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": @@ -3504,21 +3519,6 @@ def _block_sender(sender=None, uids=None, folder="INBOX", account=None, reason=" } -def _attachment_dir(folder, uid, account_id) -> Path: - """Owner/account-scoped extraction directory (see src.mail_attachment_paths). - - `folder` comes from tool arguments and the IMAP server's own mailbox names - (`/` hierarchies, absolute or `..` segments), so it must not become a path; - the old `MAIL_ATTACHMENTS_DIR/{folder}_{uid}` wrote outside the root and - shared one directory between every owner's "INBOX 42". - """ - from src.mail_attachment_paths import attachment_scope_dir - - return attachment_scope_dir( - MAIL_ATTACHMENTS_DIR, folder, uid, owner=_current_owner(), account_id=account_id, - ) - - def _download_attachment(uid, index, folder="INBOX", account=None): """Extract a specific attachment to disk and return its local path.""" fixture = _fixture_attachment_source(uid, index, folder=folder, account=account) @@ -3527,7 +3527,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None): filename = str(att.get("filename") or f"attachment-{index}.txt") safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt" content = str(att.get("content") or "") - target_dir = _attachment_dir(folder, uid, _row.get("account_id") or account) + target_dir = Path(MAIL_ATTACHMENTS_DIR) / re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}") path = target_dir / safe_name size = len(content.encode("utf-8")) try: @@ -3560,7 +3560,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None): raw = msg_data[0][1] msg = email.message_from_bytes(raw) - target_dir = _attachment_dir(folder, uid, _load_config(account).get("account_id") or account) + target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}" filepath = _extract_attachment_to_disk(msg, index, target_dir) if not filepath: return {"error": f"Attachment index {index} not found"} diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index aa84768fd..ac7107ccb 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str: lower = s.lower().strip() def _parse_time(t): - t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) + t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower() @@ -433,7 +433,7 @@ def parse_due_for_user(s: str) -> str: return base.replace(hour=t[0], minute=t[1]).isoformat() # Time-first: "3pm today", "11pm today", "9am tomorrow" - m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) + m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) if m: time_part, word = m.group(1).strip(), m.group(2) base = today @@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime: def _parse_time(t: str): """Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None.""" - t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) + t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None @@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime: # time-first: "3pm today", "9am tomorrow", "11pm tonight" # (parity with parse_due_for_user, which handles these via the same form) - m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) + m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) if m: time_part, word = m.group(1).strip(), m.group(2) base = today diff --git a/routes/chat_routes.py b/routes/chat_routes.py index e3f7bfa02..d3257882d 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -11,7 +11,7 @@ from urllib.parse import urlparse from datetime import datetime from typing import Dict, Any, AsyncGenerator, List, Optional -from fastapi import APIRouter, Request, HTTPException, Form, Query, Depends +from fastapi import APIRouter, Request, HTTPException, Form, Query from fastapi.responses import StreamingResponse from pydantic import ValidationError @@ -32,6 +32,7 @@ from src.agent_loop import ( ) 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, @@ -48,13 +49,7 @@ from src.foreground_model_routing import ( from src.session_search import search_session_messages from src.prompt_security import untrusted_context_message from core.exceptions import SessionNotFoundError -from src.auth_helpers import ( - effective_user, - get_current_user, - is_delegated_credential, - require_api_token_scope, - require_chat_api_token_scope, -) +from src.auth_helpers import effective_user, get_current_user from routes.session_routes import _verify_session_owner from routes.document_helpers import _owner_session_filter from core.database import SessionLocal, get_session_mode, set_session_mode @@ -85,8 +80,6 @@ from src.tool_policy import ( web_search_enabled_for_turn, ) from src.tool_approvals import tool_approval_store -from src.tool_approval_scopes import stamp_chat_session_grant -from src.tool_security import delegated_credential_blocked_tools from src.workspace_paths import backend_workspace_path from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES from src.model_profiles import ( @@ -327,31 +320,13 @@ def _is_external_discovery_request(text: str) -> bool: )) -_URL_SCHEME_RE = re.compile(r"https?://", re.I) -_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I) - - -def _mentions_pdf_url(value: str) -> bool: - """Same as ``re.search(r"https?://[^\\s]+(?:\\.pdf\\b|/pdf/)", value, re.I)``. - - Checked per whitespace-free token from its first scheme only (a later - scheme's tail is a suffix of the first's), so a token packed with - `http://` repeats is scanned once instead of once per repeat (ReDoS). - """ - for token in value.split(): - scheme = _URL_SCHEME_RE.search(token) - if scheme and _PDF_URL_TAIL_RE.search(token, scheme.end() + 1): - return True - return False - - def _prefers_structured_document_tools(text: str) -> bool: """Identify external paper/PDF extraction where shell is a bad source route.""" value = str(text or "") if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I): return False return bool( - _mentions_pdf_url(value) + re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I) or re.search( r"\b(?:paper|report|study)\b[\s\S]{0,1200}?" r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b", @@ -1290,23 +1265,6 @@ def _stream_failure_status(chunk: str) -> Optional[int]: return None -def _reject_delegated_tool_approval(request: Request) -> None: - """Refuse an approval answered by a bearer API token. - - A tool approval records that a HUMAN authorized one dangerous action. A - token is a delegated credential handed to an integration, so when it - answers the prompt it triggered, nobody is asked and the gate collapses - into an extra round trip. Owner and session already match here: the token - is answering on behalf of the account that minted it. - """ - if is_delegated_credential(request): - raise HTTPException( - 403, - "Tool approvals require an interactive session. " - "API tokens cannot authorize a gated action.", - ) - - def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool: """Persist a consumed approval decision on its existing tool event.""" @@ -1331,11 +1289,6 @@ def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool: if str(ask_user.get("approval_id") or "") != approval_key: continue ask_user["resolved"] = normalized_decision - stamp_chat_session_grant( - ask_user, - getattr(sess, "id", ""), - normalized_decision, - ) message_id = metadata.get("_db_id") resolved_metadata = { key: value for key, value in metadata.items() if key != "_db_id" @@ -2178,17 +2131,13 @@ def setup_chat_routes( webhook_manager=None, skills_manager=None, ) -> APIRouter: - router = APIRouter( - tags=["chat"], - dependencies=[Depends(require_chat_api_token_scope)], - ) + router = APIRouter(tags=["chat"]) # ------------------------------------------------------------------ # # POST /api/chat (non-streaming) # ------------------------------------------------------------------ # @router.post("/api/chat", response_model=Dict[str, Any]) async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]: - require_api_token_scope(request, "chat") _set_user_time_from_request(request) message = chat_request.message @@ -2405,7 +2354,6 @@ def setup_chat_routes( # ------------------------------------------------------------------ # @router.post("/api/chat_stream") async def chat_stream(request: Request) -> StreamingResponse: - require_api_token_scope(request, "chat") body = None try: if request.headers.get("content-type", "").startswith("application/json"): @@ -2525,7 +2473,7 @@ def setup_chat_routes( _explicit_web_intent = ( _explicit_url_target or bool(re.search( - r"\b(search|look\s+(?:(?:this|that|it|them|these|those)\s*)?up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b", + r"\b(search|look\s+(?:this|that|it|them|these|those)?\s*up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b", _msg_l, )) or requires_external_web_verification(message) @@ -2624,6 +2572,21 @@ 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() + editor_state = form_data.get("editor_state") or "" + editor_screenshot = form_data.get("editor_screenshot") or "" + 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, @@ -2731,7 +2694,6 @@ def setup_chat_routes( ) owner = effective_user(request) if tool_approval_id: - _reject_delegated_tool_approval(request) from src.agent_runtime.authority import require_user_approval_request require_user_approval_request(request) pending_tool_approval = tool_approval_store.peek(tool_approval_id) @@ -3189,7 +3151,38 @@ 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: + # The editor tool family drives the browser-side image editor; it + # replaces the generic edit_image instruction. Without an open + # editor session the tools cannot reach the canvas, so tell the + # user to open the image instead of emitting failing tool calls. + 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: @@ -3226,6 +3219,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 @@ -3238,12 +3236,6 @@ def setup_chat_routes( if _use_turn_contract and _turn_capabilities and "search_browser" not in _turn_capabilities: _explicit_web_intent = False disabled_tools = set() - # Minting is admin-only, so every owner-keyed check below answers - # "admin" for a token. Cap it at the non-admin policy instead. - # stream_agent_loop repeats this from delegated_credential. - _delegated_credential = is_delegated_credential(request) - if _delegated_credential: - disabled_tools.update(delegated_credential_blocked_tools()) # Only disable bash when the caller *explicitly* set it to a falsy # value. When unset (None), defer to per-user privilege checks below. # Web search is per-turn opt-in: either the chat pre-search setting @@ -3475,9 +3467,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( + 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, @@ -3485,9 +3490,16 @@ 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 ( - _explicit_browser_intent or _external_discovery_intent - ) else ()), + spec_surface=_spec_surface, + capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if ( + _explicit_browser_intent + or _external_discovery_intent + # Grant search/browser tools whenever the turn actually offers + # them (Web toggle on / not denied), not only for explicit + # browser wording. Otherwise the model sees web_search in its + # schema but every call is rejected by request authority. + or "web_search" not in disabled_tools + ) else set())), ) if exact_tool_approval is not None: from src.agent_runtime.authority import RequestAuthority @@ -3513,8 +3525,6 @@ def setup_chat_routes( if s["function"]["name"] != "host_shell"] _contract_mgr = get_mcp_manager() _owner_blocked = blocked_tools_for_owner(_user) - if _delegated_credential: - _owner_blocked.update(delegated_credential_blocked_tools()) if ( _workspace_tools_disabled_for_owner(_user) and not _native_workspace_contract @@ -3677,7 +3687,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"}), @@ -3711,10 +3723,17 @@ def setup_chat_routes( ) # Every recovery path receives the same scope denial. The central # dispatcher also checks the immutable contract after rewrites. - disabled_tools.update( - s["function"]["name"] for s in _contract_schemas - if not _turn_contract.permits(s["function"]["name"]) - ) + # The strict router (spec) surface picks its own tools AFTER this + # contract is resolved, so scoping disabled_tools to the regex + # contract would empty its menu (e.g. all email/notes tools denied + # for a plain "search for the appointment email" turn). On spec + # turns keep only the pre-contract denials (user/privilege/global/ + # plan/compare) and let src/spec_router.py + the spec surface decide. + if not _spec_surface: + disabled_tools.update( + s["function"]["name"] for s in _contract_schemas + if not _turn_contract.permits(s["function"]["name"]) + ) # Contract resolution is the final policy-and-routing authority. # Some legacy/API-model paths arrive with a stale disabled snapshot # assembled before routing. The scope-denial pass above may retain @@ -3879,7 +3898,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 @@ -4027,9 +4046,10 @@ def setup_chat_routes( owner=_user, size="auto", progress_callback=_image_progress_callback, + incognito=incognito, )) else: - _img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user)) + _img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user, incognito=incognito)) _img_started = time.time() _img_tick = 0 while not _img_task.done(): @@ -4568,7 +4588,6 @@ def setup_chat_routes( uploaded_files=ctx.uploaded_files, defer_context_shaping=_foreground_policy.enabled, external_untrusted_context_seen=external_untrusted_context_seen, - delegated_credential=_delegated_credential, exact_approval=exact_tool_approval, client_runtime_context=client_runtime_context, thinking_mode=thinking_mode, @@ -4879,11 +4898,20 @@ def setup_chat_routes( async def _safe_stream() -> AsyncGenerator[str, None]: """Wrapper that guarantees _active_streams cleanup even if stream_with_save raises before reaching a mode-specific finally block.""" + _gen = stream_with_save() try: - async for chunk in stream_with_save(): + while not _shutdown.is_shutting_down(): + try: + chunk = await _gen.__anext__() + except StopAsyncIteration: + break yield chunk finally: _active_streams.pop(session, None) + try: + await _gen.aclose() + except Exception: + pass # Compare panes and explicitly unattended native clients are # short-lived, single-shot generations with nobody to resume them. @@ -4975,7 +5003,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 # ------------------------------------------------------------------ # @@ -5074,6 +5107,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/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/shell_routes.py b/routes/shell_routes.py index 8e2e969e0..677b74cf0 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -26,6 +26,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 shutdown as _shutdown from src import process_lifecycle # POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist @@ -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/src/agent_evidence.py b/src/agent_evidence.py index b3c7e2b22..d8adcbf9d 100644 --- a/src/agent_evidence.py +++ b/src/agent_evidence.py @@ -54,6 +54,7 @@ def workspace_artifact_is_usable(path: Path) -> bool: class EvidenceKind(str, Enum): TOOL_RESULT = "tool_result" ARTIFACT_MUTATION = "artifact_mutation" + DOCUMENT_MUTATION = "document_mutation" ARTIFACT_VALIDATION = "artifact_validation" VERIFIER_RESULT = "verifier_result" MEDIA_INGRESS = "media_ingress" @@ -131,6 +132,20 @@ class CompletionDecision: _ARTIFACT_PATH = r"(?:/|\./|\.\./)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12}" +# Document-editor tools mutate a server-side document rather than a filesystem +# path, so their successful results are recorded as a distinct evidence kind. +_DOCUMENT_MUTATION_TOOLS = frozenset({"create_document", "update_document", "edit_document"}) +# Owned private-action writes (calendar, tasks, notes, memory, contacts) mutate +# server-side state that carries no filesystem path, so a successful +# authoritative result is their mutation evidence. Without this, a verified +# calendar/task create is discarded as an "unsupported execution claim". +_PRIVATE_WRITE_ACTIONS: Mapping[str, frozenset[str]] = { + "manage_calendar": frozenset({"create_event", "update_event", "delete_event"}), + "manage_contact": frozenset({"add", "update", "edit", "delete"}), + "manage_memory": frozenset({"add", "edit", "delete"}), + "manage_notes": frozenset({"add", "update", "delete", "toggle_item"}), + "manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}), +} _ARTIFACT_REQUEST_RE = re.compile( rf"\b(?:writ(?:e|ten)|creat(?:e|ed)|make|made|sav(?:e|ed)|produc(?:e|ed)|" rf"generat(?:e|ed)|export(?:ed)?|edit(?:ed)?|modif(?:y|ied)|updat(?:e|ed)|" @@ -639,6 +654,9 @@ class EvidenceLedger: # order of actions so receipt evidence and effects share one ordering. self.effects: list[dict[str, Any]] = [] self._action_order: dict[str, int] = {} + # Successful authoritative owned-state writes (calendar/tasks/notes/ + # memory/contacts) that an unnamed mutation claim may cite. + self._private_action_mutations: list[str] = [] def record_effects(self, entries: Iterable[Mapping[str, Any]], action_order: Mapping[str, int], partial_reads: Iterable[str] = ()) -> None: @@ -898,6 +916,29 @@ class EvidenceLedger: detail="post-write artifact inspection", ) + if tool in _DOCUMENT_MUTATION_TOOLS: + # A successful, authoritative document write is evidence of a + # document mutation even though it has no filesystem path. + self._append( + kind=EvidenceKind.DOCUMENT_MUTATION, + success=success, + authoritative=authoritative, + source=event, + detail=tool, + ) + + if tool in _PRIVATE_WRITE_ACTIONS: + try: + write_args = json.loads(command or "{}") + except (TypeError, json.JSONDecodeError): + write_args = None + action = ( + str(write_args.get("action") or "").strip().lower() + if isinstance(write_args, Mapping) else "" + ) + if action in _PRIVATE_WRITE_ACTIONS[tool] and authoritative and success: + self._private_action_mutations.append(f"{tool}:{action}") + if tool in {"bash", "host_shell"} and (command_is_test(_command_text(command)) or _matches_declared_verifier( command, self.requirements.verifier_commands, @@ -969,6 +1010,22 @@ class EvidenceLedger: return False return True + def _supports_document_mutation(self) -> bool: + """A successful authoritative document write supports an unnamed + document-mutation claim, which carries no filesystem path to match.""" + return any(event.kind == EvidenceKind.DOCUMENT_MUTATION + and event.authoritative and event.success + for event in self.events) + + def _supports_mutation_claim(self, paths: Sequence[str]) -> bool: + """Match a mutation claim to a file edit, a document write, or a + successful owned-state action when the claim names no file path.""" + if self._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths): + return True + if paths: + return False + return self._supports_document_mutation() or bool(self._private_action_mutations) + def record_media_ingress(self, metadata: Mapping[str, Any]) -> None: for artifact in metadata.get("artifacts") or []: if not isinstance(artifact, Mapping): diff --git a/src/agent_runtime/authority.py b/src/agent_runtime/authority.py index cd5197300..ec5d79e0a 100644 --- a/src/agent_runtime/authority.py +++ b/src/agent_runtime/authority.py @@ -316,7 +316,12 @@ class RequestAuthority: _BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find", - "screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"}) + "screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info", + "batch"}) +# Interaction classes are admitted on a strict router (spec) surface whenever +# the server itself offered private_browser: the router decides the tool set, +# so requiring the verb in the text would break "browse X then click Y". +_BROWSER_INTERACTION_ACTIONS = frozenset({"click", "fill", "type", "press", "evaluate", "select"}) @dataclass(frozen=True) @@ -363,7 +368,8 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None, history=(), policy=None, active_document=False, - image_attachment=False, capabilities=None, client_runtime_context=None): + image_attachment=False, capabilities=None, client_runtime_context=None, + spec_surface=False): """Deterministic server policy over semantic facts, never schema inventory.""" if not isinstance(request_text, str): raise TypeError("Authority requires trusted request text") @@ -372,10 +378,25 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works families = intent.capabilities if capabilities is not None: families |= frozenset(capabilities) + if spec_surface: + # Strict router (spec) surface: agent_loop builds the final tool set in + # src/spec_router.py AFTER this runs, so turn_contract.offered is still + # the compact inventory here. The router already limits schemas to its + # picks + sticky prior tools + ask_user/update_plan, and + # _resolve_tool_blocks drops calls to unoffered tools, so admit every + # family and let the spec surface be the boundary. Selected-tool + # narrowing is skipped for the same reason. + families |= frozenset(FAMILY_TOOLS) tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families)) - selected = intent.selected_tools + selected = None if spec_surface else intent.selected_tools if selected is not None: tools = tools & set(selected) if families else set(selected) + if "web_search" in tools: + # Search selection narrows to the query tool, but reading a source + # it surfaces is the same bounded read operation. Without the + # sibling reader every discovered result is a dead end and the + # turn fails closed on the first web_fetch of a found URL. + tools.add("web_fetch") if tools & {"web_search", "web_fetch"}: tools.add("private_browser") operation = intent.required_read @@ -389,13 +410,28 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works actions = inputs = None if name == "private_browser": actions = _BROWSER_READ_ACTIONS - # Explicit interaction intent admits its operation class. A - # browser offered only as static-Web fallback gets no such grant. - if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I): + if spec_surface: + # The strict router itself offered a working browser: admit its + # interaction classes too, not only when the verb is in the text. + actions |= _BROWSER_INTERACTION_ACTIONS + elif re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I): + # Explicit interaction intent admits its operation class. A + # browser offered only as static-Web fallback gets no such grant. actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select") if re.search(r"\b" + action + r"\b", request_text, re.I)) if operation is not None and name == canonical_tool(operation.tool): - inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input}) + required_args = dict(operation.args) + if set(required_args) <= {"action"}: + # An action-only read ("list my tasks/memories") pins the action + # class, not the exact JSON: a model that adds benign arguments + # (limit/offset/filter) still performs the same admitted read. + # Reads that name an identity keep the exact-input seal below. + required_action = str(required_args.get("action") or "").strip().casefold() or None + if required_action: + actions = (frozenset({required_action}) if actions is None + else actions & frozenset({required_action})) + else: + inputs = frozenset({ExactOperation.normalize(name, _json(required_args)).input}) grants.append(OperationGrant(name, actions, inputs)) authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""), tuple(grants)) diff --git a/src/agent_runtime/completion.py b/src/agent_runtime/completion.py index f667b6937..51485513a 100644 --- a/src/agent_runtime/completion.py +++ b/src/agent_runtime/completion.py @@ -1,7 +1,8 @@ """One presentation gate between agent execution and externally visible prose. -Tool, progress and interaction events stay live. Answer deltas are held until -the generator unwinds so a later replacement cannot conceal an earlier false +Tool, progress and interaction events stay live. Evidence-safe complete +sentences from ordinary answers may stream before the terminal check; execution +answers remain gated so a later replacement cannot conceal an earlier false claim. This consumes no provider calls. Cancellation closes the inner generator under the same journal/turn authority; it never emits a successful terminal event. """ @@ -123,7 +124,7 @@ def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDeci or not ledger._supports_verifier_claim(identities, paths)): why = 'no current passing executable verification supports the claim' elif claim == 'mutation': - if not ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths): + if not ledger._supports_mutation_claim(paths): why = 'no matching artifact mutation supports the execution claim' elif claim == 'execution': # A generic assertion cannot be tied confidently to a receipt. @@ -167,6 +168,16 @@ def _disclosure(answer: str, ledger: EvidenceLedger) -> str: return ('\n\n' + summary) if answer.strip() else summary +def _complete_prose_prefix(text: str) -> str: + """Return complete statement segments, keeping the current sentence pending.""" + statements = list(_unquoted_statements(text)) + if not statements: + return '' + if statements[-1][0].strip(): + statements.pop() + return ''.join(statement for statement, _ in statements) + + def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]: incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else '' execution_required = _execution_obligation(ledger.requirements) @@ -232,6 +243,10 @@ def with_completion_gate(func): journal.effects = (parent.effects if parent is not None and parent.effects is not None else EffectLog(journal.run_id)) answer_events: list[dict] = [] + streamed_answer_events: set[int] = set() + streamed_safe_text = '' + stream_conflict = False + first_visible_answer_at = None metrics_events: list[dict] = [] answer = '' has_final = False @@ -297,18 +312,36 @@ def with_completion_gate(func): first_answer_at = perf_counter() # Boolean thinking=True marks a reasoning-only delta; # a textual thinking companion must not hide an answer - # delta. Both shapes remain buffered until the gate. + # delta. Thinking remains buffered until terminal review. if isinstance(data.get('thinking'), str): - answer_events.append({'delta': data['thinking'], 'thinking': True}) + thinking_event = {'delta': data['thinking'], 'thinking': True} + answer_events.append(thinking_event) data = {key: value for key, value in data.items() if key != 'thinking'} if 'delta' not in data: continue if data.get('thinking') is not True and 'delta' in data: if has_final: + stream_conflict |= bool(streamed_safe_text) + streamed_safe_text = '' answer = '' has_final = False answer += str(data.get('delta') or '') answer_events.append(data) + if data.get('thinking') is not True and 'delta' in data \ + and not _execution_obligation(requirements): + prefix = _complete_prose_prefix(answer) + if prefix: + ledger_now = _ledger(journal, requirements) + decision_now = ledger_now.evaluate(exhausted=exhausted, awaiting_user=awaiting) + safe_prefix, _ = _supported_prose(prefix, ledger_now, decision_now) + if safe_prefix.startswith(streamed_safe_text): + delta = safe_prefix[len(streamed_safe_text):] + if delta: + first_visible_answer_at = first_visible_answer_at or perf_counter() + yield _event({'delta': delta}) + streamed_safe_text = safe_prefix + elif streamed_safe_text: + stream_conflict = True continue yield chunk if provider_error and not answer_events and not metrics_events: @@ -361,7 +394,10 @@ def with_completion_gate(func): disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft or filtered_answer != answer) replaced_answer = not disclosure_only and bool( - presentation_replaced or reason or unsafe_draft or safe_answer != answer) + presentation_replaced or reason or unsafe_draft or safe_answer != answer or stream_conflict + or (provider_error and streamed_safe_text)) + if streamed_safe_text and not answer.startswith(streamed_safe_text): + replaced_answer = True if replaced_answer: reasoning = [event for event in answer_events if event.get('thinking') is True] _, unsafe_reasoning = completion_answer( @@ -369,11 +405,31 @@ def with_completion_gate(func): replace(presentation_decision, can_complete=True)) if not unsafe_reasoning: for event in reasoning: - yield _event(event) - yield _event({'type': 'final_response', 'content': safe_answer}) + if id(event) not in streamed_answer_events: + yield _event(event) + yield _event({ + 'type': 'final_response', + 'content': safe_answer, + 'render_owner': 'streamed', + 'replacement_scope': 'turn', + }) else: for event in answer_events: - yield _event(event) + if event.get('thinking') is True: + if id(event) not in streamed_answer_events: + streamed_answer_events.add(id(event)) + first_visible_answer_at = first_visible_answer_at or perf_counter() + yield _event(event) + elif event.get('type') == 'final_response': + if streamed_safe_text: + event = {**event, 'replacement_scope': 'turn'} + first_visible_answer_at = first_visible_answer_at or perf_counter() + yield _event(event) + if not has_final: + remainder = answer[len(streamed_safe_text):] if answer.startswith(streamed_safe_text) else answer + if remainder: + first_visible_answer_at = first_visible_answer_at or perf_counter() + yield _event({'delta': remainder}) if disclosure_only: yield _event({'delta': disclosure}) if provider_error: @@ -386,8 +442,11 @@ def with_completion_gate(func): if ledger.effects: metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects] metadata['completion_gate'] = { - 'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0, - 'first_visible_answer_seconds': released_at - started, + 'buffer_seconds': max(0, (first_visible_answer_at or released_at) - first_answer_at) + if first_answer_at is not None else 0, + 'evaluation_seconds': released_at - first_answer_at if first_answer_at is not None else 0, + 'first_visible_answer_seconds': first_visible_answer_at - started + if first_visible_answer_at is not None else 0, 'additional_provider_calls': 0, 'answer_replaced': replaced_answer, } diff --git a/src/agent_tools/__init__.py b/src/agent_tools/__init__.py index 7db8cffbb..7689f6ead 100644 --- a/src/agent_tools/__init__.py +++ b/src/agent_tools/__init__.py @@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool from .bg_job_tools import ManageBgJobsTool from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool +from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool from .admin_tools import ( ADMIN_TOOL_HANDLERS, do_manage_endpoints, do_manage_mcp, do_manage_webhooks, @@ -72,6 +73,10 @@ TOOL_HANDLERS = { "list_sessions": ListSessionsTool().execute, "send_to_session": SendToSessionTool().execute, "manage_session": ManageSessionTool().execute, + "plan_edit": PlanEditTool().execute, + "run_editor_command": RunEditorCommandTool().execute, + "get_editor_screenshot": GetEditorScreenshotTool().execute, + "ai_edit_image": AiEditImageTool().execute, } # Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings). TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS) diff --git a/src/agent_tools/document_tools.py b/src/agent_tools/document_tools.py index c83cec2b0..2e1d7f76e 100644 --- a/src/agent_tools/document_tools.py +++ b/src/agent_tools/document_tools.py @@ -496,6 +496,7 @@ def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional "content": clean, "version": 1, "source_doc_id": getattr(source_doc, "id", None), + "exit_code": 0, } @@ -618,6 +619,7 @@ class CreateDocumentTool: "language": language, "content": content, "version": 1, + "exit_code": 0, } except Exception as e: db.rollback() @@ -708,6 +710,7 @@ class UpdateDocumentTool: "language": doc.language, "content": new_content, "version": new_ver, + "exit_code": 0, } except Exception as e: db.rollback() @@ -863,6 +866,7 @@ class EditDocumentTool: "version": new_ver, "applied": applied, "skipped": skipped, + "exit_code": 0, } return {"error": "No edits applied — FIND text cannot be blank"} @@ -968,6 +972,7 @@ class EditDocumentTool: "version": new_ver, "applied": applied, "skipped": skipped, + "exit_code": 0, } if partial_edits: result.update({ @@ -1059,6 +1064,7 @@ class SuggestDocumentTool: "partial": bool(invalid), "rejected": len(invalid), "invalid_suggestions": invalid, + "exit_code": 0, } finally: db.close() diff --git a/src/agent_tools/web_tools.py b/src/agent_tools/web_tools.py index 627ed6c68..007717bb5 100644 --- a/src/agent_tools/web_tools.py +++ b/src/agent_tools/web_tools.py @@ -14,6 +14,7 @@ import tempfile import time import html import hashlib +import pwd import urllib.parse import urllib.request import uuid @@ -31,7 +32,10 @@ _ACTIVE_BROWSER_SESSIONS: set[str] = set() def _service_home() -> Path: """Return the account home even when a task overrides ``HOME``.""" - return platform_compat.service_home() + try: + return Path(pwd.getpwuid(os.getuid()).pw_dir) + except (KeyError, OSError): + return Path.home() def _host_npm_roots() -> list[Path]: diff --git a/src/ai_interaction.py b/src/ai_interaction.py index 9890ed549..1108407f4 100644 --- a/src/ai_interaction.py +++ b/src/ai_interaction.py @@ -464,8 +464,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner return {"error": "Edit needs line 2: memory_id, line 3: new text"} memory_id = lines[1].strip() new_text = lines[2].strip() - if not memory_id: - return {"error": "Edit needs line 2: memory_id"} if not new_text: return {"error": "New text cannot be empty"} @@ -499,8 +497,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner if len(lines) < 2: return {"error": "Delete needs line 2: memory_id"} memory_id = lines[1].strip() - if not memory_id: - return {"error": "Delete needs line 2: memory_id"} memories = _memory_manager.load_all() original_len = len(memories) @@ -1155,7 +1151,50 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O # Image generation # --------------------------------------------------------------------------- -async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict: +# Message used whenever a generative image edit (object add/remove/replace, +# image generation) is requested but no image provider is configured. The +# photo-editor agent and the image tools surface this verbatim so the user gets +# a plain, actionable explanation instead of a silent substitute edit. +IMAGE_PROVIDER_REQUIRED_MESSAGE = ( + "Removing/adding objects needs an image generation provider; enable one in " + "Settings > Models." +) + + +def image_editing_provider_available(owner: Optional[str] = None) -> bool: + """True when an enabled, owner-visible image endpoint can serve AI edits. + + Generative operations (object add/remove/replace, image generation) require + a configured image model. Local transforms (remove-bg, upscale, denoise, + enhance-face) do not, but the photo-editor agent gates the whole generative + request on this so it never falls back to deterministic approximations. + """ + try: + from src.settings import load_settings + + if not load_settings().get("image_gen_enabled", True): + return False + except Exception: + pass + try: + from src.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter + + db = SessionLocal() + try: + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, # noqa: E712 + ModelEndpoint.model_type == "image", + ) + q = owner_filter(q, ModelEndpoint, owner) + return q.first() is not None + finally: + db.close() + except Exception: + return False + + +async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict: """Generate an image using an image-capable model (e.g. gpt-image-1). Content format: @@ -1314,7 +1353,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne else: payload["quality"] = "medium" - logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}") + logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}") try: # GPT image models can take 30-120s+ depending on quality @@ -1365,15 +1404,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne logger.warning(f"Failed to save gallery record: {_ge}") return "" - # GPT image models always return b64_json; DALL-E may return url - if img.get("b64_json"): + def _store_image(image_bytes): + if incognito: + return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None img_dir = Path(GENERATED_IMAGES_DIR) img_dir.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex[:12]}.png" img_path = img_dir / filename - img_path.write_bytes(base64.b64decode(img.get("b64_json"))) - image_url = f"/api/generated-image/{filename}" - image_id = _save_to_gallery(filename) + img_path.write_bytes(image_bytes) + return f"/api/generated-image/{filename}", _save_to_gallery(filename) + + # Private results stay in the response, never in the gallery or on disk. + if img.get("b64_json"): + image_url, image_id = _store_image(base64.b64decode(img['b64_json'])) elif img.get("url"): # Download external URL and save locally (DALL-E returns temp URLs) @@ -1387,13 +1430,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne try: dl_resp = httpx.get(result_url, timeout=60) if dl_resp.status_code == 200: - img_dir = Path(GENERATED_IMAGES_DIR) - img_dir.mkdir(parents=True, exist_ok=True) - filename = f"{uuid.uuid4().hex[:12]}.png" - img_path = img_dir / filename - img_path.write_bytes(dl_resp.content) - image_url = f"/api/generated-image/{filename}" - image_id = _save_to_gallery(filename) + image_url, image_id = _store_image(dl_resp.content) else: image_url = result_url # fallback to external URL except Exception as _dl_e: @@ -1430,6 +1467,7 @@ async def do_edit_image( size: str = "auto", quality: str = "medium", progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, + incognito: bool = False, ) -> Dict: """Edit an uploaded image using the configured image endpoint.""" import base64 @@ -1503,7 +1541,7 @@ async def do_edit_image( request_id = uuid.uuid4().hex payload["request_id"] = request_id - logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80]) + logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80]) def _save_edited_image_to_gallery(filename: str) -> str: try: @@ -1533,6 +1571,9 @@ async def do_edit_image( from PIL import Image with Image.open(BytesIO(image_bytes)) as output: size = f"{output.width}x{output.height}" + image_mime = Image.MIME.get(output.format, 'image/png') + if incognito: + return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), '' img_dir = Path(GENERATED_IMAGES_DIR) img_dir.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex[:12]}{suffix}" diff --git a/src/browser_observation.py b/src/browser_observation.py index 9eb2c547a..09d5e5d32 100644 --- a/src/browser_observation.py +++ b/src/browser_observation.py @@ -17,10 +17,13 @@ def compact_browser_observation(value, budget=8000): notices.append('Exit code: ' + str(item['exit_code'])) if item.get('success') is False: notices.append('Browser command failed.') - snapshot = item.get('snapshot') or item.get('text') - if item.get('title'): - notices.append('Title: ' + str(item['title'])) + snapshot = item.get('snapshot') + if not isinstance(snapshot, str) or not snapshot.strip(): + snapshot = item.get('text') url = item.get('url') or item.get('origin') + title = item.get('title') + if isinstance(title, str) and title.strip(): + notices.append('Title: ' + title.strip()) if isinstance(snapshot, str) and snapshot.strip(): # Snapshot text already contains labels and refs in DOM order. # The refs mapping repeats them and buries menus in raw JSON. diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 28ee1a5bf..29712a12b 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -32,12 +32,7 @@ from src.tool_schemas import ( normalized_native_function_argument_error, ) from src.tool_types import ToolBlock -from src.tool_parsing import iter_email_addresses, parse_tool_blocks, strip_tool_blocks, strip_angle_tags -from src.text_scanning import ( - contains_detailed_sequence_request, - contains_search_engine_navigation, - iter_prefixed_token_matches, -) +from src.tool_parsing import parse_tool_blocks, strip_tool_blocks from src.turn_contract import ( _REQUEST_PREFIX, calendar_retiming_request, @@ -49,6 +44,7 @@ from src.prompt_security import untrusted_context_message from src.model_profiles import ( model_id_leaf, is_odysseus_merged_tools_model, + supports_user_thinking_toggle, uses_odysseus_progressive_thinking, ) @@ -59,94 +55,6 @@ MODE = COMPACT_PREVIEW_MODE class ProviderStreamError(Exception): """A provider reported failure inside an otherwise successful SSE response.""" - -# Only these audited, fixed domain messages may cross the exception boundary. -# Return the canonical constants rather than arbitrary exception text. -_PUBLIC_PREVIEW_TOOL_ERRORS = {message: message for message in ( - 'An equivalent search already returned evidence. Change the angle, missing subtopic, source type, or corroboration target instead of only changing freshness wording.', - 'Browser navigation already failed for this exact URL; use another source page.', - 'Do not infer image content repeatedly from filenames; use inspect_media on representative files, then continue from visual evidence.', - 'Equivalent search intent was repeated after a correction reminder; web_search is disabled for this turn.', - 'Look up the target with list_sessions before changing a chat. Use its exact returned ID; never invent last-chat/latest aliases. If the target is ambiguous, ask using the candidate chat titles.', - 'Resolve the named recipient with resolve_contact before drafting. Never invent an email address.', - 'Selection-only edits cannot use replace_all. Use a unique contextual FIND inside the selected passage.', - 'The bounded search-attempt budget is exhausted. Do not search again; answer from usable evidence already gathered, or clearly report what could not be verified and suggest a concrete next step.', - 'The calendar read has not succeeded yet. Obtain the requested calendar evidence before creating the dependent email draft.', - 'The latest note search returned no candidates, so this reference has no note to open. Do not reuse an older list item; report the empty result or ask which note was intended.', - 'The latest research search returned no candidates, so this reference has no report to open. Do not reuse an unrelated older report.', - 'The proposed expansion only adds placeholder/meta text. Write substantive content that continues the existing document’s subject, voice, and format; do not announce that a paragraph was added.', - 'The recipient address is not supported by the contact lookup. Use an exact returned address for the requested person; if no match exists, explain the missing recipient instead of guessing.', - 'The same artifact target was already rewritten three times; finish from the latest successful version instead of rewriting it again.', - 'These suggestions collapse multiple different passages into the same much shorter replacement, violating the request to preserve meaning. Produce passage-specific revisions that retain each source passage’s claims and intent.', - 'This exact call already failed twice and will not be executed again; change strategy or finish from existing evidence.', - 'This exact failed call was repeated after a correction reminder; the tool is disabled for this turn. Finish from existing evidence.', - 'This exact invalid call was repeated after two validation failures; the tool is disabled for this turn.', - 'This exact successful call already returned evidence. Do not repeat it; change the arguments or tool to gather different evidence, or finish from the evidence already available.', - 'This still image was already inspected and the same visual evidence is already in context. inspect_media is withheld for the next correction round; use that evidence, inspect a different file, or finish.', - 'This operation is outside the preview safety policy. No change was made.', - 'This replacement does not make its passage more concise. Shorten the wording while retaining its facts and meaning; a spelling-only change does not satisfy the requested action. Retry with shorter replacements.', - 'Tool arguments could not be converted for execution.', - 'Tool arguments must be a JSON object.', - 'Tool execution budget exhausted; finish from existing evidence.', - 'Tool is not offered or permitted.', - 'Two equivalent searches already returned no evidence. Do not repeat this search wording; use a different offered tool or a materially different query.', - 'Unresolved video target: this ID was not supplied by the user or observed in a successful tool result. Do not guess it from the title. Open/read the referenced browser link or call youtube_tool latest_channel_video for the observed channel, then use its returned ID.', - 'inspect_media exports only extract video stills and require an explicit timestamp plus output_path for every item. First inspect the video to find the timestamp; use write_file or python to author a diagram or other new artifact.', - 'web_fetch requires url or urls. query only filters a supplied page; it is not a search or writing request.', -)} - - -def _schema_argument_hint(args, schema): - """Describe a schema mismatch from the schema and the call's own arguments. - - Rebuilt from a fresh validation rather than read off the caught exception, - so nothing the exception carries can reach the model. - """ - if not isinstance(schema, dict): - return '' - try: - validator = jsonschema.validators.validator_for(schema)(schema) - error = jsonschema.exceptions.best_match(validator.iter_errors(args)) - except jsonschema.exceptions.SchemaError: - return '' - if error is None: - return '' - location = '.'.join(str(part) for part in error.absolute_path)[:80] - field = f"'{location}' " if location else '' - if error.validator == 'required' and isinstance(error.instance, dict): - missing = [str(name) for name in error.validator_value if name not in error.instance] - prefix = f'{location}.' if location else '' - hint = 'Missing required argument: ' + ', '.join(f"'{prefix}{name}'" for name in missing) + '.' - elif error.validator == 'type': - expected = error.validator_value - expected = ' or '.join(expected) if isinstance(expected, list) else str(expected) - hint = f'Argument {field}must be of type {expected}.' - elif error.validator == 'enum': - allowed = ', '.join(str(value) for value in error.validator_value[:12]) - hint = f'Argument {field}must be one of: {allowed}.' - elif error.validator == 'additionalProperties': - hint = 'Remove arguments the offered tool schema does not define.' - else: - return '' - return f'{hint} Correct the call using the offered tool schema.' - - -def _public_preview_tool_error(exc, *, execution_attempted=False): - """Keep useful domain guidance while withholding arbitrary diagnostics.""" - if execution_attempted: - return 'The tool failed unexpectedly. Check the server log and retry.' - if isinstance(exc, json.JSONDecodeError): - return 'Tool arguments are not valid JSON. Correct the JSON object and retry.' - if isinstance(exc, jsonschema.ValidationError): - return 'Tool arguments do not match the required schema. Correct the call using the offered tool schema.' - detail = str(exc) - if detail.startswith('Artifact completion Python must reference the required '): - return 'Artifact completion Python must create non-empty output at the required artifact target.' - if detail.startswith('Shell access to credential variable '): - return 'Shell access to credentials is blocked. Use brokered native tools.' - return _PUBLIC_PREVIEW_TOOL_ERRORS.get(detail, 'The tool call could not be validated. Check its arguments and retry.') - - # Native unattended workspaces routinely require several inspections followed # by several artifact writes. The interactive preview keeps its six-call # limit below; this larger budget applies only after server-side validation of @@ -172,6 +80,13 @@ ARTIFACT_RESEARCH_TOOLS = frozenset({ 'web_search', 'web_fetch', 'private_browser', 'pdf_extract', 'youtube_tool', 'inspect_media', 'extract_text', 'transcribe_media', }) +DETAILED_VIDEO_REQUEST = re.compile( + r"\b(?:how\s+many|count|sequence|in\s+order|chronological|" + r"timestamps?|what\s+time|at\s+what\s+time|when\s+.*(?:end|happen)|" + r"first\s+.*(?:save|attempt|event)|score(?:board)?s?)\b|" + r"(?:多少|几次|何时|什么时候|时间|顺序)", + re.IGNORECASE, +) READ_TOOLS = frozenset({ 'manage_notes', 'manage_calendar', 'manage_memory', 'manage_skills', 'manage_tasks', 'manage_documents', 'manage_research', 'manage_contact', 'list_sessions', @@ -196,6 +111,13 @@ SAFE_WRITE_TOOLS = frozenset({ EXPLICIT_EXECUTE_TOOLS = frozenset({'bash', 'python'}) SAFE_UI_TOOLS = frozenset({'ui_control'}) BROKERED_JOB_TOOLS = frozenset({'trigger_research'}) +# Scoped NL photo-editor surface. These are offered only when the +# ``photo_editor`` capability family is active (editor Quick Edit popup or an +# image-edit chat turn); they drive the in-process, owner-scoped editor bridge +# and the existing AI image ops. +EDITOR_AGENT_TOOLS = frozenset({ + 'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image', +}) CONTRACT_REQUIRED_TOOLS = frozenset({ 'send_email', 'reply_to_email', 'create_session', 'send_to_session', 'manage_session', @@ -209,6 +131,7 @@ from src.turn_contract import CONTRACT_CORE_TOOLS PREVIEW_TOOLS = ( READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS | BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS + | EDITOR_AGENT_TOOLS ) # Keep a small recovery-capable surface on every interactive compact agent # turn. Routing still adds domain tools, while policy and action guards remain @@ -298,23 +221,19 @@ def provider_compatible_tool_choice_request(request, model): # Ajax's forced decoder emits incomplete optional payloads # (and named choice can emit scalar/repeated-number arguments). # Keep the selected schema; validate completion in the harness. + # (Ajax's auto decoding is designed around a single-tool + # boundary, so this narrowing is intentional here. The + # tool-hiding bug was in the deepseek/kimi branch below, which + # must keep the full offered set.) return {**request, 'tools': selected, 'tool_choice': 'auto'} if choice == 'required': return {**request, 'tool_choice': 'auto'} if model_name.startswith(('deepseek', 'kimi')) and 'tool_choice' in request: compatible = dict(request) - choice = compatible.get('tool_choice') - selected_name = ( - (choice.get('function') or {}).get('name') - if isinstance(choice, dict) else None - ) - if selected_name: - selected = [ - schema for schema in compatible.get('tools') or [] - if (schema.get('function') or {}).get('name') == selected_name - ] - if selected: - compatible['tools'] = selected + # DeepSeek/Kimi reject a forced named choice. Drop the unsupported + # choice but KEEP the full offered tool set: narrowing `tools` to the + # named schema would hide every other tool the turn offered (e.g. a + # required calendar read hiding the email tools the user asked for). compatible.pop('tool_choice', None) return compatible if ( @@ -895,95 +814,13 @@ def malformed_write_handoff_target(arguments, required_artifacts=(), user_text=' return target -_PAGE_LISTING_WORDS = ("stories", "articles", "posts", "headlines", "pages") - - -def _listing_allowed(char): - return char == " " or char in ".:/-" or char == "_" or char.isalnum() - - -def _allowed_then_space(value): - """Whether value can be class+ followed by whitespace+, without retries.""" - if len(value) < 2: - return False - first_disallowed = next((i for i, char in enumerate(value) if not _listing_allowed(char)), len(value)) - if first_disallowed < len(value): - return first_disallowed > 0 and all(char.isspace() for char in value[first_disallowed:]) - return value[-1].isspace() - - -def _space_then_allowed(value): - """Whether value can be whitespace+ followed by the listing class+.""" - if len(value) < 2: - return False - first_nonspace = next((i for i, char in enumerate(value) if not char.isspace()), len(value)) - if first_nonspace < len(value): - return first_nonspace > 0 and all(_listing_allowed(char) for char in value[first_nonspace:]) - trailing_spaces = len(value) - len(value.rstrip(" ")) - return trailing_spaces > 0 and (len(value) > trailing_spaces or trailing_spaces >= 2) - - -def _page_listing_request(user_text): - value = str(user_text or '').lstrip() - nonspace_end = len(value.rstrip()) - if value[nonspace_end - 1:nonspace_end] in ("!", "?"): - value = value[:nonspace_end - 1] - lead = re.match( - r"(?:top|latest|recent|list(?: the)?|show(?: me)?(?: the)?)(?=\s)", - value, re.I, - ) - if not lead: - return False - cursor = lead.end() - while cursor < len(value) and value[cursor].isspace(): - cursor += 1 - body = value[cursor:] - nonspace_end = len(body.rstrip()) - terminal_end = nonspace_end - if body[terminal_end - 1:terminal_end] == ".": - terminal_end = len(body[:terminal_end - 1].rstrip()) - first_disallowed = len(body) - last_disallowed = -1 - first_nonspace_after_disallowed = len(body) - for index, char in enumerate(body): - if not _listing_allowed(char): - first_disallowed = min(first_disallowed, index) - if index < nonspace_end: - last_disallowed = index - if index >= first_disallowed and not char.isspace(): - first_nonspace_after_disallowed = min(first_nonspace_after_disallowed, index) - last_literal_space = body.rfind(" ") - for word in _PAGE_LISTING_WORDS: - for candidate in re.finditer(word, body, re.I): - start = candidate.start() - prefix_allowed = start >= 2 and ( - body[start - 1].isspace() if start <= first_disallowed - else first_disallowed > 0 and start <= first_nonspace_after_disallowed - ) - if start == 0 or prefix_allowed: - after_start = candidate.end() - if after_start >= terminal_end: - return True - on = after_start - while on < len(body) and body[on].isspace(): - on += 1 - if on > after_start and body[on:on + 2].casefold() == "on": - tail_start = on + 2 - tail_end = tail_start - while tail_end < len(body) and body[tail_end].isspace(): - tail_end += 1 - if len(body) - tail_start >= 2 and tail_end > tail_start: - if tail_end < len(body): - if last_disallowed < tail_end: - return True - elif last_literal_space > tail_start: - return True - return False - - def page_listing_response(entries, user_text, max_items=10): """Render simple page listings from observed titles/URLs, never synthesized rankings.""" - if not _page_listing_request(user_text) or re.search(r'\b(?:and|compare|summarize|analyse|analyze|about|by|since|yesterday)\b', user_text, re.I): + if not re.fullmatch( + r'\s*(?:top|latest|recent|list(?: the)?|show(?: me)?(?: the)?)\s+' + r'(?:[\w .:/-]+\s+)?(?:stories|articles|posts|headlines|pages)' + r'(?:\s+on\s+[\w .:/-]+)?[.!?]?\s*', user_text, re.I, + ) or re.search(r'\b(?:and|compare|summarize|analyse|analyze|about|by|since|yesterday)\b', user_text, re.I): return '' from urllib.parse import quote, urlsplit from html import escape @@ -1399,12 +1236,55 @@ def incomplete_broad_web_answer(content, user_text, *, recovery_attempts=0): return len(words) < 80 or not re.search(r'https?://\S+', str(content or '')) -def progressive_thinking_for_turn(model, offered_schemas, thinking_mode=None): - """Use Qwen reasoning only when this turn has no Odysseus tool surface.""" - - if str(thinking_mode or '').lower() == 'off': +def progressive_thinking_for_turn(model, offered_schemas, thinking_mode=None, *, tool_failed=False, deliberate=False): + """Ajax Compact owns reasoning; legacy session preferences do not control it.""" + if not uses_odysseus_progressive_thinking(model): return False - return uses_odysseus_progressive_thinking(model) and not bool(offered_schemas) + return tool_failed or deliberate or not bool(offered_schemas) + + +def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_user_text): + """Env-gated router decision for spec tool-surface turns. Fail open (None).""" + try: + from src import spec_router + if not spec_router.think_router_enabled(): + return None + from src.agent_loop import _configured_model_tool_surface_raw + if _configured_model_tool_surface_raw( + endpoint_url, model, owner, headers=headers + ) != 'spec': + return None + return spec_router.route_thinking(direct_user_text) + except Exception: + logging.getLogger(__name__).warning( + 'spec think router decision failed; using rules only', exc_info=True) + return None + + +def spec_router_think_reason(round_number, decision, existing_deliberate): + """Add 'router_think' on round 1 when the router asked for thinking and no + other rule already selected a deliberate reason.""" + if (round_number == 1 and not existing_deliberate + and isinstance(decision, dict) and decision.get('think')): + return 'router_think' + return '' + + +def tool_deliberation_reason(name, args): + """Classify selected operations, never infer intent from tool availability.""" + name = canonical(name) + if name in {'python', 'edit_file', 'apply_patch'}: + return 'code' + if name in {'edit_document', 'suggest_document', 'update_document'}: + return 'document_edit' + if name in {'draft_email', 'draft_email_reply', 'ai_draft_email_reply'}: + return 'email_draft' + action = str(args.get('action') or '').lower() + if name == 'manage_tasks' and action in {'create', 'add', 'update', 'edit'}: + return 'task_design' + if name == 'manage_skills' and action in {'add', 'edit', 'patch', 'publish', 'audit'}: + return 'skill_evaluation' + return '' def visible_content_after_qwen_thinking(content): @@ -1422,6 +1302,48 @@ def visible_content_after_qwen_thinking(content): return text.strip() +def qwen_think_split_events(content, state): + """Split a no-reasoning-parser thinking round into live deltas. + + Some thinking servers (no --reasoning-parser) return the whole + ``…`` trace as ordinary ``content``. Buffering that round + made the UI show nothing while the model thought and then dump the whole + answer at once. Stream the trace as ``{'delta': ..., 'thinking': True}`` + events (the same shape a reasoning-parser server emits) and the text after + ```` 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: + if '' not in lowered: + return events + # Hold back a possible partial '' so it is never shown as + # thinking text. + 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 '') @@ -1699,24 +1621,17 @@ def prior_workspace_path_answer(user_text, history): return '' -def _prior_web_source_request(text): - text = str(text or '') - candidate = text.lstrip().rstrip() - candidate = candidate.rstrip(".!? ") - return bool(re.fullmatch( - r"(?:(?:where|what)\s+did\s+you\s+(?:get|find)\s+(?:that|this)\s+from[?., ]*" - r"(?:give|show|send)\s+me\s+(?:the\s+)?(?:source\s+)?link" - r"|(?:give|show|send)\s+me\s+(?:the\s+)?(?:source\s+)?link(?:\s+for\s+that)?" - r"|what(?:['’]?s|\s+is)\s+(?:the\s+)?source(?:\s+link)?)", - candidate, - re.I, - )) - - def prior_web_source_answer(user_text, history): """Return the latest source URL for an explicit source-only follow-up.""" text = str(user_text or '') - if not _prior_web_source_request(text): + if not re.fullmatch( + r"\s*(?:(?:where|what)\s+did\s+you\s+(?:get|find)\s+(?:that|this)\s+from[?., ]*" + r"(?:give|show|send)\s+me\s+(?:the\s+)?(?:source\s+)?link[.!? ]*" + r"|(?:give|show|send)\s+me\s+(?:the\s+)?(?:source\s+)?link(?:\s+for\s+that)?[.!? ]*" + r"|what(?:['’]?s|\s+is)\s+(?:the\s+)?source(?:\s+link)?[.!? ]*)\s*", + text, + re.I, + ): return '' call_names = {} candidates = [] @@ -2358,7 +2273,7 @@ def canonical_tools_for_mode(tools, mode): def contract_builder(): root = Path(os.environ.get( 'ODYSSEUS_TOOL_CONTRACT_ROOT', - str(Path(__file__).resolve().parents[1] / "scripts"), + '/home/pewds/odysseus-tool-work/scripts', )).resolve() contract_path = root / 'eval_alltools_unseen_compare.py' if contract_path.is_file(): @@ -2381,9 +2296,6 @@ def compact_schemas(schemas, *, model=None): # The evaluator's short email names and live MCP aliases share the same # contract; keep live dispatch names intact. compact = contract_builder()(copy.deepcopy(schemas), 'compact_contract_v5') - if re.match(r'^ajax(?:$|[-_])', model_id_leaf(model)): - compact = [schema for schema in compact - if canonical(schema['function']['name']) != 'ask_user'] # Description dropout makes edit_document's legacy free-form ``command`` # field indistinguishable from a verb/action hint. Small models then emit # values such as {"command":"replace"}, which cannot identify either side @@ -2897,7 +2809,7 @@ 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: + 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. routed_canonical.discard('generate_image') missing_active = { @@ -2997,11 +2909,12 @@ def draft_contact_evidence_error(name, args, *, dependencies=(), executions=(), and not e.get('blocked')] if not observations: return 'Resolve the named recipient with resolve_contact before drafting. Never invent an email address.' + address_pattern = r'[A-Za-z0-9.!#$%&\x27*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' known = {address.casefold() for e in observations - for address in iter_email_addresses(str(e.get('output') or ''), ascii_only=True)} - known.update(address.casefold() for address in iter_email_addresses(user_text, ascii_only=True)) + for address in re.findall(address_pattern, str(e.get('output') or ''))} + known.update(address.casefold() for address in re.findall(address_pattern, user_text)) proposed = {address.casefold() for field in ('to', 'cc', 'bcc') - for address in iter_email_addresses(str(args.get(field) or ''), ascii_only=True)} + for address in re.findall(address_pattern, str(args.get(field) or ''))} if not proposed or not proposed <= known: return ('The recipient address is not supported by the contact lookup. Use an exact ' 'returned address for the requested person; if no match exists, explain ' @@ -3619,6 +3532,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'} @@ -4119,7 +4044,7 @@ def active_document_revision_quality_error(name, args, *, active_document, user_ if not incoming: return None addition = incoming[len(existing):].strip() if existing and incoming.startswith(existing) else '' - candidate = strip_angle_tags(addition, ' ').strip() + candidate = re.sub(r'<[^>]+>', ' ', addition).strip() candidate = re.sub(r'\s+', ' ', candidate) if not candidate or candidate.casefold() in str(user_text or '').casefold(): return None @@ -4144,8 +4069,8 @@ def document_suggestion_quality_error(name, args, *, user_text): for suggestion in suggestions: if not isinstance(suggestion, dict): continue - source = re.sub(r'\s+', ' ', strip_angle_tags(str(suggestion.get('find') or ''), ' ', allow_empty=True)).strip() - result = re.sub(r'\s+', ' ', strip_angle_tags(str(suggestion.get('replace') or ''), ' ', allow_empty=True)).strip() + source = re.sub(r'\s+', ' ', re.sub(r'<[^>]*>', ' ', str(suggestion.get('find') or ''))).strip() + result = re.sub(r'\s+', ' ', re.sub(r'<[^>]*>', ' ', str(suggestion.get('replace') or ''))).strip() if not source or not result: continue source_words = len(re.findall(r"\b[\w’'-]+\b", source)) @@ -4207,6 +4132,24 @@ 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') + ) + if 'sealed workspace' in blob or 'resource_identity_denied' in blob: + 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.""" @@ -4301,17 +4244,13 @@ _WORKSPACE_FILE_RE = re.compile( r"/workspace/[^\s,,、;;`\"'<>]+\.[A-Za-z0-9]{1,12}", re.I, ) -_WORKSPACE_PREFIX_RE = re.compile(r"/workspace/", re.I) -_WORKSPACE_FILE_TOKEN_TAIL_RE = re.compile(r"[^\s,,、;;`\"'<>]*") def declared_workspace_artifacts(user_text): """Return explicit output paths, excluding paths used only as inputs.""" text = str(user_text or '') paths = [] - for match in iter_prefixed_token_matches( - text, _WORKSPACE_PREFIX_RE, _WORKSPACE_FILE_RE, _WORKSPACE_FILE_TOKEN_TAIL_RE - ): + for match in _WORKSPACE_FILE_RE.finditer(text): path = match.group(0).rstrip('.!?))]}') if path.startswith('/workspace/fixtures/') or path in paths: continue @@ -4431,61 +4370,17 @@ def evidence_tool_keeps_distinct_requests_available(name): } -def _terminal_source_link_clause(text): - """Recognize the terminal short source/link clause from right to left.""" - value = str(text or '') - def matches_at(end): - while end and value[end - 1] in ".!?": - end -= 1 - while end and value[end - 1].isspace(): - end -= 1 - lowered = value[:end].casefold() - for courtesy in ("please", "pls"): - if lowered.endswith(courtesy): - boundary = end - len(courtesy) - end = boundary - while end and value[end - 1].isspace(): - end -= 1 - lowered = value[:end].casefold() - break - token_match = re.search(r"(?:sources?|citations?|links?)$", lowered) - if token_match is None: - return False - prefix = value[:token_match.start()] - original_cursor = len(prefix) - courtesy_end = original_cursor - while courtesy_end and prefix[courtesy_end - 1].isspace(): - courtesy_end -= 1 - cursors = [original_cursor] - for courtesy in ("please", "pls"): - start = courtesy_end - len(courtesy) - if start >= 0 and prefix[start:courtesy_end].casefold() == courtesy: - if courtesy_end < original_cursor and (start == 0 or prefix[start - 1].isspace()): - cursors.append(start) - break - for cursor in cursors: - while cursor and prefix[cursor - 1].isspace(): - if prefix[cursor - 1] == "\n": - return True - cursor -= 1 - if cursor == 0 or prefix[cursor - 1] in ".!?;,": - return True - return False - - return matches_at(len(value)) or (value.endswith("\n") and matches_at(len(value) - 1)) - - def requested_web_source_links(user_text): - text = str(user_text or '') - return _terminal_source_link_clause(text) or bool(re.search( + return bool(re.search( r'\b(?:return|give|show|include|provide|cite|find)\b.{0,35}\b(?:source\s+)?links?\b' + r'|(?:^|[.!?;,\n])\s*(?:(?:pls|please)\s+)?(?:sources?|citations?|links?)\s*(?:pls|please)?\s*[.!?]*$' r'|\b(?:\d+|one|two|three|four|five)\s+(?:official\s+)?(?:source\s+)?links?\b' r'|\bofficial\s+source\b' r'|\b(?:with|include|provide|cite|show|give|find)\s+(?:the\s+)?(?:official\s+)?(?:sources|citations)\b' r'|\blink\s+(?:to\s+)?(?:the\s+|your\s+)?(?:original\s+|official\s+)?(?:instructions|sources|documentation|articles?|reports?|studies|manuals?|guides?)\b' r'|\b(?:find|locate|get|download)\b.{0,60}\bofficial\b.{0,60}\b(?:manual|guide|handbook|pdf|documentation)\b' r'|\b(?:find|locate|get|download)\b.{0,80}\b(?:manual|guide|handbook|pdf)\b.{0,40}\b(?:online|official)\b', - text, + str(user_text or ''), re.IGNORECASE, )) @@ -4522,67 +4417,12 @@ def unbound_lookup_reference(user_text, history, *, supplied_context=False): )) -def _web_source_rows(text): - """Extract numbered source title/URL rows with monotonic line scans.""" - rows = [] - position = 0 - length = len(text) - while position < length: - if position and text[position - 1] != "\n": - newline = text.find("\n", position) - if newline < 0: - break - position = newline + 1 - continue - cursor = position - if cursor >= length or text[cursor] != "[": - newline = text.find("\n", cursor) - if newline < 0: - break - position = newline + 1 - continue - cursor += 1 - digit_start = cursor - while cursor < length and text[cursor].isdigit(): - cursor += 1 - if cursor == digit_start or cursor >= length or text[cursor] != "]": - position += 1 - continue - cursor += 1 - if cursor >= length or not text[cursor].isspace(): - position += 1 - continue - while cursor < length and text[cursor].isspace(): - cursor += 1 - title_start = cursor - title_line_end = text.find("\n", title_start) - if title_line_end < 0: - break - title_end = title_line_end - while title_end > title_start and text[title_end - 1].isspace(): - title_end -= 1 - url_start = title_line_end + 1 - while url_start < length and text[url_start].isspace(): - url_start += 1 - scheme_length = 7 if text.startswith("http://", url_start) else 8 if text.startswith("https://", url_start) else 0 - if title_end > title_start and scheme_length: - url_end = url_start + scheme_length - while url_end < length and not text[url_end].isspace(): - url_end += 1 - rows.append((text[title_start:title_end], text[url_start:url_end])) - position = url_end - continue - newline = text.find("\n", position) - if newline < 0: - break - position = newline + 1 - return rows - - def web_source_links(raw, *, max_items=1, prefer_official=False, query=''): """Extract stable title/URL pairs from the web tool's source preamble.""" text = str(raw or '') - rows = _web_source_rows(text) + rows = re.findall( + r'^\[\d+\]\s+(.+?)\s*\n\s*(https?://\S+)', text, re.MULTILINE, + ) query_tokens = set(re.findall(r'[a-z0-9]+', str(query or '').casefold())) - { 'the', 'a', 'an', 'official', 'source', 'link', 'page', 'website', 'site', 'guide', 'search', 'find', 'for', 'return', @@ -5235,7 +5075,6 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authorit async def stream_preview(*, endpoint_url, model, messages, headers, turn_contract, session_id, owner, disabled_tools, tool_policy, history_session=None, external_untrusted_context_seen=False, - delegated_credential=False, active_document=None, active_email=None, workspace=None, client_runtime_context=None, max_tokens=768, max_rounds=8, max_tool_calls=0, @@ -5271,20 +5110,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac suggestion_only=suggestion_target, source_verification=_bound_editor_requests_web_verification(direct_user_text), ) - from src.tool_security import delegated_credential_blocked_tools - from src.tool_capabilities import delegated_tool_is_blocked - disabled_tools = set(disabled_tools or ()) - if delegated_credential: - disabled_tools.update(delegated_credential_blocked_tools()) - hard_disabled = disabled_tools | (set(tool_policy.all_disabled_names()) if tool_policy else set()) - def permitted_schema(schema): - name = schema['function']['name'] - return (not (tool_policy and tool_policy.block_all_tool_calls) - and name not in hard_disabled - and not (delegated_credential and delegated_tool_is_blocked(name))) - - offered = [schema for schema in compact_schemas(turn_contract.schemas(), model=model) - if permitted_schema(schema)] + offered = compact_schemas(turn_contract.schemas(), model=model) if standalone_social_turn(direct_user_text) or inline_text_transformation(direct_user_text): offered = [] external_schema_by_name = { @@ -5302,6 +5128,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac for schema in offered ] progressive_thinking = progressive_thinking_for_turn(model, offered, ignored.get('thinking_mode')) + recovery_thinking_pending = False + recovery_thinking_rounds = [] + operation_thinking_pending = '' + deliberated_operations = set() + operation_thinking_rounds = [] external_runtime_tools = frozenset( str((schema.get('function') or {}).get('name') or '') for schema in (external_tool_schemas or ()) @@ -5335,7 +5166,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac 'This is a tool preview connected to the authenticated user’s real data. ' ) system = ( - f'You are Odysseus. Current UTC date and time: {datetime.now(timezone.utc).isoformat()}. ' + f'You are Ajax, the assistant in Odysseus. Current UTC date and time: {datetime.now(timezone.utc).isoformat()}. ' + runtime_scope_clause + 'Use available tools when needed, including for personal records and current information. ' 'Preserve conversation context on follow-ups and choose arguments yourself. ' @@ -5472,7 +5303,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac initial_length = len(history) security = ToolRunSecurityContext( external_untrusted_context_seen=bool(external_untrusted_context_seen), - delegated_credential=bool(delegated_credential), unattended_tools=( NATIVE_WORKSPACE_TOOLS if native_workspace_enabled else frozenset() ), @@ -5542,11 +5372,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac action_promise_recovery_attempts = 0 citation_recovery_attempted = False force_no_tools_next_round = False - # Research can support an editor operation without owning its deliverable. - web_briefing_target = ( - not active_editor_target and broad_current_web_request(direct_user_text) - ) - force_web_search_next_round = web_briefing_target and not native_workspace_enabled force_private_browser_next_round = False force_web_fetch_next_round = False suggestion_retry_required = False @@ -5614,14 +5439,61 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac required_artifacts = runtime_required_artifacts( direct_user_text, client_runtime_context, ) if native_workspace_enabled else tuple() - if required_artifacts: - web_briefing_target = False + # Editor/artifact tasks have their own completion checks and reserved write + # budget. Do not reopen general briefing research after producing the output. + web_briefing_target = ( + not active_editor_target and not required_artifacts + and broad_current_web_request(direct_user_text) + ) + force_web_search_next_round = web_briefing_target and not native_workspace_enabled yield event({'type': 'turn_contract', **turn_contract.audit(), 'schema_mode': 'compact_contract_v5', 'native_workspace': native_workspace_enabled, 'required_artifacts': list(required_artifacts), 'multimodal_image_count': image_context_count, 'attachment_reference_count': attachment_refs, 'image_rehydration': conversation_diagnostics.get('image_rehydration')}) + # A photo-edit turn with no open editor has no canvas to drive; offering the + # editor tools would only loop until the round cap and end with no answer. + # Answer deterministically and ask the user to open the attached image in + # the editor (Quick Edit) before applying the request. + _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}) + # The user text can carry harness attachment annotations + # ("[Image: x.png]", "[No vision model ...]"). Strip them so the + # one-click action re-sends only what the user typed. + _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() + # Machine-readable action so the chat UI can render a one-click + # "Open in editor & apply" button that loads the attached image and + # re-sends this prompt through the photo_editor endpoint. + 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 +5575,16 @@ 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')) # 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. @@ -5860,7 +5742,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac round_offered = artifact_completion_tool_schemas( round_offered, required_artifacts, ) - round_offered = [schema for schema in round_offered if permitted_schema(schema)] research_choice = None if not required_artifacts: round_offered, research_choice, _ = bounded_research_tool_policy( @@ -5874,6 +5755,53 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac # budget avoids a reasoning-only response that leaves the # required artifact unwritten. round_max_tokens = request_max_tokens + editor_write_complete = ( + active_editor_target and successful_write + and not editor_partial_pending and not editor_batch_pending + ) + deliberate = operation_thinking_pending or ( + 'document_edit' if active_editor_target and not editor_write_complete else + 'code' if standalone_code_request(direct_user_text) and not successful_write else + 'research_synthesis' if web_briefing_target and successful_web_retrievals >= 2 else '' + ) + if not deliberate: + deliberate = spec_router_think_reason( + round_number, _spec_route_think, deliberate) + progressive_thinking = progressive_thinking_for_turn( + model, offered, ignored.get('thinking_mode'), + tool_failed=recovery_thinking_pending, + deliberate=bool(deliberate), + ) + if progressive_thinking and deliberate: + deliberated_operations.add(deliberate) + operation_thinking_rounds.append({'round': round_number, 'reason': deliberate}) + operation_thinking_pending = '' + if progressive_thinking and recovery_thinking_pending: + recovery_thinking_rounds.append(round_number) + yield event({'type': 'tool_loop_recovery', + 'reason': 'progressive_thinking', 'round': round_number}) + # Reserve room for a private reasoning trace. Progressive Qwen + # asks for it explicitly; reasoning-capable providers such as + # DeepSeek V4 stream reasoning_content against max_tokens, so the + # default 768-token budget can starve the visible answer. + if progressive_thinking: + # Reserve room for reasoning and repair, honoring explicit limits. + round_max_tokens = max(round_max_tokens, 4096) + if max_tokens and int(max_tokens) > 0: + round_max_tokens = min(round_max_tokens, int(max_tokens)) + elif ( + supports_user_thinking_toggle(model) + and str(ignored.get('thinking_mode') or '').strip().lower() != 'off' + ): + # Reasoning is on by default for these providers and its + # tokens count against max_tokens, so raise the ceiling above + # the 768-token chat default. An explicit larger cap still + # wins; the model stops early, so this is a ceiling only. + floor = 4096 + if max_tokens and int(max_tokens) > floor: + floor = min(int(max_tokens), 8192) + round_max_tokens = max(round_max_tokens, floor) + recovery_thinking_pending = False request = {'model': model, 'messages': request_messages, 'temperature': temperature, 'max_tokens': round_max_tokens, 'stream': True, 'stream_options': {'include_usage': True}, @@ -5886,6 +5814,8 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac and not editor_partial_pending and not editor_batch_pending ) if editor_write_complete: + request['chat_template_kwargs']['enable_thinking'] = False + progressive_thinking = False request['max_tokens'] = min(round_max_tokens, 256) request['messages'] = [*request['messages'], { 'role': 'user', 'content': @@ -6039,15 +5969,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac pending, content, round_reasoning = {}, '', '' document_preview_index = None document_preview_content = '' - if 'tools' in request: - request['tools'] = [schema for schema in request['tools'] if permitted_schema(schema)] - offered_names = {schema['function']['name'] for schema in request['tools']} - choice = request.get('tool_choice') - if not request['tools']: - for key in ('tools', 'tool_choice', 'parallel_tool_calls'): - request.pop(key, None) - elif isinstance(choice, dict) and choice.get('function', {}).get('name') not in offered_names: - request.pop('tool_choice', None) can_preview_document = any( schema.get('function', {}).get('name') == 'create_document' for schema in request.get('tools', []) @@ -6056,6 +5977,19 @@ 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 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 @@ -6094,6 +6028,17 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac # established budget/error semantics. round_offered = list(request.get('tools') or []) finish_during_stream = False + # stream_preview talks to the provider directly and therefore + # bypasses the llm_core request pipeline. Apply the DeepSeek V4 + # reasoning toggle here too, otherwise a user's thinking_mode + # choice is silently ignored for these sessions. + from src.llm_core import _apply_deepseek_v4_reasoning_defaults + _apply_deepseek_v4_reasoning_defaults( + request, endpoint_url, model, ignored.get('thinking_mode'), + ) + think_split_state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False} + round_reasoning_seen = False + think_answer_streamed = False async with preview_model_response(client, endpoint_url, headers, request, context_recovery) as response: response.raise_for_status() finish_event = ( @@ -6111,7 +6056,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac provider_error = payload['error'] if isinstance(provider_error, dict): provider_error = provider_error.get('message') or provider_error.get('detail') - detail = str(provider_error or 'Unknown provider error').strip() + detail = str(provider_error or 'Unknown provider error').strip()[:300] raise ProviderStreamError(detail) usage = payload.get('usage') or {} if usage: @@ -6135,11 +6080,41 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac ) if reasoning: round_reasoning += str(reasoning) + round_reasoning_seen = True text = delta.get('content') or '' if text: first_token = first_token or time.monotonic() content += text if ( + progressive_thinking + and not round_reasoning_seen + and not prior_summary_answer + and not source_lookup_pending + and not tool_call_requested + and not pending + ): + # No reasoning parser: the model's … + # trace arrives as ordinary content. Stream the + # trace as thinking deltas and the text after + # as content instead of buffering the + # whole round (which showed nothing, then dumped + # the answer at once). + for split_event in qwen_think_split_events(content, think_split_state): + if ( + replace_streamed_draft_on_finish + and not streamed_round_text + and not split_event.get('thinking') + ): + split_event = { + **split_event, + 'render_owner': 'streamed', + 'replacement_scope': 'turn', + } + yield event(split_event) + if not split_event.get('thinking'): + streamed_round_text = True + think_answer_streamed = True + elif ( not prior_summary_answer and not progressive_thinking and not source_lookup_pending @@ -6194,7 +6169,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac break if progressive_thinking: content = visible_content_after_qwen_thinking(content) - if content and not prior_summary_answer and not source_lookup_pending: + if think_answer_streamed: + # The answer already went out as live deltas during the + # split; never dump it a second time. + streamed_round_text = True + elif (content and not prior_summary_answer and not source_lookup_pending + and not pending): yield event({'delta': content}) streamed_round_text = True proposed = [pending[i] for i in sorted(pending)] @@ -6536,6 +6516,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac result.get('error') or result.get('exit_code') not in (None, 0) ) + recovery_thinking_pending |= failed output = result.get('output') or result.get('error') or result output = output if isinstance(output, str) else json.dumps( output, ensure_ascii=False, @@ -6630,7 +6611,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac if ( native_workspace_enabled and content - and contains_detailed_sequence_request(direct_user_text) + and DETAILED_VIDEO_REQUEST.search(direct_user_text) and successful_video_inspections == 1 and not media_detail_nudge_sent and round_number < round_limit @@ -6695,6 +6676,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 @@ -6726,6 +6726,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac # policy. A harmless read followed by blocked mutations must # not partially execute or emit one denial per attempted row. batch_policy_denied = False + batch_deliberation = '' for call in proposed: try: preflight_name = offered_tool_alias( @@ -6788,6 +6789,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac policy_decisions.append({'round': round_number, **decision.audit()}) batch_policy_denied = True break + if not batch_deliberation: + reason = tool_deliberation_reason(preflight_name, preflight_args) + if reason and progressive_thinking: + deliberated_operations.add(reason) + if reason not in deliberated_operations: + batch_deliberation = reason except (KeyError, TypeError, ValueError, json.JSONDecodeError, jsonschema.ValidationError): # Malformed calls still enter the normal tool-error # feedback path so the model can repair their syntax. @@ -6798,6 +6805,21 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac history.append({'role': 'assistant', 'content': refusal}) yield event({'type': 'final_response', 'content': refusal}) break + if (batch_deliberation and not progressive_thinking + and uses_odysseus_progressive_thinking(model) + and round_number < round_limit): + # No call has executed. Remove the unexecuted call envelope + # so the next request contains no orphaned tool-call IDs. + history.pop() + operation_thinking_pending = batch_deliberation + history.append({'role': 'user', '_harness_control': True, 'content': + 'Before executing the proposed operation, review its arguments against ' + 'the user request and the available evidence. Nothing in this proposed ' + 'batch has executed. Return the corrected tool calls, or obtain missing ' + 'evidence first. Do not expand permissions or assume missing facts. ' + 'Proposed batch: ' + json.dumps(proposed, ensure_ascii=False)}) + replace_streamed_draft_on_finish |= streamed_round_text + continue terminal_denial = False terminal_suppression_violation = False terminal_search_budget_violation = False @@ -6810,6 +6832,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac # request with HTTP 400. Collect visual evidence while the # batch executes and append it only after every tool result. round_visual_blocks = [] + ask_user_turn_end = False for call in proposed: name = offered_tool_alias(call['function']['name'], round_offered) arguments = call['function']['arguments'] @@ -6817,10 +6840,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac result, desc, policy_denied, block = None, name, False, None args = {} execution_attempted = False - # Guidance a preview validator authored for this call. It - # is the only failure text returned verbatim; exception - # text goes through _public_preview_tool_error. - validator_error = '' call_signature = None semantic_scope = None try: @@ -6879,12 +6898,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac prior_outcome = browser_navigation_outcomes.get(requested_url) if requested_url and prior_outcome and prior_outcome[1] >= 2: calls += 1 - validator_error = ( + raise ValueError( f'Opening {requested_url} twice reached the same page ' f'({prior_outcome[0]}). Do not repeat it; use the current ' 'page evidence or a different navigation strategy.' ) - raise ValueError(validator_error) if tool_type == 'web_search': web_search_attempts += 1 if not native_workspace_enabled and web_search_attempts > 3: @@ -6978,7 +6996,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac 'This exact invalid call was repeated after two validation failures; ' 'the tool is disabled for this turn.' ) - validator_error = semantic_error raise ValueError(semantic_error) if canonical(name) == 'bash': command = str(args.get('command') or '') @@ -6987,7 +7004,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac calls += 1 suppressed_tool_until_round['bash'] = round_number + 1 round_recovery_messages.append(sensitive_error) - validator_error = sensitive_error raise ValueError(sensitive_error) misused_native_tool = shell_native_tool_command_misuse( command, round_offered, @@ -7001,7 +7017,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac 'with its offered schema.' ) round_recovery_messages.append(recovery) - validator_error = recovery raise ValueError(recovery) semantic_scope = semantic_repeat_scope(name, args) if ( @@ -7058,11 +7073,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac if canonical(name) in permanently_suppressed_tools: calls += 1 terminal_suppression_violation = True - validator_error = ( + raise ValueError( f'{name} was disabled after repeated identical calls; ' 'no further execution was attempted.' ) - raise ValueError(validator_error) success_repeat_limit = ( private_browser_success_repeat_limit(args) if tool_type == 'private_browser' else 1 @@ -7155,7 +7169,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac ) if artifact_code_error: round_recovery_messages.append(artifact_code_error) - validator_error = artifact_code_error raise ValueError(artifact_code_error) decision = evaluate_preview_call( name, args, latest_user, @@ -7358,19 +7371,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac ): artifact_body_handoff_attempts += 1 artifact_body_handoff_target = handoff_target - logging.getLogger(__name__).warning( - 'Clean v3 tool call failed: %s', exc, exc_info=True, - ) - if not validator_error and isinstance(exc, jsonschema.ValidationError) and schema: - validator_error = _schema_argument_hint(args, schema['function']['parameters']) - result = { - 'error': ( - validator_error if validator_error and not execution_attempted - else _public_preview_tool_error(exc, execution_attempted=execution_attempted) - ), - 'error_category': 'tool_execution_error' if execution_attempted else 'invalid_tool_arguments', - 'exit_code': 1, - } + result = {'error': str(exc).splitlines()[0][:300], 'exit_code': 1} if (canonical(block.tool_type if block is not None else name) == 'youtube_tool' and result.get('exit_code') not in (None, 0)): round_recovery_messages.append( @@ -7447,6 +7448,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac 'type': 'tool_loop_recovery', 'reason': 'web_fetch_boilerplate_fallback', }) + recovery_thinking_pending |= failed if not failed and canonical(actual_tool) == 'web_search': embedded_urls = search_embedded_article_urls(output) if embedded_urls: @@ -7495,6 +7497,8 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac browser_access_blocked = ( canonical(actual_tool) == 'private_browser' and not failed + # Compaction is for model readability; title/snapshot + # relationships must remain intact for recovery checks. and browser_observation_access_blocked(result.get('output') or result) ) browser_page_missing = ( @@ -7502,6 +7506,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac and not failed and browser_observation_page_missing(output) ) + recovery_thinking_pending |= browser_access_blocked or browser_page_missing if browser_page_missing: round_recovery_messages.append( 'The browser displayed a missing-page error, not article evidence. ' @@ -7559,9 +7564,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac requested_browser_url = private_browser_open_url(args) effective_browser_url = private_browser_effective_url(result) browser_search_url = requested_browser_url or effective_browser_url - is_search_engine_navigation = contains_search_engine_navigation( - browser_search_url - ) or bool(re.search( + is_search_engine_navigation = bool(re.search( + r'https?://(?:[^/]+\.)?(?:google\.[^/]+|bing\.com|duckduckgo\.com)' + r'/(?:search|sorry|html|lite|\?)', + browser_search_url, + re.I, + )) or bool(re.search( r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/', effective_browser_url, re.I, @@ -7729,9 +7737,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac 'execution_attempted': execution_attempted, 'blocked': policy_denied or schema is None, 'desc': desc, 'round': round_number} - for error_category in ('tool_execution_error', 'invalid_tool_arguments'): - if result.get('error_category') == error_category: - tool_event['error_category'] = error_category + if isinstance(result.get('ask_user'), dict): + # Copy the interactive payload into the persisted tool + # event so history reload can reconstruct the card even + # after the turn ends without another provider round. + tool_event['ask_user'] = result['ask_user'] reader_event = email_reader_event(direct_user_text, actual_tool, args, result, failed=failed) if reader_event: yield event(reader_event) @@ -7843,6 +7853,19 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac record_tool_execution(executions, tool_event) yield event(tool_event) history.append({'role': 'tool', 'tool_call_id': call['id'], 'content': output}) + ask_user_payload = result.get('ask_user') + if isinstance(ask_user_payload, dict) and str(ask_user_payload.get('question') or '').strip(): + # ask_user is a turn boundary, not an ordinary tool. + # Emit the interactive card, persist the question as the + # assistant text (so the model sees it was already asked + # next turn), and stop without feeding the tool result + # back for another provider round. + ask_question = str(ask_user_payload['question']).strip() + history.append({'role': 'assistant', 'content': ask_question}) + yield event({'type': 'final_response', 'content': ask_question}) + yield event({'type': 'ask_user', 'data': ask_user_payload}) + ask_user_turn_end = True + break if (not failed and block is not None and block.tool_type == 'web_fetch' and len(proposed) == 1 and result.get('page_entries') and set(turn_contract.required) <= {'web_fetch', 'web_search', 'extract_text'}): @@ -8069,6 +8092,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac ) if policy_denied: terminal_denial = True + if ask_user_turn_end: + # The ask_user card is the final UI event; end the turn and + # wait for the user's selection instead of advancing a round. + break if round_visual_blocks: visual_message = untrusted_context_message( 'tool visual evidence', @@ -8139,7 +8166,43 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac 'round': round_number, }) if terminal_denial: - refusal = denied_response() + usable_evidence = any( + not e.get('error') and e.get('exit_code', 0) == 0 and not e.get('blocked') + for e in executions + ) + if usable_evidence: + # A denied call must not discard evidence a successful call already + # produced. Recover by answering from the evidence we have and stating + # plainly what could not be done, instead of a blanket refusal. + if not round_recovery_messages: + recovery = ( + 'One or more tool calls were denied, but earlier tool calls in ' + 'this turn returned usable evidence. Do not repeat any denied ' + 'call and do not call more tools. Using only the evidence ' + 'already returned, provide the best complete final answer now, ' + 'and briefly state what you could not do because it was not ' + 'permitted.' + ) + if history and history[-1].get('_harness_control'): + history[-1]['content'] = ( + str(history[-1].get('content') or '') + ' ' + recovery + ) + else: + history.append({ + 'role': 'user', '_harness_control': True, 'content': recovery, + }) + force_no_tools_next_round = True + yield event({ + 'type': 'completion_recovery', + 'reason': 'denial_after_successful_evidence', + 'round': round_number, + }) + continue + refusal = ( + WORKSPACE_REQUIRED_REFUSAL + if workspace_process_denial(executions) + else denied_response() + ) history.append({'role': 'assistant', 'content': refusal}) yield event({'type': 'final_response', 'content': refusal}) break @@ -8304,9 +8367,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac else: yield event({'delta': '\nThe preview reached its round limit. Please narrow the request.'}) except ProviderStreamError as exc: - detail = 'The selected model provider failed while generating. Retry or choose another model.' - logging.getLogger(__name__).warning('Clean v3 provider stream failed: %s', exc, exc_info=True) - yield f'event: error\ndata: {json.dumps({"status": 502, "error": detail, "error_category": "provider_stream_error"})}\n\n' + detail = f'The selected model provider failed while generating: {exc}' + logging.getLogger(__name__).warning('Clean v3 provider stream failed: %s', exc) + yield f'event: error\ndata: {json.dumps({"status": 502, "error": detail})}\n\n' return except httpx.HTTPStatusError as exc: status = exc.response.status_code @@ -8360,5 +8423,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac 'policy_decisions': policy_decisions, 'schema_mode': 'compact_contract_v5', 'clean_v3_preview': True, 'thinking_mode': 'progressive_on' if progressive_thinking else 'off', + 'recovery_thinking_rounds': recovery_thinking_rounds, + 'operation_thinking_rounds': operation_thinking_rounds, }}) yield 'data: [DONE]\n\n' diff --git a/src/context_compactor.py b/src/context_compactor.py index 11f450323..bdf9ec025 100644 --- a/src/context_compactor.py +++ b/src/context_compactor.py @@ -105,7 +105,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: diff --git a/src/memory_vector.py b/src/memory_vector.py index 5b57f38d7..5d084a117 100644 --- a/src/memory_vector.py +++ b/src/memory_vector.py @@ -102,15 +102,15 @@ class MemoryVectorStore: return collections def add(self, memory_id: str, text: str): - """Add a single memory entry to the vector index.""" + """Insert or refresh a memory entry, preserving unchanged embeddings.""" if not self._healthy: return for lane in self._lanes: try: - existing = lane.collection.get(ids=[memory_id]) - if existing["ids"]: + existing = lane.collection.get(ids=[memory_id], include=["documents"]) + if existing["ids"] and existing["documents"] == [text]: continue - lane.collection.add( + lane.collection.upsert( ids=[memory_id], embeddings=lane.encode([text]), documents=[text], diff --git a/src/model_profiles.py b/src/model_profiles.py index 344c19a41..28ccbc028 100644 --- a/src/model_profiles.py +++ b/src/model_profiles.py @@ -56,7 +56,7 @@ def supports_user_thinking_toggle(value: object) -> bool: return False return any(pattern in leaf for pattern in ( "kimi-k2.5", "kimi-k2.6", "kimi-k3", - "qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", + "qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "deepseek-v4", "minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3", "magistral", "mistral-small", "mistral-medium", )) diff --git a/src/settings.py b/src/settings.py index 6c9d81392..049320d06 100644 --- a/src/settings.py +++ b/src/settings.py @@ -47,6 +47,13 @@ DEFAULT_SETTINGS = { "image_gen_enabled": False, "image_model": "", "image_quality": "medium", + # Local editor-agent/planner model (Quick Edit NL planner). When empty the + # server auto-detects the local Qwen3.5-9B vision server on seneca and falls + # back to the hosted chat model. See src/editor_agent_model.py. + "editor_agent_base_url": "", + "editor_agent_model": "", + "editor_caption_base_url": "", + "editor_caption_model": "", "vision_model": "", "vision_enabled": True, # Ordered fallback chain for the Vision model (image analysis, OCR, tagging). diff --git a/src/task_scheduler.py b/src/task_scheduler.py index beeb4dee5..83312b4b6 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -1969,20 +1969,21 @@ class TaskScheduler: messages.append(datetime_context_msg) messages.append({"role": "user", "content": user_content}) - # Recover the registered runtime URL and credentials for this chat route. + # Resolve headers from the endpoint's API key headers = {} try: - from core.database import SessionLocal - from src.endpoint_resolver import ( - build_chat_url, build_headers, resolve_endpoint_runtime, - resolve_owner_registered_endpoint, - ) + from core.database import SessionLocal, ModelEndpoint + from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base + from src.auth_helpers import owner_filter db2 = SessionLocal() try: - ep = resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None) - base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None) - endpoint_url = build_chat_url(base) - headers = build_headers(api_key, base) + ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None) + eps = ep_q.all() + for ep in eps: + if same_endpoint_base(endpoint_url, ep.base_url): + headers = build_headers(ep.api_key, normalize_base(ep.base_url)) + break finally: db2.close() except Exception: @@ -2169,23 +2170,26 @@ class TaskScheduler: endpoint_url, model = self._resolve_defaults(db, task.owner) if not endpoint_url or not model: raise RuntimeError("No model/endpoint configured for research") + endpoint_url = _normalize_chat_endpoint(endpoint_url) # Record the resolved model for the run record (see _execute_task_locked). self._last_run_model = model - # Authorize the selected URL before normalization can collapse service paths. + # Resolve headers try: - from src.endpoint_resolver import ( - build_chat_url, build_headers, resolve_endpoint_runtime, - resolve_owner_registered_endpoint, - ) + from core.database import ModelEndpoint + from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base + from src.auth_helpers import owner_filter + db2 = db if not headers_from_resolver: - ep = resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None) - base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None) - endpoint_url = build_chat_url(base) - headers = build_headers(api_key, base) + ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None) + eps = ep_q.all() + for ep in eps: + if same_endpoint_base(endpoint_url, ep.base_url): + headers = build_headers(ep.api_key, normalize_base(ep.base_url)) + break except Exception: pass - endpoint_url = _normalize_chat_endpoint(endpoint_url) max_tokens = int(get_setting("research_max_tokens", 8192)) extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90) @@ -2501,6 +2505,36 @@ class TaskScheduler: ScheduledTask.task_type == "action", ).all() if row[0] } + # Built-in housekeeping rows created before the request-authority + # seal carry a NULL snapshot, so every scheduled run failed with + # "Scheduled action has no matching server request authority". + # Replace them: drop the unsealed row and let the seed loop below + # recreate an authorized copy (server-owned ingress), preserving the + # user's enable/pause choice. We never grant authority to the + # existing row, so a model-origin row can never gain a built-in + # grant this way. + replaced_actions = {} + stale_rows = db.query(ScheduledTask).filter( + ScheduledTask.owner == owner, + ScheduledTask.task_type == "action", + ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())), + ).all() + sealed_actions = {r.action for r in stale_rows + if (r.request_authority_json or "").strip()} + removed_status = {} + for stale in stale_rows: + if (stale.request_authority_json or "").strip(): + continue + removed_status.setdefault(stale.action, stale.status) + db.delete(stale) + for action, status in removed_status.items(): + if action in sealed_actions: + # A sealed copy already exists; the NULL one was just a + # stale duplicate. Keep the sealed row and let the seed + # loop skip this action. + continue + replaced_actions[action] = status + existing_actions.discard(action) renamed = [] builtin_tasks = db.query(ScheduledTask).filter( ScheduledTask.owner == owner, @@ -2661,12 +2695,15 @@ class TaskScheduler: output_target=defs.get("output_target", "none"), notifications_enabled=False, ) + if action in replaced_actions and replaced_actions[action] in ("active", "paused"): + task.status = replaced_actions[action] db.add(task) seeded.append(action) - if seeded or renamed or removed_dupes or retired_count: + if seeded or renamed or removed_dupes or retired_count or replaced_actions: logger.info( - "Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s", + "Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s authority_replaced=%s", owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count, + sorted(replaced_actions), ) # Always commit — the orphan-run sweep above may have produced # pending deletes even when no defaults changed. diff --git a/src/tool_index.py b/src/tool_index.py index 33337211c..8961e70c3 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -111,7 +111,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "get_weather": "Get current weather and a three-day forecast for a city or place from Open-Meteo without an API key. Use for weather lookups before web_search.", "web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.", "pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.", - "youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks.", + "youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. YouTube ONLY — accepts only youtube.com or youtu.be links (video/shorts URL or 11-char video_id, or a channel /@handle/videos URL); for any other website use web_fetch or web_search instead.", "private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.", "inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.", "extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.", @@ -129,7 +129,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.", "update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.", "suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.", - "generate_image": "Generate an AI image from a text prompt. Specify model, size, and quality. Art, illustrations, photos.", + "generate_image": "Generate a NEW AI image from a text prompt (add content not already in the photo, e.g. a moon). Specify model, size, and quality. Art, illustrations, photos. Not for tone/filter edits.", "chat_with_model": "Send a message to a different AI model. Compare responses, get specialized help, delegate tasks.", "ask_teacher": "Ask a more capable model for help with a difficult problem. Escalate complex tasks.", "pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.", @@ -191,6 +191,10 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.", "app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter= lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.", "edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.", + "plan_edit": "Plan a multi-step edit for the open image editor and return ordered editor ops to run.", + "run_editor_command": "Run one deterministic editor op (adjustments, masked filters, selection, layers, import/place, export, screenshot) on the open image editor.", + "get_editor_screenshot": "Capture the open image editor's current canvas as a PNG data URL for inspection.", + "ai_edit_image": "Apply an AI image edit (remove/inpaint objects, generative replace, remove-bg, upscale, denoise, enhance-face) to the open image and place the result as a new layer. Use for object removal/addition/replacement; never approximate those with selection/adjustment/filter ops.", "trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.", "manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.", } diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 4e399a03d..e6402f20e 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -70,7 +70,7 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s command = args.get("command") if isinstance(command, str): path, separator, content = command.partition("\n") - if separator and path.strip() and content.strip(): + if separator and path.strip() and content: args["path"] = path.strip() args["content"] = content logger.warning("Normalized legacy `command` write shape to path/content") @@ -494,14 +494,14 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "youtube_tool", - "description": "Read YouTube-specific data without fighting the JS UI. Use for YouTube video comments, transcripts, metadata, or latest video from a channel. For general web lookup use web_search; for interacting with the visible site use private_browser.", + "description": "Read YouTube-specific data (comments, transcripts, metadata, or latest video from a channel) without fighting the YouTube JS UI. YOUTUBE ONLY: it accepts only youtube.com or youtu.be links — a video URL, a shorts/live link, or an 11-character video_id, or a channel /@handle/videos URL. It does NOT accept any other website. For a non-YouTube URL do not use this tool: use web_fetch for one specific page or web_search for an open-ended lookup; use private_browser only to drive the visible browser.", "parameters": { "type": "object", "properties": { "action": {"type": "string", "enum": ["comments", "transcript", "metadata", "latest_channel_video"], "description": "What YouTube data to retrieve. Use latest_channel_video with max_results for latest N uploads from a channel."}, - "url": {"type": "string", "description": "YouTube video or channel URL"}, - "video_url": {"type": "string", "description": "YouTube video URL"}, - "video_id": {"type": "string", "description": "YouTube video id"}, + "url": {"type": "string", "description": "A youtube.com or youtu.be URL (video, watch?v=, shorts, or a channel /@handle/videos URL). No other hosts."}, + "video_url": {"type": "string", "description": "A youtube.com or youtu.be video URL"}, + "video_id": {"type": "string", "description": "The 11-character YouTube video id (e.g. jNQXAC9IVRw)"}, "channel_url": {"type": "string", "description": "YouTube channel URL or /@handle/videos URL"}, "handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"}, "max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"} @@ -1497,7 +1497,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "generate_image", - "description": "Generate an AI image from a text prompt and save it to the gallery. If this tool reports that no image model or endpoint is configured, report that limitation directly; do not substitute Bash, Python, SVG, or another tool.", + "description": "Generate a NEW image from a text prompt and place it as a layer in the open editor or save it to the gallery. Use for adding content that is not already in the photo (e.g. 'add a full moon in the sky'). If this tool reports that no image model or endpoint is configured, report that limitation directly; do NOT substitute deterministic editor ops (selection/adjustments/filters), Bash, Python, or SVG.", "parameters": { "type": "object", "properties": { @@ -1527,6 +1527,75 @@ FUNCTION_TOOL_SCHEMAS = [ } } }, + { + "type": "function", + "function": { + "name": "plan_edit", + "description": "Plan a photo-editing program for the currently open image editor. Returns an ordered list of editor ops to run against the canvas. Route object removal, added content, or replaced content to ai_edit_image/generate_image; use deterministic ops only for tone/colour/filter/crop/text/selection edits. If no image provider is configured for a generative request, say so instead of planning a substitute deterministic edit. Use the canonical adjustment/filter shapes (adjustments.apply/applyMasked params {\"type\", \"params\"}; filters.apply/applyMasked params {\"id\"}); never invent adjustment types or filter ids. New ops: photo.autoTone; retouch.skin/blemish/teeth; selection.face/background. Portrait/look-better ('fix the skin', 'fix my teeth') -> selection.face then retouch.skin (and retouch.teeth for teeth) then ONE gentle photo.autoTone; never a timid single step. Low-key/'fix the lighting' on a dark image: at most ONE global lift (prefer photo.autoTone once or shadows-highlights); never stack autoTone/brightness/exposure/levels, and keep highlights <= 0. Temperature sign: moody/cool/cinematic -> white-balance temperature NEGATIVE; warm/golden/sunset -> POSITIVE. Background softer -> selection.background then filters.applyMasked {id:'blur-gaussian'} (never a plain global blur). A vague prompt with no concrete defect ('clean this up') -> return {\"reply\": , \"steps\": [], \"ask_user\": {\"question\":..., \"options\":[...]}} with 2-3 concrete options instead of guessing.", + "parameters": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "What the user wants done to the image"}, + "state": {"type": "object", "description": "Optional current editor state (layers, selection, document info)"} + }, + "required": ["prompt"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_editor_command", + "description": "Run a single deterministic operation on the open image editor and return its result. Use catalog ops such as adjustments.*, filters.applyMasked, selection.*, layers.*, document.importImage, layers.placeImage, export.png, screenshot. Do NOT use for AI/masked edits (use ai_edit_image) or new imagery (use generate_image). Canonical shapes: adjustments.apply/applyMasked params = {\"type\": , \"params\": {...}}; filters.apply/applyMasked params = {\"id\": }. Never nest a numeric bag under {\"adjustments\": {...}} and never invent adjustment types or filter ids (no \"vignette\" or \"clarity\"). Additional canonical ops: photo.autoTone {}; retouch.skin {smooth,opacity,blemish,radius,threshold}; retouch.blemish {radius,threshold}; retouch.teeth {desat,bright}; selection.face {mode}; selection.background {mode,subject,fuzziness}. At most one global brightness/exposure lift (prefer shadows-highlights; keep highlights <= 0). Never stack global lifts.", + "parameters": { + "type": "object", + "properties": { + "op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"}, + "params": {"type": "object", "description": "Parameters for the op"}, + "session": {"type": "string", "description": "Editor session id (defaults to the open session)"}, + "wait": {"type": "boolean", "description": "Wait for the editor result (default true)"} + }, + "required": ["op"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_editor_screenshot", + "description": "Capture the open image editor's current canvas as a PNG data URL. Use to inspect the result before choosing the next edit.", + "parameters": { + "type": "object", + "properties": { + "session": {"type": "string", "description": "Editor session id (defaults to the open session)"} + } + } + } + }, + { + "type": "function", + "function": { + "name": "ai_edit_image", + "description": "Apply an AI image edit and place the result as a NEW layer, never overwriting the original. Two modes. mode='instruction' (default): send the WHOLE image with a clear prompt and no mask — use a rephrased instruction that NAMES what must stay unchanged, e.g. 'replace the background with a sunny beach, keep the woman exactly the same'. Use this for background/scene replacement, changing the season or style, adding an object to the scene (a moon, a person), and removing diffuse distractions. mode='masked': masked inpaint on a selected region — use ONLY for an explicit regional removal of a clearly identified object ('remove the person on the left'); select the object first so the mask is tight. NEVER approximate object removal/addition/replacement with selection, adjustment, filter or transform ops; if no image provider is configured, report that instead of substituting a deterministic edit. Other ops: remove-bg, upscale (2x/4x), denoise, enhance-face.", + "parameters": { + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["remove-bg", "upscale", "denoise", "enhance-face", "inpaint", "generative"], "description": "AI edit operation"}, + "mode": {"type": "string", "enum": ["instruction", "masked"], "description": "instruction = whole-image edit with no mask (background/season/style/add-object/diffuse removal); masked = tight-mask regional removal of a selected object. Default instruction."}, + "prompt": {"type": "string", "description": "Instruction for inpaint/generative (in instruction mode, name what must stay unchanged)"}, + "image": {"type": "string", "description": "Optional source (data URL or gallery image id); defaults to the current canvas/screenshot"}, + "mask": {"type": "string", "description": "Optional base64 PNG mask (white = region to edit); required for mode='masked'"}, + "scale": {"type": "number", "description": "Upscale factor (2 or 4)"}, + "strength": {"type": "number", "description": "Denoise strength (0-1)"}, + "steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."}, + "place": {"type": "boolean", "description": "Place the result as a new layer (default true)"}, + "model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"}, + "session": {"type": "string", "description": "Editor session id"} + }, + "required": ["op"] + } + } + }, { "type": "function", "function": { @@ -2200,13 +2269,7 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock elif tool_type == "get_workspace": content = "" elif tool_type == "write_file": - body = args.get("content") - if isinstance(body, str) and body.strip(): - content = args.get("path", "") + "\n" + body - else: - # Preserve missing/empty intent for WriteFileTool instead of folding - # all empty shapes into the same path-plus-newline representation. - content = json.dumps(args) + content = args.get("path", "") + "\n" + args.get("content", "") elif tool_type == "edit_file": content = json.dumps(args) elif tool_type == "apply_patch": diff --git a/src/tool_types.py b/src/tool_types.py index 3024a13a9..54c0df8b6 100644 --- a/src/tool_types.py +++ b/src/tool_types.py @@ -28,5 +28,6 @@ TOOL_TAGS = { "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model", "list_cookbook_servers", "edit_image", "trigger_research", "manage_research", + "plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image", "app_api", } | BUILTIN_EMAIL_TOOLS diff --git a/src/tools/calendar.py b/src/tools/calendar.py index 3eeacda72..15f64dad2 100644 --- a/src/tools/calendar.py +++ b/src/tools/calendar.py @@ -227,7 +227,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor desc = str(raw_args.get("description") or "") if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I): raw = desc - if raw in (None, "", False): + if raw is None or raw == "" or raw is False: return None if raw is True: return 10 @@ -236,10 +236,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor text = str(raw).strip().lower() if text in {"none", "no", "off", "false"}: return None - m = re.search(r"(? 0: delta = timedelta(seconds=secs) diff --git a/src/tools/notes.py b/src/tools/notes.py index a28cda78c..f464e0dca 100644 --- a/src/tools/notes.py +++ b/src/tools/notes.py @@ -322,7 +322,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict: if looks_like_reminder: temporal = re.search( r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?" - r"|\b\d{1,2}(?::\d{2})?(?:\s*(?:am|pm))?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b" + r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b" r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b", lower_combined, ) diff --git a/src/workspace_paths.py b/src/workspace_paths.py index 5374f8637..5a6593a3c 100644 --- a/src/workspace_paths.py +++ b/src/workspace_paths.py @@ -61,3 +61,4 @@ def backend_workspace_path(path: str | None) -> str: return container_root return str(PurePosixPath(container_root) / rel) return expanded + diff --git a/tests/WAVE6_TEST_ISOLATION_REPORT.md b/tests/WAVE6_TEST_ISOLATION_REPORT.md new file mode 100644 index 000000000..90ee0d67e --- /dev/null +++ b/tests/WAVE6_TEST_ISOLATION_REPORT.md @@ -0,0 +1,369 @@ +# Wave 6 test-isolation foundation + +## Provenance and boundary + +- Worktree: `/home/alteixeira20/Projects/Odysseus/odysseus-wave6-test-isolation`. +- Starting branch: `wave6/test-isolation-foundation`. +- Starting HEAD: `b1666951faf8285054e1ca90f11533b0fb53fb57`. +- Starting TREE: `a3dee24e3c56c22e1d3f9805f409bf16b086c3d4`. +- `git status --short --branch`: `## wave6/test-isolation-foundation` only. +- `git status --porcelain`: empty. Starting worktree clean. +- No rebase, merge, push, PR edit, or access to the Wave 4 worktree. +- All changes are test code, test helpers, or this test validation report. + No production code changed; no STOP-level exception was needed. + +## Contamination map + +| Source | Mutation / binding | Previous cleanup | Escaping state / effect | +| --- | --- | --- | --- | +| Both tests in `tests/test_truncate_message_count_regression.py`, through `_make_manager` | Creates an unowned `mkstemp` DB; directly sets `os.environ["DATABASE_URL"]`; reloads `core.database` then `core.session_manager` | Only the assertion's ORM session is closed | Environment, engine, factory, declarative Base, ORM classes, manager globals, and registered engine listeners survive; database files are never deleted | +| Collection of `tests/test_manage_tasks_owner_scope.py` | Creates a module-lifetime temporary DB and assigns `_TS` permanently to `core.database.SessionLocal` | Some individual rows deleted; ORM sessions closed | The collection-time factory assignment leaks to unrelated tests; truncation reload replaces it, so production handlers and `_seed` / `_get` use different databases | +| `test_checkin_digest_owner_scope.py`, `test_gallery_owner_filter_single_user.py`, `test_document_session_owner_scope.py` | Create module-lifetime DB files / engines and retain local factories | Some rows deleted / route factory restored / ORM sessions closed | Owned files and engines lack teardown; retained imported ORM identities can disagree with reloaded module globals | +| `tests/conftest.py` bootstrap | `DATABASE_URL` was set only if absent before importing `core.database` | No environment restoration | An inherited developer URL could be opened and initialized during collection | + +`importlib.reload` updates the existing module dictionary in place; it does +not change the `sys.modules` module object's identity. Previously imported +symbols nevertheless keep their old class/factory identities. In particular, +`src.database` re-exports old objects, while reloaded `core.session_manager` +rebinds its `DbSession`, `DbChatMessage`, `DbDocument`, `SessionLocal`, and +`SessionManager`. Old functions still resolve globals through those changed +module dictionaries. Each database reload also registers a new SQLite connect +listener on SQLAlchemy's Engine class. + +The current failures are principally in `test_manage_tasks_owner_scope.py`: +`_seed` writes through its retained `_TS`, but `do_manage_tasks` imports the +now-replaced factory from `core.database` at call time. Ownerless / cross-owner +mutations incorrectly observe "task not found"; successful mutation/list/create +witnesses likewise read or write the wrong test database. Four invalid-date +cases still pass, so the observed contamination count is 16 rather than all +20 task-owner cases. + +`src.settings` has TTL caches, but those caches do not resolve DATABASE_URL. +The contaminating helper neither patches nor reloads settings. The database +URL is read directly from the environment at database import/reload time; +settings cache invalidation is not the correction for this leak. + +## Baseline reproduction before editing + +All baseline commands explicitly selected in-memory bootstrap storage. Python +with installed pytest/SQLAlchemy was +`/tmp/pr40-final-validation-venv/bin/python` (pytest 9.1.1, SQLAlchemy 2.1.1). + +```sh +DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py +# 30 passed, 9 warnings in 2.48s + +DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_truncate_message_count_regression.py tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py +# 16 failed, 14 passed, 9 warnings in 2.40s + +DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rx --runxfail tests/test_runtime_behavior_regressions.py -k negative_web +# 2 failed (IndexError), 1 passed, 13 deselected, 1 warning in 1.61s +``` + +The same 30 database tests shuffled with `random.Random(42)` before editing +reported **9 failed, 21 passed**, 9 warnings in 2.37s. Exact command: + +```sh +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 before-seed42 tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py +``` + +## Isolation correction and regression evidence + +- `tests/helpers/database.py` owns a fresh TemporaryDirectory, file-backed + SQLite engine with NullPool, real schema, and factory for each invocation. + Teardown disposes the engine and removes the directory including sidecars. +- The truncation fixture temporarily patches only resource bindings in + `core.database`, `src.database`, and `core.session_manager`, with a matching + temporary environment URL. Patches are undone before resource disposal. + No module reload, module eviction, new ORM class, or listener registration. +- Four owner modules now own a disposable database per test rather than at + collection. Only the task-owner module patches the canonical factory; that + patch is scoped to the test and restored during teardown. The other modules + retain their existing handler/route wiring and ownership assertions. +- The existing pytest bootstrap always chooses in-memory storage before + production database imports and registers environment restoration with the + pytest configuration lifecycle. No new process-wide autouse cleanup fixture. +- `tests/test_database_test_isolation.py` checks real persistent truncation, + exact class/factory restoration, file removal, clean subsequent database + state, and exception teardown. Child pytest runs execute the actual original + witness entry points in both directions and check process-visible bindings + after collection and after every test. An inherited developer-DB sentinel + remains byte-identical, with no database sidecars created, and the inherited + environment URL is restored after pytest exits. + +Mutation checks temporarily restored one original file at a time, ran +`test_actual_tests_restore_process_state_and_ignore_inherited_database[True]`, +and restored the corrected file in `finally`. Both mutations made the new +regression fail (pytest exit 1): original truncation was caught as +"test leaked database or module state", original task-owner collection as +"collection changed database bindings". No mutation was retained. + +## Historical negative-web witnesses and production blocker + +Both parameter cases in +`test_negative_web_wording_withholds_the_web_tools_unhandled` intended to prove +that inferred negative-web wording can receive a memory-only answer without +`web_search` or `web_fetch` being offered to the model: + +1. `Summarise what you already know. Do not search the web.` +2. `No web search please, just tell me what you know about Python decorators.` + +Both previously indexed an empty fake-model call list and xfailed on unrelated +`IndexError`. The fake also emitted provider-shaped `choices` JSON at a seam +that now consumes normalized `{"delta": ...}` SSE. + +The repaired witnesses drive the unmodified public `stream_agent_loop`, mock +only model output and existing infrastructure edges, capture the actual SSE, +and require one model call, withheld web schemas, and delivered fake answer. +The existing passing "don't search online" control and positive search +control remain. The fake now emits normalized SSE. + +The two historical xfails remain **strict**, with +`raises=_MemoryOnlyTurnBlocked`. That exception is emitted only when the real +loop makes zero model calls and returns the exact known disabled-web response. +IndexError, arbitrary assertions, or other exceptions no longer qualify. + +This is a genuine production defect outside the lane: negative-web detection +is true for both phrasings, but `_classify_agent_request` returns `{'web'}` and +`_web_search_unavailable_for_turn` returns true after the negative has disabled +web tools. The loop short-circuits the requested memory-only response with: + +> Web access is disabled for this turn. Enable web search and resend the request. + +The passing "Answer from memory only, don't search online." control has +`{'memory', 'web'}` intent domains and does not short-circuit. A minimized +reproduction using those three production functions requires neither a model +nor a mock. Relevant production locations (intentionally untouched): +`src/agent_loop.py` negative detector near line 864, classifier near 10371, +negative-tool disabling near 20735, unavailable calculation near 22233, and +short-circuit response near 25101. + +```sh +/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider --runxfail tests/test_runtime_behavior_regressions.py -k negative_web_wording_withholds_the_web_tools_unhandled +# 2 failed with _MemoryOnlyTurnBlocked, 14 deselected, 1 warning in 0.75s +``` + +No production repair was attempted. Runtime intent/availability behavior, +authority, completion/evidence, and Wave 4 effects files remain untouched. +This blocker prevents a READY release-foundation verdict despite passing test +isolation validation. + +## Final order validation + +The focused manifest has 50 cases: the five baseline database modules plus +`test_database_test_isolation.py` and `test_runtime_behavior_regressions.py`. +Normal collection order is alphabetical by the seven explicitly selected files. +Reversed and seeded runs reorder **individual collected test cases**, including +parameterized cases. Both contamination directions also vary file collection +order. Every execution manifest is retained as JSON in the evidence directory. + +| Run | Result | +| --- | --- | +| Normal | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s | +| Reverse of all collected cases | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.47s | +| Truncation module first, owner witnesses afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.00s | +| All four owner modules first, truncation afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s | +| Seed 1 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s | +| Seed 42 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s | +| Seed 2026 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.97s | + +Exact executed commands: + +```sh +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-normal tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py reverse after-reverse tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-truncation-first tests/test_truncate_message_count_regression.py tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-owners-first tests/test_checkin_digest_owner_scope.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_truncate_message_count_regression.py tests/test_database_test_isolation.py tests/test_runtime_behavior_regressions.py +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:1 after-seed1 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 after-seed42 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:2026 after-seed2026 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py +``` + +The order driver is test-session-local; it is not installed into conftest or +production and never changes the normal suite's ordering. Its exact source +(for recreating `/tmp/odysseus-wave6-isolation-evidence/run_order.py`) is: + +```python +import json +import os +from pathlib import Path +import random +import sys +import pytest + +out = Path('/tmp/odysseus-wave6-isolation-evidence') +mode, label, *modules = sys.argv[1:] +class Order: + @pytest.hookimpl(trylast=True) + def pytest_collection_modifyitems(self, items): + if mode == 'reverse': + items.reverse() + elif mode.startswith('seed:'): + random.Random(int(mode.split(':')[1])).shuffle(items) + (out / f'{label}-manifest.json').write_text(json.dumps([i.nodeid for i in items], indent=2) + '\n') +os.environ['DATABASE_URL'] = 'sqlite:///:memory:' +args = ['-q', '-p', 'no:cacheprovider', '-rsx', *modules] +(out / f'{label}-command.json').write_text(json.dumps({'argv':sys.argv,'pytest_args':args,'DATABASE_URL':os.environ['DATABASE_URL']},indent=2) + '\n') +raise SystemExit(pytest.main(args, plugins=[Order()])) +``` + +## Adjacent and complete validation + +The first complete run finished with **44 failed, 12509 passed, 62 skipped, +2 xfailed**, 184 warnings and 6 passing subtests in 391.85s. Its failure +inventory was 42 missing-Playwright-package failures and two generated +configuration-reference freshness failures. The local worktree had not had +`npm ci` run; the generator includes test-source line references, and the +bootstrap edit moved the existing references by one line. + +`npm ci --ignore-scripts` installed the four already-locked packages; package +and lock files were unchanged. Cached Chromium launched successfully +(version 151.0.7922.34). A bootstrap comment now explains why isolation must +precede collection and preserves the existing referenced line locations, +without editing the generated website or the generator. This follow-up is +commit `f67d0388fd805867a0920dbffd4da056b1361e38` +(`fix(tests): preserve bootstrap reference locations`). + +```sh +npm ci --ignore-scripts +# exit 0; 4 packages installed, lockfile and package policy unchanged + +python scripts/generate_env_reference.py --check +# exit 0; website/configuration-reference.md is up to date (112 variables) + +/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_module_api.py tests/test_env_reference.py +# 16 passed, 1 warning in 17.00s +``` + +The first full run and the 44-case focused rerun are retained separately from +the final complete run. The 44-case rerun reported **1 failed, 43 passed** +in 73.61s: the existing font/color browser witness observed its font-size +icon still showing `13` after its text had changed to size 5 (expected `24`). +An isolated reproduction of that unchanged test passed in 2.13s. This is an +observed intermittent browser UI witness; no database failure was involved, +and a production root cause has not been established. The test and editor +implementation remain unchanged, with both observations retained rather +than suppressing the failed run. No failure was hidden by skipping or deleting a test. + + +```sh +/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx tests/test_session_manager.py tests/test_session_manager_cleanup.py tests/test_session_manager_persist_guard.py tests/test_session_mutation_lookup.py tests/test_session_discovery_message_count.py tests/test_history_order_by_timestamp_regression.py tests/test_history_display_model_hydration.py tests/test_session_search.py tests/test_session_search_batch_fetch.py tests/test_session_list_owner_scope.py tests/test_session_owner_attribution.py tests/test_session_endpoint_owner_scope.py tests/test_session_concurrent.py tests/test_app_db_permissions.py tests/test_database_utcnow.py tests/test_tool_policy.py tests/test_turn_contract.py tests/test_chat_preprocess_tool_policy.py tests/test_chat_route_tool_policy.py tests/test_resend_message_nondestructive.py +# 1262 passed, 0 skipped, 0 xfailed, 7 warnings in 40.56s + +/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx +# 12556 passed, 59 skipped, 2 xfailed, 184 warnings, 6 subtests passed in 468.49s (0:07:48); exit 0 + +python -m compileall -q app.py core routes src services scripts tests +# exit 0 + +git diff --check +# exit 0, no output + +git diff --check b1666951faf8285054e1ca90f11533b0fb53fb57 +# exit 0, no output + +git diff --name-only --diff-filter=U +# exit 0, no output + +rg -n '^(<<<<<<< |=======$|>>>>>>> )' +# exit 1, no matches +``` + +No test was deleted, no skip was added, and no unrelated xfail was added. The +same two historical parameter cases remain xfailed for the demonstrated +production blocker, now restricted to that actual contract failure. + +The isolation commit is +`e2b847c84d27ce785565757e81ca5293896eaab3` +(`fix(tests): isolate database and module state`). The final bounded commit +contains the repaired web witnesses and this validation report. Final commit, +HEAD, TREE and clean status are supplied in the accompanying final report to +avoid placing a commit's own hash inside its committed contents. + +Full logs, commands, and per-run manifests from this session are in +`/tmp/odysseus-wave6-isolation-evidence/`. + +The follow-up normal focused run after preserving bootstrap reference +locations is recorded in `after-bootstrap-followup.log`. It reported **48 passed, 2 xfailed**, 11 warnings in 9.55s. Its exact command is: + +```sh +/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-bootstrap-followup tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py +``` + +The additional browser observation came from the 44-case manifest retained in +`full-failures-manifest.json`; every exact node ID in that diagnostic command is +retained in `full-failures-rerun-command.json`. The isolated diagnostic command: + +```sh +/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile +# 1 passed, 1 warning in 2.13s +``` + +## Independent review disposition + +A read-only review of isolation commit e2b847c8 raised two concerns, which +were checked against the frozen source rather than accepted as proven leaks: + +- `core.models._SESSION_MANAGER_INSTANCE`: `SessionManager.__init__` only + initializes its owned cache and loads metadata; it does not call the global + setter. `SessionManager.add_message` explicitly persists through its own + manager instead of calling `Session.add_message` / the singleton. Neither + truncation witness nor the helper invokes a singleton setter or API route + setup. Thus the asserted singleton mutation is absent from this lane's real + seam. Resetting that unrelated singleton would add cleanup without a proven + owned mutation and could hide a genuine leak elsewhere. +- Eager real-database import versus `clear_fake_database_modules`: the exact + pre-import block already exists on frozen b1666951. The clearing helper + intentionally leaves real modules with a string `__file__` intact and evicts + replacement fake modules lacking it. The correction neither adds that + pre-import nor changes fake-module identification. Its "real module" early + return is intended behavior, not a newly introduced cleanup conflict. + +Neither concern supplied a failing reproducer. Both were resolved by the +existing source paths and the actual collection/teardown identity regression; +no speculative global cleanup was introduced. + +The minimized production reproduction command was: + +```sh +DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python - <<'PYTHON' +from src.agent_loop import _explicitly_avoids_web_lookup, _classify_agent_request, _web_search_unavailable_for_turn +from src.tool_policy import WEB_TOOL_NAMES +for text in ( + "Answer from memory only, don't search online.", + 'Summarise what you already know. Do not search the web.', + 'No web search please, just tell me what you know about Python decorators.', +): + intent = _classify_agent_request([{'role': 'user', 'content': text}], text) + domains = set(intent['domains']) + print(repr(text)) + print('negative_web:', _explicitly_avoids_web_lookup(text)) + print('intent_domains:', sorted(domains)) + print('web_disabled_short_circuit:', _web_search_unavailable_for_turn(domains, set(WEB_TOOL_NAMES), text, None, None)) +PYTHON +``` + +Results: negative-web detection true for all three; intent domains respectively +`['memory', 'web']`, `['web']`, `['web']`; short-circuit false, true, true. + +## Final validation result and verdict + +The final complete run in `full-pytest-final.log` exited 0: +**12556 passed, 59 skipped, 2 xfailed**, 184 warnings and 6 passing subtests in +468.49s. All 12617 collected cases are accounted for. The existing optional, +platform-specific and opt-in skips remain; no skip marker was added. Installing +the already-locked Node dependencies enabled three previously skipped cases, +so the skip count decreased from 62 in the first run to 59. No tests were +deleted and no unrelated xfail was introduced. + +The font/color browser witness passed in the final complete run. Its failed +44-case diagnostic and passing isolated result remain documented as an +intermittent observation; no production root cause was established or fixed. + +Database/environment/module isolation validation is green. Both historical +negative-web cases remain narrowly expected failures for a reproduced genuine +runtime defect outside the test-isolation boundary. These are integration +blockers; production intent/short-circuit behavior must be corrected in an +appropriate lane before removing their strict markers. + +WAVE 6 TEST-ISOLATION FOUNDATION NOT READY diff --git a/tests/documentMobileExport.mjs b/tests/documentMobileExport.mjs new file mode 100644 index 000000000..a94424f18 --- /dev/null +++ b/tests/documentMobileExport.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ headless: true }); +try { + for (const width of [390, 900]) { + const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true }); + await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.setContent('
'); + await page.evaluate(async () => { + const mod = await import('/static/js/document.js?mobile-export-regression=1'); + mod.init('/api'); + mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' }); + }); + await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' }); + await page.evaluate(() => { + document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important'); + }); + await page.locator('#doc-footer-export-btn').click(); + const menu = page.locator('#doc-export-menu'); + await menu.waitFor({ state: 'visible' }); + const bounds = await menu.boundingBox(); + assert(bounds.x >= 0 && bounds.y >= 0); + assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844); + assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000)); + await page.screenshot({ path: `/tmp/document-export-${width}.png` }); + // A second click closes the menu, then reopening must still allow export. + await page.locator('#doc-footer-export-btn').click(); + assert.equal(await menu.count(), 0); + await page.locator('#doc-footer-export-btn').click(); + const pending = page.waitForEvent('download'); + await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click(); + const download = await pending; + assert(download.suggestedFilename().endsWith('.md')); + assert.equal(await download.failure(), null); + const stream = await download.createReadStream(); + let content = ''; + for await (const chunk of stream) content += chunk; + assert.equal(content, '# Mobile export\n\nActual document content.'); + await page.close(); + console.log(`PASS ${width}px: visible menu, toggle, download contents`); + } +} finally { + await browser.close(); +} diff --git a/tests/pdfAnnotationGestures.mjs b/tests/pdfAnnotationGestures.mjs new file mode 100644 index 000000000..c788ec76d --- /dev/null +++ b/tests/pdfAnnotationGestures.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; + +const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8'); +const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction')); +const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing')); +const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget'))); +const browser = await chromium.launch({ headless: true }); +try { + for (const touch of [true, false]) { + const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch }); + await page.setContent('
'); + await page.evaluate(({ build, state, placement }) => { + new Function(` + const activeDocId = 'test', docId = activeDocId; + const _pdfPaneAnnotationsByDoc = new Map([['test', []]]); + const annotationRefs = _pdfPaneAnnotationsByDoc.get('test'); + const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map(); + let _pdfDropMode = 'check', serial = 0; + const _newAnnotationId = () => 'new-' + (++serial); + const _pushPdfUndoSnapshot = () => {}; + const _schedulePdfPaneSave = () => {}; + const pageWrap = document.getElementById('page'); + const page = {page: 1}; + ${state} + ${build} + ${placement} + for (let i = 0; i < 2; i++) { + const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'}); + annotationRefs.push(ref); + ref._setHandlesVisible(true); + } + window.refs = annotationRefs; + `)(); + }, { build, state, placement }); + const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]'); + const box = await grip.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(300, 220); + assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none')))); + // Proximity updates must not bring controls back while dragging. + await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true))); + assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden()); + await page.mouse.up(); + assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2); + assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible()); + await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click(); + assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1); + await page.locator('#page').click({ position: { x: 100, y: 450 } }); + assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2); + console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`); + await page.close(); + } +} finally { + await browser.close(); +} diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 2857bc73f..dca572470 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -89,7 +89,7 @@ a status code. `scripts/odysseus-smoke --areas` prints the current list. | Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read | | Memory | a fact is listed, found by search, and gone after delete | | Uploads | an attachment reads back byte for byte | -| Cookbook | hardware is detected; a fresh install's recommendation request returns the explicit empty-catalog Rescan guidance; state persists | +| Cookbook | hardware is detected and recommendations come back sized against it; state persists | | Settings | a preference written on one session is still there after a new login | ## What is not covered, and why diff --git a/tests/test_action_intents_calendar.py b/tests/test_action_intents_calendar.py index dbb523440..1019acada 100644 --- a/tests/test_action_intents_calendar.py +++ b/tests/test_action_intents_calendar.py @@ -20,3 +20,4 @@ def test_terse_timed_calendar_create_promotes_to_calendar(): assert intent.needs_tools assert intent.category == "calendar" + diff --git a/tests/test_agent_evidence.py b/tests/test_agent_evidence.py index 22dda5dc8..cdc34b678 100644 --- a/tests/test_agent_evidence.py +++ b/tests/test_agent_evidence.py @@ -1,5 +1,7 @@ import json +import pytest + from src.agent_evidence import ( CompletionRequirements, CompletionStatus, @@ -934,3 +936,66 @@ def test_exhaustion_and_awaiting_user_are_non_completion_states(): ledger = EvidenceLedger() assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER + + +@pytest.mark.parametrize("tool", ["create_document", "update_document", "edit_document"]) +def test_authoritative_document_write_records_document_mutation_evidence(tool): + ledger = EvidenceLedger.from_tool_events([ + {"tool": tool, "command": "fix grammar", "exit_code": 0}, + ]) + + mutations = [event for event in ledger.events + if event.kind == EvidenceKind.DOCUMENT_MUTATION] + assert len(mutations) == 1 + assert mutations[0].authoritative and mutations[0].success + assert ledger._supports_document_mutation() is True + + +def test_non_authoritative_document_write_is_not_mutation_evidence(): + ledger = EvidenceLedger.from_tool_events([ + {"tool": "update_document", "command": "fix grammar"}, + ]) + + assert ledger._supports_document_mutation() is False + + +def test_successful_private_write_supports_mutation_claim_without_artifact(): + # manage_calendar:create_event has no filesystem artifact, so the completion + # verifier previously erased the model's "I created the meeting" claim. A + # successful authoritative owned-state write must now support it. + from src.agent_runtime.completion import completion_answer + + ledger = EvidenceLedger.from_tool_events([ + { + "round": 1, + "tool": "manage_calendar", + "command": json.dumps({ + "action": "create_event", + "title": "Meeting with Sam", + "start": "2026-10-16T15:00:00", + }), + "output": "Created event [Meeting with Sam]", + "exit_code": 0, + "execution_attempted": True, + }, + ]) + assert ledger._private_action_mutations == ["manage_calendar:create_event"] + + answer, _ = completion_answer( + "I created the meeting with Sam.", ledger, ledger.evaluate()) + assert "created the meeting with Sam" in answer + assert not answer.startswith("Unsupported execution claims") + + +def test_failed_private_write_does_not_support_mutation_claim(): + ledger = EvidenceLedger.from_tool_events([ + { + "round": 1, + "tool": "manage_calendar", + "command": json.dumps({"action": "create_event", "title": "x"}), + "output": "denied", + "exit_code": 1, + "execution_attempted": True, + }, + ]) + assert ledger._private_action_mutations == [] diff --git a/tests/test_ask_user_persistence.py b/tests/test_ask_user_persistence.py index 06c368905..ee597395c 100644 --- a/tests/test_ask_user_persistence.py +++ b/tests/test_ask_user_persistence.py @@ -87,6 +87,91 @@ def test_ask_user_is_emitted_last_and_persisted(monkeypatch): assert metrics["tool_events"][0]["ask_user"] == payload +def test_clean_preview_ask_user_ends_turn_without_second_round(monkeypatch): + """The compact ``stream_preview`` runtime must treat ask_user as a turn boundary. + + Regression for chat d8dca715: the preview fed the ask_user tool result + back to the model, so it burned a second provider round and persisted the + model's follow-up prose instead of the question. + """ + import src.clean_agent_preview as module + from src.tool_schemas import FUNCTION_TOOL_SCHEMAS + from src.tool_policy import ToolPolicy + from src.turn_contract import resolve_full_inventory_contract + + payload = { + "question": "What would be most useful to start with?", + "options": [ + {"label": "A 30-day meditation plan"}, + {"label": "How to measure brain activity"}, + ], + "multi": False, + } + requests = [] + responses = iter([ + {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'ask-1', + 'function': {'name': 'ask_user', + 'arguments': json.dumps(payload, ensure_ascii=False)}}]}}]}, + {'choices': [{'delta': {'content': 'SHOULD NOT BE REQUESTED'}}]}, + ]) + + class Response: + def __init__(self, body): self.body = body + 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(self.body) + 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): + requests.append(kwargs['json']) + return Response(next(responses)) + + async def execute(block, **kwargs): + parsed = json.loads(block.content) + return 'ask_user', { + 'ask_user': parsed, + 'output': 'Awaiting their selection.', + 'exit_code': 0, + } + + monkeypatch.setattr(module.httpx, 'AsyncClient', Client) + monkeypatch.setattr(module, 'execute_tool_block', execute) + + schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'ask_user') + contract = resolve_full_inventory_contract(schemas=[schema], policy=ToolPolicy()) + raw = _collect(module.stream_preview( + endpoint_url='http://test', model='test', headers={}, + messages=[{'role': 'user', 'content': 'Help me pick a project.'}], + turn_contract=contract, session_id='test', owner='test', + disabled_tools=set(), tool_policy=ToolPolicy(), + )) + events = _events(raw) + + # Ending the turn means no second provider round is requested. + assert len(requests) == 1 + ask_user_events = [event for event in events if event.get('type') == 'ask_user'] + assert len(ask_user_events) == 1 + assert ask_user_events[0]['data'] == payload + # The saved assistant text is the question, not the model's follow-up prose. + final = next(event for event in events if event.get('type') == 'final_response') + assert final['content'] == payload['question'] + types = [event.get('type') for event in events] + assert types.index('ask_user') < types.index('metrics') + # Reconstructable from persisted metadata, and no phantom extra rounds. + metrics = next(event['data'] for event in events if event.get('type') == 'metrics') + assert metrics['tool_events'][0]['ask_user'] == payload + assert metrics['agent_rounds'] == 1 + assert metrics['clean_v3_turn'][-1] == { + 'role': 'assistant', 'content': payload['question'], + } + + def test_frontend_uses_one_renderer_for_live_and_restored_cards(): chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8") renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8") diff --git a/tests/test_auth_root_path.py b/tests/test_auth_root_path.py index 6e05a7c98..38b0becf5 100644 --- a/tests/test_auth_root_path.py +++ b/tests/test_auth_root_path.py @@ -60,6 +60,7 @@ def test_login_page_static_assets_resolve_under_mount_path(): mounted_login_url = "https://example.test/odysseus/login" for relative_asset, mounted_path in ( ("static/manifest.json", "/odysseus/static/manifest.json"), + ("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"), ( "static/fonts/FiraCode-Regular.woff2", "/odysseus/static/fonts/FiraCode-Regular.woff2", 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_authority_spec_surface.py b/tests/test_authority_spec_surface.py new file mode 100644 index 000000000..b5b62293c --- /dev/null +++ b/tests/test_authority_spec_surface.py @@ -0,0 +1,42 @@ +"""Spec-surface admission: the strict router picks the tools after authority is +built, so spec turns admit the router's families; compact/full stay unchanged.""" +import json + +from src.agent_runtime.authority import create_request_authority, ExactOperation + + +def _permits(auth, tool, args=None): + return auth.permits(ExactOperation.normalize(tool, json.dumps(args or {}))) + + +def _auth(text, **kw): + return create_request_authority(text, owner="pewds", session_id="s1", workspace="", **kw) + + +def test_spec_surface_admits_a_router_chosen_family_tool(): + auth = _auth("Search for the appointment email", spec_surface=True) + assert _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"}) + + +def test_non_spec_turn_does_not_admit_the_same_call(): + auth = _auth("Search for the appointment email") + assert not _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"}) + + +def test_spec_surface_admits_browser_interaction_and_batch(): + auth = _auth("do the thing", spec_surface=True) + assert _permits(auth, "private_browser", {"action": "batch"}) + assert _permits(auth, "private_browser", {"action": "click", "selector": "#go"}) + assert _permits(auth, "private_browser", {"action": "fill", "selector": "#q", "text": "x"}) + + +def test_batch_is_read_but_click_stays_verb_gated_off_spec(): + auth = _auth("search the web for the appointment") + assert _permits(auth, "private_browser", {"action": "batch"}) + assert not _permits(auth, "private_browser", {"action": "click", "selector": "#go"}) + + +def test_spec_surface_keeps_the_exact_read_input_seal(): + auth = _auth("Read note id abc123", spec_surface=True) + assert _permits(auth, "manage_notes", {"action": "view", "id": "abc123"}) + assert not _permits(auth, "manage_notes", {"action": "view", "id": "zzz999"}) diff --git a/tests/test_browser_observation.py b/tests/test_browser_observation.py index d84bbfdcc..9c486941b 100644 --- a/tests/test_browser_observation.py +++ b/tests/test_browser_observation.py @@ -1,8 +1,28 @@ import json +import pytest from src.clean_agent_preview import preview_tool_result_text +@pytest.mark.parametrize('snapshot', [None, '', ' ']) +def test_text_and_title_survive_without_snapshot(snapshot): + result = {'url': 'https://example.org', 'title': 'Access denied', + 'text': 'Unusual traffic. Complete the CAPTCHA.', 'snapshot': snapshot} + output = preview_tool_result_text({'output': json.dumps(result)}, 'private_browser', {}) + assert 'Access denied' in output + assert 'Unusual traffic. Complete the CAPTCHA.' in output + from src.clean_agent_preview import browser_observation_access_blocked + assert browser_observation_access_blocked(output) + + +def test_snapshot_takes_precedence_over_duplicate_page_text(): + output = preview_tool_result_text({'output': json.dumps({ + 'snapshot': '- link "Report" [ref=e1]', 'text': 'Duplicated page body', + })}, 'private_browser', {}) + assert '[ref=e1]' in output + assert 'Duplicated page body' not in output + + def test_legacy_page_text_retains_access_block_evidence(): result = {'url': 'https://example.org', 'title': 'Security verification', 'text': 'Unusual traffic. Complete the CAPTCHA.'} diff --git a/tests/test_chat_route_tool_policy.py b/tests/test_chat_route_tool_policy.py index efb955ef2..e30df2b5a 100644 --- a/tests/test_chat_route_tool_policy.py +++ b/tests/test_chat_route_tool_policy.py @@ -231,6 +231,25 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles(): ) +def test_request_authority_grants_web_when_turn_offers_web_tools(): + """Web tools offered for a turn must carry request authority too. + + Regression: the authority capabilities gate only recognized explicit + browser wording (``_explicit_browser_intent``/``_external_discovery_intent``), + so an implicit research question with the Web toggle on offered + ``web_search`` in the model's schema but rejected every call with + ``request_authority_denied``. The gate must also grant ``search_browser`` + whenever web tools are not disabled for the turn. + """ + source = _CHAT_ROUTES.read_text(encoding="utf-8") + assert "({'search_browser'} if (" in source, ( + "request authority must be built with a search_browser capabilities gate" + ) + assert "or \"web_search\" not in disabled_tools" in source, ( + "authority must grant search_browser whenever web_search is offered" + ) + + def test_explicit_private_browser_workflow_survives_web_search_disabled(): source = _CHAT_ROUTES.read_text(encoding="utf-8") assert "_explicit_private_browser_intent" in source @@ -748,3 +767,21 @@ def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode(): assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, ( "Frontend must send explicit allow_web_search=false in agent mode when toggle is off" ) + + +def test_spec_surface_skips_the_contract_scope_tool_denial(): + """On the strict-router (spec) surface the regex turn contract must NOT + scope disabled_tools, or the spec router menu (email/notes/...) is emptied + before src/spec_router.py runs. Compact/full/none keep the denial.""" + source = _CHAT_ROUTES.read_text(encoding="utf-8") + assert "if not _spec_surface:" in source, ( + "the contract-scope disabled_tools.update must be guarded for spec turns" + ) + idx = source.index("if not _spec_surface:") + segment = source[idx:idx + 500] + assert "disabled_tools.update(" in segment, ( + "the contract-scope denial must live inside the spec guard" + ) + assert "if not _turn_contract.permits(" in segment, ( + "the guarded block must be the contract-scope denial" + ) diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index 2526562b3..29301c20f 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -109,12 +109,37 @@ def test_deepseek_flash_keeps_tools_but_drops_unsupported_forced_choice(): } compatible = provider_compatible_tool_choice_request(request, 'deepseek-flash') - assert [tool['function']['name'] for tool in compatible['tools']] == ['inspect_media'] + # A forced named choice must not hide the other offered tools. + assert [tool['function']['name'] for tool in compatible['tools']] == [ + 'inspect_media', 'python' + ] assert 'tool_choice' not in compatible assert request['tool_choice']['function']['name'] == 'inspect_media' assert provider_compatible_tool_choice_request(request, 'qwen3.5-9b') is request +def test_deepseek_forced_read_does_not_hide_other_offered_tools(): + # Regression: a required calendar read forced tool_choice=manage_calendar on + # a turn that also offered the email tools; the DeepSeek compatibility path + # used to shrink `tools` to manage_calendar only, so the model reported the + # email tools the user asked for were unavailable. + request = { + 'model': 'deepseek-v4-flash', + 'tools': [ + {'type': 'function', 'function': {'name': 'manage_calendar'}}, + {'type': 'function', 'function': {'name': 'mcp__email__search_emails'}}, + {'type': 'function', 'function': {'name': 'mcp__email__read_email'}}, + ], + 'tool_choice': {'type': 'function', 'function': {'name': 'manage_calendar'}}, + } + compatible = provider_compatible_tool_choice_request(request, 'deepseek-v4-flash') + names = [tool['function']['name'] for tool in compatible['tools']] + assert names == [ + 'manage_calendar', 'mcp__email__search_emails', 'mcp__email__read_email' + ] + assert 'tool_choice' not in compatible + + def test_deepseek_v4_flash_drops_named_choice_for_thinking_mode(): request = { 'tools': [{'type': 'function', 'function': {'name': 'write_file'}}], @@ -136,8 +161,10 @@ def test_kimi_k3_drops_named_choice_incompatible_with_thinking_mode(): compatible = provider_compatible_tool_choice_request(request, 'kimi-k3') + # The full tool surface is preserved; only the unsupported forced choice + # is dropped. assert [tool['function']['name'] for tool in compatible['tools']] == [ - 'inspect_media' + 'inspect_media', 'write_file' ] assert 'tool_choice' not in compatible @@ -210,8 +237,8 @@ def test_private_browser_covered_click_does_not_advance_dom_revision(): def test_compact_notes_preserves_create_vs_edit_and_replacement_semantics(): schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_notes') compact = compact_schemas([schema])[0]['function'] - assert 'add creates a new note' in compact['description'] - assert 'update with id' in compact['description'] + assert 'ONE add call' in compact['description'] + assert 'update with id edits an existing note, never add' in compact['description'] assert 'replaces the whole checklist' in compact['parameters']['properties']['checklist_items']['description'] @@ -3511,6 +3538,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 +3568,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}, @@ -4366,21 +4400,20 @@ def test_v3_schema_uses_configured_versioned_contract_root(tmp_path, monkeypatch (contract_root / 'eval_alltools_unseen_compare.py').write_text( "def tools_for_mode(tools, mode):\n" " assert mode == 'compact_contract_v5'\n" - " tools[0]['function']['description'] = 'versioned-contract-loaded'\n" + " tools[0]['function']['parameters']['properties']['action']['description'] = 'versioned-contract-loaded'\n" " return tools\n", encoding='utf-8', ) monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root)) module.contract_builder.cache_clear() try: - # Probe a tool whose compact description the harness does not - # replace; manage_notes now carries a full harness-owned override. - search = next( + notes = next( s for s in FUNCTION_TOOL_SCHEMAS - if s['function']['name'] == 'web_search' + if s['function']['name'] == 'manage_notes' ) - compact = module.compact_schemas([search])[0] - assert compact['function']['description'].startswith('versioned-contract-loaded') + compact = module.compact_schemas([notes])[0] + assert compact['function']['parameters']['properties']['action']['description'] == 'versioned-contract-loaded' + assert 'update with id edits an existing note, never add' in compact['function']['description'] finally: module.contract_builder.cache_clear() @@ -4392,6 +4425,7 @@ def test_v3_document_edit_schema_has_one_unambiguous_structured_form(): assert parameters['required'] == ['edits'] assert set(parameters['properties']) == {'edits', 'more'} assert parameters['properties']['more']['type'] == 'boolean' + assert 'more' not in parameters['required'] def test_v3_ui_schema_advertises_only_policy_executable_client_local_actions(): @@ -4526,7 +4560,7 @@ async def test_stream_emits_incremental_text_and_persistable_history(monkeypatch raw = [chunk async for chunk in stream_preview(endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'hi'}], headers={}, turn_contract=contract, session_id='test', owner='test', disabled_tools=set(), tool_policy=ToolPolicy())] events = [json.loads(s[6:]) for s in raw if '[DONE]' not in s] assert [e['delta'] for e in events if 'delta' in e] == ['Hello', ' there'] - assert requests[0]['messages'][0]['content'].startswith('You are Odysseus.') + assert requests[0]['messages'][0]['content'].startswith('You are Ajax, the assistant in Odysseus.') metrics = next(e['data'] for e in events if e.get('type') == 'metrics') assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}] assert raw[-1] == 'data: [DONE]\n\n' @@ -4562,9 +4596,7 @@ async def test_preview_provider_stream_error_is_terminal_not_empty_answer(monkey disabled_tools=set(), tool_policy=ToolPolicy(), )] assert raw[-1].startswith('event: error\ndata: ') - assert 'selected model provider failed' in raw[-1] - assert 'provider_stream_error' in raw[-1] - assert 'Qwen3_5MTPDraftModel' not in raw[-1] + assert 'Qwen3_5MTPDraftModel' in raw[-1] assert all('returned no answer' not in chunk for chunk in raw) assert all('"type": "metrics"' not in chunk for chunk in raw) @@ -4664,6 +4696,74 @@ def test_ajax_c375_tool_surface_disables_progressive_thinking(): ) +@pytest.mark.parametrize('name,args,reason', [ + ('python', {}, 'code'), + ('apply_patch', {}, 'code'), + ('edit_document', {}, 'document_edit'), + ('draft_email_reply', {}, 'email_draft'), + ('manage_tasks', {'action': 'create'}, 'task_design'), + ('manage_skills', {'action': 'publish'}, 'skill_evaluation'), + ('manage_tasks', {'action': 'list'}, ''), + ('manage_tasks', {'action': 'pause'}, ''), + ('manage_skills', {'action': 'view'}, ''), + ('read_file', {}, ''), + ('web_search', {}, ''), + ('manage_notes', {'action': 'add'}, ''), +]) +def test_tool_deliberation_is_operation_specific(name, args, reason): + from src.clean_agent_preview import tool_deliberation_reason, progressive_thinking_for_turn + assert tool_deliberation_reason(name, args) == reason + schemas = [{'function': {'name': name}}] + assert progressive_thinking_for_turn('ajax_c375', schemas, deliberate=bool(reason)) == bool(reason) + assert not progressive_thinking_for_turn('other-model', schemas, deliberate=True) + + +@pytest.mark.asyncio +async def test_task_creation_deliberates_before_executing_once(monkeypatch): + import src.clean_agent_preview as module + requests, executions = [], [] + arguments = {'action': 'create', 'name': 'Morning news', 'prompt': 'Summarize news', 'schedule': 'daily', 'time': '09:00'} + proposal = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'task-call', + 'function': {'name': 'manage_tasks', 'arguments': json.dumps(arguments)}}]}}]} + responses = iter([proposal, proposal, {'choices': [{'delta': {'content': 'Created the task.'}}]}]) + + class Response: + def __init__(self, payload): self.payload = payload + 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(self.payload) + 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): + requests.append(kwargs['json']) + return Response(next(responses)) + + async def execute(block, **kwargs): + executions.append(block) + return 'manage_tasks', {'output': 'Created task task-1', 'exit_code': 0} + + monkeypatch.setattr(module.httpx, 'AsyncClient', Client) + monkeypatch.setattr(module, 'execute_tool_block', execute) + schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_tasks'] + contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy()) + raw = [chunk async for chunk in stream_preview( + endpoint_url='http://test', model='ajax_c375', + messages=[{'role': 'user', 'content': 'Create a daily task at 9am to summarize news.'}], + headers={}, turn_contract=contract, session_id='test', owner='test', + disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, + )] + assert len(executions) == 1, '\n'.join(raw) + assert [r['chat_template_kwargs']['enable_thinking'] for r in requests] == [False, True, False] + assert not any(m.get('tool_calls') for m in requests[1]['messages']) + assert not any('event: error' in chunk for chunk in raw) + + @pytest.mark.asyncio async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch): import src.clean_agent_preview as module @@ -5427,7 +5527,9 @@ async def test_model_choice_budget_preserves_evidence_for_final_answer_without_e @pytest.mark.asyncio -async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch): +@pytest.mark.parametrize('thinking_mode', ['auto', 'off', 'on']) +@pytest.mark.parametrize('token_limit', [None, 1024]) +async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch, thinking_mode, token_limit): from dataclasses import replace import src.clean_agent_preview as module @@ -5489,13 +5591,17 @@ async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkey routing_experiment='recent_model_choice', ) raw = [chunk async for chunk in stream_preview( - endpoint_url='http://test', model='test', + endpoint_url='http://test', model='ajax_c375', thinking_mode=thinking_mode, messages=[{'role': 'user', 'content': 'Tell me more about that Reuters report.'}], headers={}, turn_contract=contract, session_id='test', owner='test', - disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, + disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, max_tokens=token_limit, )] assert executions == ['web_fetch', 'private_browser', 'web_fetch'] + thinking = [r['chat_template_kwargs']['enable_thinking'] for r in requests] + # Compact owns the protocol, including for old sessions with On/Off saved. + assert thinking == [False, True, True, False] + assert requests[1]['max_tokens'] == (token_limit or 4096) assert all( schema['function']['name'] != 'web_fetch' for schema in requests[1].get('tools', []) @@ -5572,7 +5678,8 @@ async def test_duplicate_fetch_does_not_hide_distinct_pagination_request(monkeyp @pytest.mark.asyncio -async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch): +@pytest.mark.parametrize('empty_challenge', [False, True]) +async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch, empty_challenge): import src.clean_agent_preview as module responses = iter([ @@ -5594,10 +5701,8 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc }, }]}}]}, {'choices': [{'delta': {'content': ( - 'Recent AI developments include new model releases and policy updates. ' - 'The search results identify the relevant primary sources and dates for each item, ' - 'including publication details, concrete findings, and links readers can inspect ' - 'for the full context behind each development.' + 'I found the [AI update](https://example.com/ai) page. ' + 'The browser search was blocked, so I used native search to locate it.' )}}]}, ]) requests = [] @@ -5626,6 +5731,9 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc if block.tool_type == 'private_browser': return 'private_browser', { 'output': json.dumps({ + 'url': 'https://www.google.com/sorry/index?continue=search', + 'title': 'Client Challenge', 'snapshot': '(empty page)', + } if empty_challenge else { 'url': 'https://www.google.com/sorry/index?continue=search', 'text': 'Our systems have detected unusual traffic. Complete the reCAPTCHA.', }), @@ -5651,7 +5759,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc ) raw = [chunk async for chunk in stream_preview( endpoint_url='http://test', model='test', - messages=[{'role': 'user', 'content': 'Open browser and find an AI model release.'}], + messages=[{'role': 'user', 'content': 'Open browser and find the AI update page on example.com. Give me its link.'}], headers={}, turn_contract=contract, session_id='test', owner='test', disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, )] @@ -5659,6 +5767,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc assert executions == ['private_browser', 'web_search'] assert requests[1]['tool_choice'] == 'required' assert [s['function']['name'] for s in requests[1]['tools']] == ['web_search'] + assert not any(chunk.startswith('event: error') for chunk in raw), raw events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk] assert any( event.get('type') == 'tool_loop_recovery' @@ -5721,9 +5830,10 @@ async def test_native_stream_reserves_remaining_budget_for_required_artifact(mon async def execute(block, **kwargs): executed.append(block.tool_type) - return block.tool_type, {"output": "ok", "exit_code": 0, - "materialized_artifacts": ["/tmp_workspace/results"] - if block.tool_type == "python" and "out.md" in block.content else []} + if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content: + return block.tool_type, {'output': 'ok', 'exit_code': 0, + 'materialized_artifacts': ['/tmp_workspace/results']} + return block.tool_type, {"output": "ok", "exit_code": 0} monkeypatch.setattr(module.httpx, "AsyncClient", Client) monkeypatch.setattr(module, "execute_tool_block", execute) @@ -5835,9 +5945,10 @@ async def test_native_stream_reserves_wall_time_for_required_artifact(monkeypatc executed.append(block.tool_type) if block.tool_type == "web_search": now[0] = 450.0 - return block.tool_type, {"output": "ok", "exit_code": 0, - "materialized_artifacts": ["/tmp_workspace/results"] - if block.tool_type == "python" and "out.md" in block.content else []} + if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content: + return block.tool_type, {'output': 'ok', 'exit_code': 0, + 'materialized_artifacts': ['/tmp_workspace/results']} + return block.tool_type, {"output": "ok", "exit_code": 0} monkeypatch.setattr(module.time, "monotonic", lambda: now[0]) monkeypatch.setattr(module.httpx, "AsyncClient", Client) @@ -6429,14 +6540,15 @@ async def test_native_stream_permanently_withholds_tool_when_model_ignores_dupli @pytest.mark.asyncio -async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch): +@pytest.mark.parametrize('ignores_final_instruction', [False, True]) +async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch, ignores_final_instruction): import src.clean_agent_preview as module arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"}) responses = iter([ {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": { "name": "inspect_media", "arguments": arguments, }}]}}]} - for index in range(1, 4) + for index in range(1, 5 if ignores_final_instruction else 4) ] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}]) class Response: @@ -6488,9 +6600,15 @@ async def test_native_stream_terminates_after_calling_a_permanently_suppressed_t assert 'tools' not in requests[3] assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower() final = [event for event in events if event.get("type") == "final_response"] - assert final == [] metrics = next(event['data'] for event in events if event.get('type') == 'metrics') - assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.' + if ignores_final_instruction: + assert len(final) == 1 + assert 'model repeated a tool call' in final[0]['content'] + assert metrics['clean_v3_turn'][-1]['content'] == final[0]['content'] + else: + assert final == [] + assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.' + assert raw[-1] == 'data: [DONE]\n\n' @pytest.mark.asyncio @@ -7284,3 +7402,275 @@ 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.', ] + + +@pytest.mark.asyncio +async def test_denied_call_after_successful_evidence_recovers_instead_of_refusing(monkeypatch): + """A blocked call must not discard evidence an earlier call already produced.""" + import src.clean_agent_preview as module + + def call(index, name, arguments): + return {'index': index, 'id': f'call-{index}', 'function': { + 'name': name, 'arguments': json.dumps(arguments), + }} + + packets = iter([ + {'choices': [{'delta': {'tool_calls': [ + call(0, 'web_search', {'query': 'EEG headset prices'})]}}]}, + {'choices': [{'delta': {'tool_calls': [ + call(0, 'web_fetch', {'url': 'https://vendor.example/eeg'})]}}]}, + {'choices': [{'delta': {'content': ( + 'Based on the search result, the EEG headset starts at $299 from the ' + 'vendor. I could not open the vendor page because that operation was ' + 'not permitted.' + )}}]}, + ]) + requests = [] + + class Response: + def __init__(self, payload): self.payload = payload + 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(self.payload) + 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): + requests.append(kwargs['json']) + return Response(next(packets)) + + async def execute(block, **kwargs): + if block.tool_type == 'web_search': + return 'web_search', { + 'output': '[1] EEG Headset Prices\n https://vendor.example/eeg', + 'exit_code': 0, + 'evidence_status': 'available', + } + return 'web_fetch', { + 'output': 'The exact operation is outside server request authority.', + 'exit_code': 1, + 'blocked': True, + 'failure_kind': 'request_authority_denied', + } + + monkeypatch.setattr(module.httpx, 'AsyncClient', Client) + monkeypatch.setattr(module, 'execute_tool_block', execute) + schemas = [ + schema for schema in FUNCTION_TOOL_SCHEMAS + if schema['function']['name'] in {'web_search', 'web_fetch'} + ] + contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy()) + raw = [chunk async for chunk in stream_preview( + endpoint_url='http://test', model='test', + messages=[{'role': 'user', 'content': ( + 'Search the web for the latest EEG headset prices and open the vendor page.' + )}], + headers={}, turn_contract=contract, session_id='test', owner='test', + disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=5, + )] + events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk] + assert any( + event.get('type') == 'completion_recovery' + and event.get('reason') == 'denial_after_successful_evidence' + for event in events + ) + visible = ''.join( + str(e.get('delta') or e.get('content') or '') for e in events + ) + assert '$299' in visible + assert denied_response() not in visible + # The recovery round must synthesize without tools. + assert requests[-1].get('tools') in (None, []) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('thinking_mode, expected_thinking', [ + ('on', {'type': 'enabled'}), + (None, {'type': 'enabled'}), + ('off', {'type': 'disabled'}), +]) +async def test_deepseek_v4_preview_reserves_reasoning_budget_and_honors_toggle( + monkeypatch, thinking_mode, expected_thinking, +): + import src.clean_agent_preview as module + + requests = [] + + class Response: + def __init__(self, payload): self.payload = payload + 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(self.payload) + 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): + requests.append(kwargs['json']) + return Response({'choices': [{'delta': { + 'reasoning_content': 'Let me reason about this.', + 'content': 'The answer is 42.', + }}]}) + + monkeypatch.setattr(module.httpx, 'AsyncClient', Client) + contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy()) + kwargs = {} if thinking_mode is None else {'thinking_mode': thinking_mode} + raw = [chunk async for chunk in stream_preview( + endpoint_url='https://api.deepseek.com/v1/chat/completions', + model='deepseek-v4-flash', + messages=[{'role': 'user', 'content': 'What is the answer?'}], + headers={}, turn_contract=contract, session_id='test', owner='test', + disabled_tools=set(), tool_policy=ToolPolicy(), **kwargs, + )] + + assert requests, 'a provider request should have been sent' + sent = requests[0] + if expected_thinking == {'type': 'disabled'}: + assert sent.get('reasoning_effort') == 'none' + else: + # Reasoning-capable models must reserve budget above the 768 default. + assert sent['max_tokens'] >= 4096 + assert sent.get('reasoning_effort') + assert sent.get('thinking') == expected_thinking + assert any('The answer is 42.' in chunk for chunk in raw) + + +def test_workspace_process_denial_uses_an_honest_refusal(tmp_path): + from src.clean_agent_preview import ( + workspace_process_denial, + WORKSPACE_REQUIRED_REFUSAL, + denied_response, + ) + # A resource-identity / sealed-workspace denial is recognised from any of + # the tool-event fields (error / output JSON / failure_kind). + assert workspace_process_denial([ + {'tool': 'bash', 'exit_code': 1, 'blocked': True, + 'output': '{"error":"Process creation requires a sealed workspace and launch scope",' + '"failure_kind":"resource_identity_denied"}'}, + ]) + assert workspace_process_denial([{'failure_kind': 'resource_identity_denied'}]) + # A plain request-authority denial is not a workspace denial. + assert not workspace_process_denial([ + {'tool': 'bash', 'blocked': True, 'failure_kind': 'request_authority_denied', + 'error': 'The exact operation is outside server request authority.'}, + ]) + assert not workspace_process_denial([]) + assert 'workspace' in WORKSPACE_REQUIRED_REFUSAL.lower() + assert WORKSPACE_REQUIRED_REFUSAL != denied_response() + # The terminal-denial branch picks the workspace message when appropriate. + source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text() + assert 'if workspace_process_denial(executions)' in source + assert 'WORKSPACE_REQUIRED_REFUSAL' in source + + +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_qwen_think_split_streams_thinking_then_answer_without_buffering(): + from src.clean_agent_preview import qwen_think_split_events + + state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False} + events = [] + content = '' + for piece in ['', 'abc', '', 'Hello']: + content += piece + events.extend(qwen_think_split_events(content, state)) + + thinking = ''.join(e['delta'] for e in events if e.get('thinking')) + answer = ''.join(e['delta'] for e in events if not e.get('thinking')) + assert thinking == 'abc' + assert answer == 'Hello' + # every emitted delta is non-empty and typed + assert all(e.get('delta') for e in events) + assert [e.get('thinking') for e in events] == [True, None] + + # the stored answer stays identical to visible_content_after_qwen_thinking + from src.clean_agent_preview import visible_content_after_qwen_thinking + assert visible_content_after_qwen_thinking(content) == 'Hello' + + +def test_qwen_think_split_holds_back_a_partial_close_tag(): + from src.clean_agent_preview import qwen_think_split_events + + state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False} + events = [] + content = '' + for piece in ['a', '', 'B']: + content += piece + events.extend(qwen_think_split_events(content, state)) + + thinking = ''.join(e['delta'] for e in events if e.get('thinking')) + answer = ''.join(e['delta'] for e in events if not e.get('thinking')) + assert thinking == 'a' # the partial ' 'no matching server request authority' failure.""" + import core.database as database + import routes.prefs_routes as preferences + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from src.task_scheduler import TaskScheduler + engine = create_engine(f"sqlite:///{tmp_path / 'tasks.db'}") + database.Base.metadata.create_all(engine) + sessions = sessionmaker(bind=engine) + monkeypatch.setattr(database, "SessionLocal", sessions) + monkeypatch.setattr(preferences, "_load_for_user", lambda owner: {}) + scheduler = TaskScheduler(session_manager=None) + monkeypatch.setattr(scheduler, "ensure_assistant_defaults", AsyncMock()) + with sessions() as db: + db.add(database.ScheduledTask(id="broken", owner="alice", name="Email Tags", + task_type="action", action="check_email_urgency", + status="active", request_authority_json=None)) + db.commit() + await scheduler.ensure_defaults("alice") + with sessions() as db: + rows = db.query(database.ScheduledTask).filter( + database.ScheduledTask.action == "check_email_urgency").all() + assert len(rows) == 1, "the unsealed row must be replaced, not duplicated" + task = rows[0] + assert task.id != "broken" + assert task.status == "active", "the user's enable choice must survive the reseed" + grant = restore_task_authority(task.request_authority_json, task.prompt, + task.task_type, task.action, owner=task.owner) + assert grant.permits(task_operation(task.task_type, task.action, task.prompt)) + engine.dispose() + + @pytest.mark.asyncio async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch): from src import tool_execution as execution diff --git a/tests/test_required_read_operation.py b/tests/test_required_read_operation.py index 15d181f5e..51efab065 100644 --- a/tests/test_required_read_operation.py +++ b/tests/test_required_read_operation.py @@ -139,7 +139,7 @@ def run_terminal_branch(monkeypatch, native_calls, result, operation=None, *, ma _required_read_native_id=al._required_read_native_id, _dispatch_required_safe_read=al._dispatch_required_safe_read, _compute_final_metrics=lambda *a, **kw: {"tool_events": a[8]}, - session_id="fixture", disabled_tools=set(), _caller_hard_denials=frozenset(), tool_policy=policy, + session_id="fixture", disabled_tools=set(), tool_policy=policy, owner="fixture", workspace=None, run_security=context, active_document=None, client_runtime_context=None, round_num=1, model="fixture-model", actual_endpoint_id="fixture-endpoint", actual_endpoint_label="fixture", diff --git a/tests/test_runtime_evidence_contract.py b/tests/test_runtime_evidence_contract.py index c7760e723..4f7fc3628 100644 --- a/tests/test_runtime_evidence_contract.py +++ b/tests/test_runtime_evidence_contract.py @@ -549,3 +549,34 @@ def test_host_shell_native_arguments_use_the_same_verifier_classification(comman encoded = json.dumps({'command': command, 'timeout': 120}) assert command_is_test(encoded) is expected assert command_is_validation(encoded) is expected + + +@pytest.mark.parametrize('tool', ['create_document', 'update_document', 'edit_document']) +def test_successful_document_write_supports_unnamed_mutation_claim(tool): + ledger = EvidenceLedger.from_tool_events([ + {'tool': tool, 'command': 'fix grammar', 'exit_code': 0}, + ]) + claim = 'I updated the document with the grammar fix.' + answer, reason = completion_answer(claim, ledger, ledger.evaluate()) + assert claim in answer + assert not reason + + +def test_document_write_without_authoritative_receipt_cannot_support_claim(): + ledger = EvidenceLedger.from_tool_events([ + {'tool': 'update_document', 'command': 'fix grammar'}, + ]) + claim = 'I updated the document with the grammar fix.' + answer, reason = completion_answer(claim, ledger, ledger.evaluate()) + assert reason + assert claim not in answer + + +def test_document_evidence_does_not_support_named_file_claim(): + ledger = EvidenceLedger.from_tool_events([ + {'tool': 'update_document', 'command': 'fix grammar', 'exit_code': 0}, + ]) + claim = 'I updated config.py.' + answer, reason = completion_answer(claim, ledger, ledger.evaluate()) + assert reason + assert claim not in answer 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_stream_disconnect_middleware.py b/tests/test_stream_disconnect_middleware.py new file mode 100644 index 000000000..397a91634 --- /dev/null +++ b/tests/test_stream_disconnect_middleware.py @@ -0,0 +1,33 @@ +"""Health audit: SSE client disconnects (e.g. /api/editor/bridge/events) surface +as anyio.EndOfStream through Starlette's BaseHTTPMiddleware. The slow-request +middleware must map that to a client-side 499, not a logged 500 traceback.""" +import asyncio + +import anyio +from starlette.requests import Request + +from app import _SlowRequestLogMiddleware + + +def _request(path): + return Request({"type": "http", "method": "GET", "path": path, "headers": []}) + + +def test_stream_disconnect_is_mapped_to_499(): + async def call_next(_request): + raise anyio.EndOfStream + + mw = _SlowRequestLogMiddleware(lambda *a: None) + resp = asyncio.run(mw.dispatch(_request("/api/editor/bridge/events"), call_next)) + assert resp.status_code == 499 + + +def test_real_responses_pass_through(): + from starlette.responses import JSONResponse + + async def call_next(_request): + return JSONResponse({"ok": True}, status_code=200) + + mw = _SlowRequestLogMiddleware(lambda *a: None) + resp = asyncio.run(mw.dispatch(_request("/api/health"), call_next)) + assert resp.status_code == 200 diff --git a/tests/test_turn_contract.py b/tests/test_turn_contract.py index 1ff2d14a5..8e180b49c 100644 --- a/tests/test_turn_contract.py +++ b/tests/test_turn_contract.py @@ -328,6 +328,30 @@ def test_conversational_request_wrappers_preserve_explicit_family(message, expec assert requested_capabilities(message) == expected +@pytest.mark.parametrize("message", [ + "30-day task + protocol doc", + "I wanna work on a project where I test meditation for 30 days. But I test my brain activity before and after so I can compare.", + "Protocol doc only", + "Make a 30-day meditation plan", + "help me build a habit tracker for the project", +]) +def test_bare_project_task_noun_does_not_inject_task_tools(message): + # A project description that merely contains the noun "task" must not offer + # the background-task scheduler or let the model auto-create a task. + assert "tasks" not in requested_capabilities(message) + assert selected_tools_for_request(message) != frozenset({"manage_tasks"}) + + +@pytest.mark.parametrize("message", [ + "Create a task to call the dentist", + "List my scheduled tasks", + "show me my automations", + "what scheduled tasks do i have?", +]) +def test_explicit_task_management_still_routes_to_tasks(message): + assert "tasks" in requested_capabilities(message) + + @pytest.mark.parametrize("message", [ "can u also pop the notes panel open for me?", "k leave it for now - also pop open the calendar panel", @@ -4413,3 +4437,12 @@ def test_chinese_video_to_image_artifact_keeps_media_and_workspace_tools(): "请完整浏览 /workspace/input/video.mp4,为每款零食截图并拼成清单图," "输出到 /workspace/snack_checklist.jpg,交付前打开成品图检查。" ) == {"media_inspection", "shell_files"} + + +def test_named_shell_selects_shell_family_even_for_a_bare_command(): + # An explicitly named shell is a request to use the shell even when the + # rest of the message is a bare/typo'd command; admission must match the + # always-offered bash tool. + assert requested_capabilities("Bash commabd Ls la") == {"shell_files"} + assert requested_capabilities("bash ls") == {"shell_files"} + assert requested_capabilities("use the terminal to show me the files") == {"shell_files"}