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"}