mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
feat(harness): authority, turn contract, tools, clean_agent_preview
Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.
Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8c147b238
commit
364229f2dc
+3
-3
@@ -101,9 +101,9 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# Skip the external-context exact-approval pause for unattended local agents.
|
# Skip the external-context exact-approval pause for unattended local agents.
|
||||||
# Keep false for shared or internet-exposed deployments.
|
# Keep false for shared or internet-exposed deployments.
|
||||||
|
|
||||||
# Post-external-context tool approval gate. On by default; set to 0 only for
|
# Optional post-external-context tool approval gate. Off by default because it
|
||||||
# trusted single-user setups that accept injected content reaching side-effect tools.
|
# can block normal agent work; enable only for deployments that want this fence.
|
||||||
# ODYSSEUS_TOOL_APPROVAL_GATE=1
|
# ODYSSEUS_TOOL_APPROVAL_GATE=0
|
||||||
|
|
||||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
# 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
|
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ services/data/
|
|||||||
|
|
||||||
# Test capture artifacts (browser session dumps may contain personal content)
|
# Test capture artifacts (browser session dumps may contain personal content)
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
# Local editor bench/probe artifacts (sessions.json holds session tokens)
|
||||||
|
bench-out/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/photocraft"]
|
||||||
|
path = vendor/photocraft
|
||||||
|
url = https://github.com/storytold/photocraft
|
||||||
+19
-13
@@ -57,10 +57,14 @@ Vendored in `static/lib/` and served directly:
|
|||||||
|
|
||||||
| Library | Purpose | License |
|
| 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)) |
|
| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause |
|
||||||
| [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)) |
|
| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 |
|
||||||
| [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)) |
|
| [docx](https://github.com/dolanmiu/docx) (`docx.umd.min.js`) | Generate `.docx` documents | MIT |
|
||||||
| [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)) |
|
| [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)) |
|
| [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)) |
|
| [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/`
|
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
|
||||||
so they can stay byte-identical to upstream.
|
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)
|
## Front-end libraries loaded at runtime (CDN)
|
||||||
|
|
||||||
Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored:
|
Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored:
|
||||||
@@ -94,8 +91,9 @@ Bundled in `static/fonts/`:
|
|||||||
|
|
||||||
| Font | License | Author |
|
| 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 |
|
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | 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 |
|
| [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 |
|
| [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
|
## Python dependencies
|
||||||
@@ -172,4 +170,12 @@ concerns from earlier are resolved:
|
|||||||
|
|
||||||
## Thanks to
|
## 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.
|
- Friends, for helping me debug.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.20.19
|
0.20.84
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ a = Analysis(
|
|||||||
['launcher.py'],
|
['launcher.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
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=[],
|
hiddenimports=[],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
|
|||||||
+5
-3
@@ -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.
|
**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.
|
`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:
|
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:
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+37
-37
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
|||||||
"subject": subject,
|
"subject": subject,
|
||||||
"from": sender_name or sender_addr or sender,
|
"from": sender_name or sender_addr or sender,
|
||||||
"from_address": sender_addr,
|
"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": date_str,
|
||||||
"date_epoch": date_epoch,
|
"date_epoch": date_epoch,
|
||||||
"summary": body[:240],
|
"summary": body[:240],
|
||||||
@@ -1198,7 +1201,7 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
|||||||
"folder": folder,
|
"folder": folder,
|
||||||
"is_read": bool(row.get("read")),
|
"is_read": bool(row.get("read")),
|
||||||
"is_done": bool(row.get("done") or row.get("answered")),
|
"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_label": str(row.get("spam_label") or ""),
|
||||||
"spam_score": int(row.get("spam_score") or 0),
|
"spam_score": int(row.get("spam_score") or 0),
|
||||||
"list_unsubscribe": str(row.get("list_unsubscribe") or headers.get("List-Unsubscribe") or ""),
|
"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
|
return None
|
||||||
if not _fixture_owner_has_rows(_current_owner()):
|
if not _fixture_owner_has_rows(_current_owner()):
|
||||||
return None
|
return None
|
||||||
for item in _fixture_email_rows(_current_owner()):
|
if not uid and not message_id:
|
||||||
if not _fixture_row_matches_account(item, account):
|
return {"error": "Email UID or Message-ID is required"}
|
||||||
continue
|
matches = [item for item in _fixture_email_rows(_current_owner())
|
||||||
if not _fixture_folder_matches(item.get("folder"), folder):
|
if _fixture_row_matches_account(item, account)
|
||||||
continue
|
and _fixture_folder_matches(item.get("folder"), folder)
|
||||||
if uid and str(item.get("uid")) == str(uid):
|
and (not uid or str(item.get("uid")) == str(uid))
|
||||||
return item
|
and (not message_id or str(item.get("message_id")) == str(message_id))]
|
||||||
if message_id and str(item.get("message_id")) == str(message_id):
|
if len(matches) > 1:
|
||||||
return item
|
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}"}
|
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):
|
if not isinstance(rows, list):
|
||||||
return False
|
return False
|
||||||
owner = _current_owner()
|
owner = _current_owner()
|
||||||
|
matches = []
|
||||||
for index, row in enumerate(rows, start=1):
|
for index, row in enumerate(rows, start=1):
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1568,14 +1574,19 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
|||||||
continue
|
continue
|
||||||
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
||||||
continue
|
continue
|
||||||
for key, value in updates.items():
|
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
|
||||||
if value is None:
|
continue
|
||||||
row.pop(key, None)
|
matches.append(row)
|
||||||
else:
|
if len(matches) != 1:
|
||||||
row[key] = value
|
return False
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
row = matches[0]
|
||||||
return True
|
for key, value in updates.items():
|
||||||
return False
|
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 ──
|
# ── 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):
|
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."""
|
"""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:
|
if fixture is not None:
|
||||||
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
||||||
_, sender_addr = email.utils.parseaddr(sender)
|
_, 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):
|
def _set_flag(uid, folder, flag, add=True, account=None):
|
||||||
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
"""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":
|
if flag == "\\Seen":
|
||||||
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
||||||
if flag == "\\Answered":
|
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):
|
def _download_attachment(uid, index, folder="INBOX", account=None):
|
||||||
"""Extract a specific attachment to disk and return its local path."""
|
"""Extract a specific attachment to disk and return its local path."""
|
||||||
fixture = _fixture_attachment_source(uid, index, folder=folder, account=account)
|
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")
|
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
||||||
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() 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 "")
|
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
|
path = target_dir / safe_name
|
||||||
size = len(content.encode("utf-8"))
|
size = len(content.encode("utf-8"))
|
||||||
try:
|
try:
|
||||||
@@ -3560,7 +3560,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
|
|||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
msg = email.message_from_bytes(raw)
|
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)
|
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||||
if not filepath:
|
if not filepath:
|
||||||
return {"error": f"Attachment index {index} not found"}
|
return {"error": f"Attachment index {index} not found"}
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
lower = s.lower().strip()
|
lower = s.lower().strip()
|
||||||
|
|
||||||
def _parse_time(t):
|
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)
|
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||||
if not m: return None
|
if not m: return None
|
||||||
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
|
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()
|
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
||||||
|
|
||||||
# Time-first: "3pm today", "11pm today", "9am tomorrow"
|
# 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:
|
if m:
|
||||||
time_part, word = m.group(1).strip(), m.group(2)
|
time_part, word = m.group(1).strip(), m.group(2)
|
||||||
base = today
|
base = today
|
||||||
@@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
|
|
||||||
def _parse_time(t: str):
|
def _parse_time(t: str):
|
||||||
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
|
"""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)
|
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
@@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
|
|
||||||
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
|
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
|
||||||
# (parity with parse_due_for_user, which handles these via the same form)
|
# (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:
|
if m:
|
||||||
time_part, word = m.group(1).strip(), m.group(2)
|
time_part, word = m.group(1).strip(), m.group(2)
|
||||||
base = today
|
base = today
|
||||||
|
|||||||
+116
-81
@@ -11,7 +11,7 @@ from urllib.parse import urlparse
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, AsyncGenerator, List, Optional
|
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 fastapi.responses import StreamingResponse
|
||||||
from pydantic import ValidationError
|
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.agent_loop import _normalize_ody_qwen_text_artifacts
|
||||||
from src import agent_runs
|
from src import agent_runs
|
||||||
|
from src import shutdown as _shutdown
|
||||||
from src.model_context import estimate_tokens
|
from src.model_context import estimate_tokens
|
||||||
from src.context_compactor import (
|
from src.context_compactor import (
|
||||||
apply_compaction_state,
|
apply_compaction_state,
|
||||||
@@ -48,13 +49,7 @@ from src.foreground_model_routing import (
|
|||||||
from src.session_search import search_session_messages
|
from src.session_search import search_session_messages
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from core.exceptions import SessionNotFoundError
|
from core.exceptions import SessionNotFoundError
|
||||||
from src.auth_helpers import (
|
from src.auth_helpers import effective_user, get_current_user
|
||||||
effective_user,
|
|
||||||
get_current_user,
|
|
||||||
is_delegated_credential,
|
|
||||||
require_api_token_scope,
|
|
||||||
require_chat_api_token_scope,
|
|
||||||
)
|
|
||||||
from routes.session_routes import _verify_session_owner
|
from routes.session_routes import _verify_session_owner
|
||||||
from routes.document_helpers import _owner_session_filter
|
from routes.document_helpers import _owner_session_filter
|
||||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
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,
|
web_search_enabled_for_turn,
|
||||||
)
|
)
|
||||||
from src.tool_approvals import tool_approval_store
|
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.workspace_paths import backend_workspace_path
|
||||||
from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES
|
from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES
|
||||||
from src.model_profiles import (
|
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:
|
def _prefers_structured_document_tools(text: str) -> bool:
|
||||||
"""Identify external paper/PDF extraction where shell is a bad source route."""
|
"""Identify external paper/PDF extraction where shell is a bad source route."""
|
||||||
value = str(text or "")
|
value = str(text or "")
|
||||||
if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I):
|
if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I):
|
||||||
return False
|
return False
|
||||||
return bool(
|
return bool(
|
||||||
_mentions_pdf_url(value)
|
re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I)
|
||||||
or re.search(
|
or re.search(
|
||||||
r"\b(?:paper|report|study)\b[\s\S]{0,1200}?"
|
r"\b(?:paper|report|study)\b[\s\S]{0,1200}?"
|
||||||
r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b",
|
r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b",
|
||||||
@@ -1290,23 +1265,6 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
|||||||
return None
|
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:
|
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||||
"""Persist a consumed approval decision on its existing tool event."""
|
"""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:
|
if str(ask_user.get("approval_id") or "") != approval_key:
|
||||||
continue
|
continue
|
||||||
ask_user["resolved"] = normalized_decision
|
ask_user["resolved"] = normalized_decision
|
||||||
stamp_chat_session_grant(
|
|
||||||
ask_user,
|
|
||||||
getattr(sess, "id", ""),
|
|
||||||
normalized_decision,
|
|
||||||
)
|
|
||||||
message_id = metadata.get("_db_id")
|
message_id = metadata.get("_db_id")
|
||||||
resolved_metadata = {
|
resolved_metadata = {
|
||||||
key: value for key, value in metadata.items() if key != "_db_id"
|
key: value for key, value in metadata.items() if key != "_db_id"
|
||||||
@@ -2178,17 +2131,13 @@ def setup_chat_routes(
|
|||||||
webhook_manager=None,
|
webhook_manager=None,
|
||||||
skills_manager=None,
|
skills_manager=None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(
|
router = APIRouter(tags=["chat"])
|
||||||
tags=["chat"],
|
|
||||||
dependencies=[Depends(require_chat_api_token_scope)],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# POST /api/chat (non-streaming)
|
# POST /api/chat (non-streaming)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.post("/api/chat", response_model=Dict[str, Any])
|
@router.post("/api/chat", response_model=Dict[str, Any])
|
||||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> 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)
|
_set_user_time_from_request(request)
|
||||||
|
|
||||||
message = chat_request.message
|
message = chat_request.message
|
||||||
@@ -2405,7 +2354,6 @@ def setup_chat_routes(
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.post("/api/chat_stream")
|
@router.post("/api/chat_stream")
|
||||||
async def chat_stream(request: Request) -> StreamingResponse:
|
async def chat_stream(request: Request) -> StreamingResponse:
|
||||||
require_api_token_scope(request, "chat")
|
|
||||||
body = None
|
body = None
|
||||||
try:
|
try:
|
||||||
if request.headers.get("content-type", "").startswith("application/json"):
|
if request.headers.get("content-type", "").startswith("application/json"):
|
||||||
@@ -2525,7 +2473,7 @@ def setup_chat_routes(
|
|||||||
_explicit_web_intent = (
|
_explicit_web_intent = (
|
||||||
_explicit_url_target
|
_explicit_url_target
|
||||||
or bool(re.search(
|
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,
|
_msg_l,
|
||||||
))
|
))
|
||||||
or requires_external_web_verification(message)
|
or requires_external_web_verification(message)
|
||||||
@@ -2624,6 +2572,21 @@ def setup_chat_routes(
|
|||||||
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
||||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||||
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
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(
|
logger.info(
|
||||||
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
||||||
chat_mode, active_doc_id, active_doc_state,
|
chat_mode, active_doc_id, active_doc_state,
|
||||||
@@ -2731,7 +2694,6 @@ def setup_chat_routes(
|
|||||||
)
|
)
|
||||||
owner = effective_user(request)
|
owner = effective_user(request)
|
||||||
if tool_approval_id:
|
if tool_approval_id:
|
||||||
_reject_delegated_tool_approval(request)
|
|
||||||
from src.agent_runtime.authority import require_user_approval_request
|
from src.agent_runtime.authority import require_user_approval_request
|
||||||
require_user_approval_request(request)
|
require_user_approval_request(request)
|
||||||
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
|
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),
|
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 [])),
|
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 _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
|
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 a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||||
if image_refs:
|
if image_refs:
|
||||||
@@ -3226,6 +3219,11 @@ def setup_chat_routes(
|
|||||||
# pasted revision request. This only offers permitted schemas;
|
# pasted revision request. This only offers permitted schemas;
|
||||||
# it never requires or performs a document mutation.
|
# it never requires or performs a document mutation.
|
||||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
_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
|
# Same decision that prepared the turn; it only stamps the contract
|
||||||
# inside the agent-contract branch below.
|
# inside the agent-contract branch below.
|
||||||
_clean_v3_preview = _compact_preview_turn
|
_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:
|
if _use_turn_contract and _turn_capabilities and "search_browser" not in _turn_capabilities:
|
||||||
_explicit_web_intent = False
|
_explicit_web_intent = False
|
||||||
disabled_tools = set()
|
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
|
# Only disable bash when the caller *explicitly* set it to a falsy
|
||||||
# value. When unset (None), defer to per-user privilege checks below.
|
# value. When unset (None), defer to per-user privilege checks below.
|
||||||
# Web search is per-turn opt-in: either the chat pre-search setting
|
# Web search is per-turn opt-in: either the chat pre-search setting
|
||||||
@@ -3475,9 +3467,22 @@ def setup_chat_routes(
|
|||||||
"document_editor": not {
|
"document_editor": not {
|
||||||
"manage_documents", "create_document", "edit_document", "update_document",
|
"manage_documents", "create_document", "edit_document", "update_document",
|
||||||
}.issubset(disabled_tools),
|
}.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.
|
# Capture permission state before schema selection/reconciliation.
|
||||||
# Only deterministic request intent supplies grants, never inventory.
|
# 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_authority = request_authority_for_http(
|
||||||
request, message, owner=_user, session_id=session, workspace=workspace,
|
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||||
history=_turn_history, policy=tool_policy,
|
history=_turn_history, policy=tool_policy,
|
||||||
@@ -3485,9 +3490,16 @@ def setup_chat_routes(
|
|||||||
active_document=bool(active_doc),
|
active_document=bool(active_doc),
|
||||||
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||||
for a in (ctx.preprocessed.attachment_meta or [])),
|
for a in (ctx.preprocessed.attachment_meta or [])),
|
||||||
capabilities=({'search_browser'} if (
|
spec_surface=_spec_surface,
|
||||||
_explicit_browser_intent or _external_discovery_intent
|
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
|
||||||
) else ()),
|
_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:
|
if exact_tool_approval is not None:
|
||||||
from src.agent_runtime.authority import RequestAuthority
|
from src.agent_runtime.authority import RequestAuthority
|
||||||
@@ -3513,8 +3525,6 @@ def setup_chat_routes(
|
|||||||
if s["function"]["name"] != "host_shell"]
|
if s["function"]["name"] != "host_shell"]
|
||||||
_contract_mgr = get_mcp_manager()
|
_contract_mgr = get_mcp_manager()
|
||||||
_owner_blocked = blocked_tools_for_owner(_user)
|
_owner_blocked = blocked_tools_for_owner(_user)
|
||||||
if _delegated_credential:
|
|
||||||
_owner_blocked.update(delegated_credential_blocked_tools())
|
|
||||||
if (
|
if (
|
||||||
_workspace_tools_disabled_for_owner(_user)
|
_workspace_tools_disabled_for_owner(_user)
|
||||||
and not _native_workspace_contract
|
and not _native_workspace_contract
|
||||||
@@ -3677,7 +3687,9 @@ def setup_chat_routes(
|
|||||||
# personal manager; core shell/Web tools are not task
|
# personal manager; core shell/Web tools are not task
|
||||||
# fallbacks. Other turns retain warm and workspace tools.
|
# fallbacks. Other turns retain warm and workspace tools.
|
||||||
extra_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 (
|
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
||||||
_active_turn_capabilities in (
|
_active_turn_capabilities in (
|
||||||
frozenset({"transcription"}), frozenset({"ocr"}),
|
frozenset({"transcription"}), frozenset({"ocr"}),
|
||||||
@@ -3711,10 +3723,17 @@ def setup_chat_routes(
|
|||||||
)
|
)
|
||||||
# Every recovery path receives the same scope denial. The central
|
# Every recovery path receives the same scope denial. The central
|
||||||
# dispatcher also checks the immutable contract after rewrites.
|
# dispatcher also checks the immutable contract after rewrites.
|
||||||
disabled_tools.update(
|
# The strict router (spec) surface picks its own tools AFTER this
|
||||||
s["function"]["name"] for s in _contract_schemas
|
# contract is resolved, so scoping disabled_tools to the regex
|
||||||
if not _turn_contract.permits(s["function"]["name"])
|
# 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.
|
# Contract resolution is the final policy-and-routing authority.
|
||||||
# Some legacy/API-model paths arrive with a stale disabled snapshot
|
# Some legacy/API-model paths arrive with a stale disabled snapshot
|
||||||
# assembled before routing. The scope-denial pass above may retain
|
# assembled before routing. The scope-denial pass above may retain
|
||||||
@@ -3879,7 +3898,7 @@ def setup_chat_routes(
|
|||||||
_heartbeat_counter = 0
|
_heartbeat_counter = 0
|
||||||
_last_progress = {}
|
_last_progress = {}
|
||||||
_sent_avg = False
|
_sent_avg = False
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
status = research_handler.get_status(session)
|
status = research_handler.get_status(session)
|
||||||
if not status or status["status"] != "running":
|
if not status or status["status"] != "running":
|
||||||
break
|
break
|
||||||
@@ -4027,9 +4046,10 @@ def setup_chat_routes(
|
|||||||
owner=_user,
|
owner=_user,
|
||||||
size="auto",
|
size="auto",
|
||||||
progress_callback=_image_progress_callback,
|
progress_callback=_image_progress_callback,
|
||||||
|
incognito=incognito,
|
||||||
))
|
))
|
||||||
else:
|
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_started = time.time()
|
||||||
_img_tick = 0
|
_img_tick = 0
|
||||||
while not _img_task.done():
|
while not _img_task.done():
|
||||||
@@ -4568,7 +4588,6 @@ def setup_chat_routes(
|
|||||||
uploaded_files=ctx.uploaded_files,
|
uploaded_files=ctx.uploaded_files,
|
||||||
defer_context_shaping=_foreground_policy.enabled,
|
defer_context_shaping=_foreground_policy.enabled,
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
delegated_credential=_delegated_credential,
|
|
||||||
exact_approval=exact_tool_approval,
|
exact_approval=exact_tool_approval,
|
||||||
client_runtime_context=client_runtime_context,
|
client_runtime_context=client_runtime_context,
|
||||||
thinking_mode=thinking_mode,
|
thinking_mode=thinking_mode,
|
||||||
@@ -4879,11 +4898,20 @@ def setup_chat_routes(
|
|||||||
async def _safe_stream() -> AsyncGenerator[str, None]:
|
async def _safe_stream() -> AsyncGenerator[str, None]:
|
||||||
"""Wrapper that guarantees _active_streams cleanup even if stream_with_save
|
"""Wrapper that guarantees _active_streams cleanup even if stream_with_save
|
||||||
raises before reaching a mode-specific finally block."""
|
raises before reaching a mode-specific finally block."""
|
||||||
|
_gen = stream_with_save()
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_with_save():
|
while not _shutdown.is_shutting_down():
|
||||||
|
try:
|
||||||
|
chunk = await _gen.__anext__()
|
||||||
|
except StopAsyncIteration:
|
||||||
|
break
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
_active_streams.pop(session, None)
|
_active_streams.pop(session, None)
|
||||||
|
try:
|
||||||
|
await _gen.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Compare panes and explicitly unattended native clients are
|
# Compare panes and explicitly unattended native clients are
|
||||||
# short-lived, single-shot generations with nobody to resume them.
|
# short-lived, single-shot generations with nobody to resume them.
|
||||||
@@ -4975,7 +5003,12 @@ def setup_chat_routes(
|
|||||||
if rec is None:
|
if rec is None:
|
||||||
if agent_runs.is_active(session_id):
|
if agent_runs.is_active(session_id):
|
||||||
return {"status": "streaming", "detached": True}
|
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
|
return rec
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -5074,6 +5107,8 @@ def setup_chat_routes(
|
|||||||
max_tokens=0,
|
max_tokens=0,
|
||||||
tools=None,
|
tools=None,
|
||||||
):
|
):
|
||||||
|
if _shutdown.is_shutting_down():
|
||||||
|
break
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(chunk[6:])
|
data = json.loads(chunk[6:])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
|
|||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
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}$")
|
_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):
|
async def research_status(session_id: str, request: Request):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_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):
|
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)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(404, "No research found for this session")
|
return {"status": "none"}
|
||||||
try:
|
try:
|
||||||
source_state = research_handler.get_source_state(session_id)
|
source_state = research_handler.get_source_state(session_id)
|
||||||
if isinstance(source_state, str) and source_state:
|
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")
|
raise HTTPException(404, "No research found for this session")
|
||||||
async def _generate():
|
async def _generate():
|
||||||
last_payload = None
|
last_payload = None
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
status = research_handler.get_status(session_id)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from src.host_docker_access import (
|
|||||||
)
|
)
|
||||||
from src.optional_deps import prepare_optional_dependency_import
|
from src.optional_deps import prepare_optional_dependency_import
|
||||||
from src.auth_helpers import _auth_disabled
|
from src.auth_helpers import _auth_disabled
|
||||||
|
from src import shutdown as _shutdown
|
||||||
from src import process_lifecycle
|
from src import process_lifecycle
|
||||||
|
|
||||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
# 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
|
lines_sent = 0
|
||||||
exit_code = None
|
exit_code = None
|
||||||
|
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
# Check client disconnect
|
# Check client disconnect
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
# tmux keeps running — that's the whole point
|
# tmux keeps running — that's the whole point
|
||||||
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
|
|||||||
|
|
||||||
lines_sent = 0
|
lines_sent = 0
|
||||||
exit_code = None
|
exit_code = None
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
||||||
return
|
return
|
||||||
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"""Read chunks, split on \\n or \\r for progress bar support."""
|
"""Read chunks, split on \\n or \\r for progress bar support."""
|
||||||
try:
|
try:
|
||||||
buf = b""
|
buf = b""
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
chunk = await stream.read(4096)
|
chunk = await stream.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
if buf:
|
if buf:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ def workspace_artifact_is_usable(path: Path) -> bool:
|
|||||||
class EvidenceKind(str, Enum):
|
class EvidenceKind(str, Enum):
|
||||||
TOOL_RESULT = "tool_result"
|
TOOL_RESULT = "tool_result"
|
||||||
ARTIFACT_MUTATION = "artifact_mutation"
|
ARTIFACT_MUTATION = "artifact_mutation"
|
||||||
|
DOCUMENT_MUTATION = "document_mutation"
|
||||||
ARTIFACT_VALIDATION = "artifact_validation"
|
ARTIFACT_VALIDATION = "artifact_validation"
|
||||||
VERIFIER_RESULT = "verifier_result"
|
VERIFIER_RESULT = "verifier_result"
|
||||||
MEDIA_INGRESS = "media_ingress"
|
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}"
|
_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(
|
_ARTIFACT_REQUEST_RE = re.compile(
|
||||||
rf"\b(?:writ(?:e|ten)|creat(?:e|ed)|make|made|sav(?:e|ed)|produc(?:e|ed)|"
|
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)|"
|
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.
|
# order of actions so receipt evidence and effects share one ordering.
|
||||||
self.effects: list[dict[str, Any]] = []
|
self.effects: list[dict[str, Any]] = []
|
||||||
self._action_order: dict[str, int] = {}
|
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],
|
def record_effects(self, entries: Iterable[Mapping[str, Any]], action_order: Mapping[str, int],
|
||||||
partial_reads: Iterable[str] = ()) -> None:
|
partial_reads: Iterable[str] = ()) -> None:
|
||||||
@@ -898,6 +916,29 @@ class EvidenceLedger:
|
|||||||
detail="post-write artifact inspection",
|
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(
|
if tool in {"bash", "host_shell"} and (command_is_test(_command_text(command)) or _matches_declared_verifier(
|
||||||
command,
|
command,
|
||||||
self.requirements.verifier_commands,
|
self.requirements.verifier_commands,
|
||||||
@@ -969,6 +1010,22 @@ class EvidenceLedger:
|
|||||||
return False
|
return False
|
||||||
return True
|
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:
|
def record_media_ingress(self, metadata: Mapping[str, Any]) -> None:
|
||||||
for artifact in metadata.get("artifacts") or []:
|
for artifact in metadata.get("artifacts") or []:
|
||||||
if not isinstance(artifact, Mapping):
|
if not isinstance(artifact, Mapping):
|
||||||
|
|||||||
@@ -316,7 +316,12 @@ class RequestAuthority:
|
|||||||
|
|
||||||
|
|
||||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
_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)
|
@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,
|
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||||
history=(), policy=None, active_document=False,
|
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."""
|
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||||
if not isinstance(request_text, str):
|
if not isinstance(request_text, str):
|
||||||
raise TypeError("Authority requires trusted request text")
|
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
|
families = intent.capabilities
|
||||||
if capabilities is not None:
|
if capabilities is not None:
|
||||||
families |= frozenset(capabilities)
|
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))
|
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:
|
if selected is not None:
|
||||||
tools = tools & set(selected) if families else set(selected)
|
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"}:
|
if tools & {"web_search", "web_fetch"}:
|
||||||
tools.add("private_browser")
|
tools.add("private_browser")
|
||||||
operation = intent.required_read
|
operation = intent.required_read
|
||||||
@@ -389,13 +410,28 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
|||||||
actions = inputs = None
|
actions = inputs = None
|
||||||
if name == "private_browser":
|
if name == "private_browser":
|
||||||
actions = _BROWSER_READ_ACTIONS
|
actions = _BROWSER_READ_ACTIONS
|
||||||
# Explicit interaction intent admits its operation class. A
|
if spec_surface:
|
||||||
# browser offered only as static-Web fallback gets no such grant.
|
# The strict router itself offered a working browser: admit its
|
||||||
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
# 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")
|
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 re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||||
if operation is not None and name == canonical_tool(operation.tool):
|
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))
|
grants.append(OperationGrant(name, actions, inputs))
|
||||||
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||||
str(workspace or ""), tuple(grants))
|
str(workspace or ""), tuple(grants))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""One presentation gate between agent execution and externally visible prose.
|
"""One presentation gate between agent execution and externally visible prose.
|
||||||
|
|
||||||
Tool, progress and interaction events stay live. Answer deltas are held until
|
Tool, progress and interaction events stay live. Evidence-safe complete
|
||||||
the generator unwinds so a later replacement cannot conceal an earlier false
|
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
|
claim. This consumes no provider calls. Cancellation closes the inner generator
|
||||||
under the same journal/turn authority; it never emits a successful terminal event.
|
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)):
|
or not ledger._supports_verifier_claim(identities, paths)):
|
||||||
why = 'no current passing executable verification supports the claim'
|
why = 'no current passing executable verification supports the claim'
|
||||||
elif claim == 'mutation':
|
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'
|
why = 'no matching artifact mutation supports the execution claim'
|
||||||
elif claim == 'execution':
|
elif claim == 'execution':
|
||||||
# A generic assertion cannot be tied confidently to a receipt.
|
# 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
|
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]:
|
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 ''
|
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
|
||||||
execution_required = _execution_obligation(ledger.requirements)
|
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
|
journal.effects = (parent.effects if parent is not None and parent.effects is not None
|
||||||
else EffectLog(journal.run_id))
|
else EffectLog(journal.run_id))
|
||||||
answer_events: list[dict] = []
|
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] = []
|
metrics_events: list[dict] = []
|
||||||
answer = ''
|
answer = ''
|
||||||
has_final = False
|
has_final = False
|
||||||
@@ -297,18 +312,36 @@ def with_completion_gate(func):
|
|||||||
first_answer_at = perf_counter()
|
first_answer_at = perf_counter()
|
||||||
# Boolean thinking=True marks a reasoning-only delta;
|
# Boolean thinking=True marks a reasoning-only delta;
|
||||||
# a textual thinking companion must not hide an answer
|
# 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):
|
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'}
|
data = {key: value for key, value in data.items() if key != 'thinking'}
|
||||||
if 'delta' not in data:
|
if 'delta' not in data:
|
||||||
continue
|
continue
|
||||||
if data.get('thinking') is not True and 'delta' in data:
|
if data.get('thinking') is not True and 'delta' in data:
|
||||||
if has_final:
|
if has_final:
|
||||||
|
stream_conflict |= bool(streamed_safe_text)
|
||||||
|
streamed_safe_text = ''
|
||||||
answer = ''
|
answer = ''
|
||||||
has_final = False
|
has_final = False
|
||||||
answer += str(data.get('delta') or '')
|
answer += str(data.get('delta') or '')
|
||||||
answer_events.append(data)
|
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
|
continue
|
||||||
yield chunk
|
yield chunk
|
||||||
if provider_error and not answer_events and not metrics_events:
|
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
|
disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft
|
||||||
or filtered_answer != answer)
|
or filtered_answer != answer)
|
||||||
replaced_answer = not disclosure_only and bool(
|
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:
|
if replaced_answer:
|
||||||
reasoning = [event for event in answer_events if event.get('thinking') is True]
|
reasoning = [event for event in answer_events if event.get('thinking') is True]
|
||||||
_, unsafe_reasoning = completion_answer(
|
_, unsafe_reasoning = completion_answer(
|
||||||
@@ -369,11 +405,31 @@ def with_completion_gate(func):
|
|||||||
replace(presentation_decision, can_complete=True))
|
replace(presentation_decision, can_complete=True))
|
||||||
if not unsafe_reasoning:
|
if not unsafe_reasoning:
|
||||||
for event in reasoning:
|
for event in reasoning:
|
||||||
yield _event(event)
|
if id(event) not in streamed_answer_events:
|
||||||
yield _event({'type': 'final_response', 'content': safe_answer})
|
yield _event(event)
|
||||||
|
yield _event({
|
||||||
|
'type': 'final_response',
|
||||||
|
'content': safe_answer,
|
||||||
|
'render_owner': 'streamed',
|
||||||
|
'replacement_scope': 'turn',
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
for event in answer_events:
|
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:
|
if disclosure_only:
|
||||||
yield _event({'delta': disclosure})
|
yield _event({'delta': disclosure})
|
||||||
if provider_error:
|
if provider_error:
|
||||||
@@ -386,8 +442,11 @@ def with_completion_gate(func):
|
|||||||
if ledger.effects:
|
if ledger.effects:
|
||||||
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
|
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
|
||||||
metadata['completion_gate'] = {
|
metadata['completion_gate'] = {
|
||||||
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
'buffer_seconds': max(0, (first_visible_answer_at or released_at) - first_answer_at)
|
||||||
'first_visible_answer_seconds': released_at - started,
|
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,
|
'additional_provider_calls': 0,
|
||||||
'answer_replaced': replaced_answer,
|
'answer_replaced': replaced_answer,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
|
|||||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
from .bg_job_tools import ManageBgJobsTool
|
from .bg_job_tools import ManageBgJobsTool
|
||||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||||
|
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
|
||||||
from .admin_tools import (
|
from .admin_tools import (
|
||||||
ADMIN_TOOL_HANDLERS,
|
ADMIN_TOOL_HANDLERS,
|
||||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||||
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
|
|||||||
"list_sessions": ListSessionsTool().execute,
|
"list_sessions": ListSessionsTool().execute,
|
||||||
"send_to_session": SendToSessionTool().execute,
|
"send_to_session": SendToSessionTool().execute,
|
||||||
"manage_session": ManageSessionTool().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).
|
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||||
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional
|
|||||||
"content": clean,
|
"content": clean,
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"source_doc_id": getattr(source_doc, "id", None),
|
"source_doc_id": getattr(source_doc, "id", None),
|
||||||
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -618,6 +619,7 @@ class CreateDocumentTool:
|
|||||||
"language": language,
|
"language": language,
|
||||||
"content": content,
|
"content": content,
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -708,6 +710,7 @@ class UpdateDocumentTool:
|
|||||||
"language": doc.language,
|
"language": doc.language,
|
||||||
"content": new_content,
|
"content": new_content,
|
||||||
"version": new_ver,
|
"version": new_ver,
|
||||||
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -863,6 +866,7 @@ class EditDocumentTool:
|
|||||||
"version": new_ver,
|
"version": new_ver,
|
||||||
"applied": applied,
|
"applied": applied,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
return {"error": "No edits applied — FIND text cannot be blank"}
|
return {"error": "No edits applied — FIND text cannot be blank"}
|
||||||
|
|
||||||
@@ -968,6 +972,7 @@ class EditDocumentTool:
|
|||||||
"version": new_ver,
|
"version": new_ver,
|
||||||
"applied": applied,
|
"applied": applied,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
if partial_edits:
|
if partial_edits:
|
||||||
result.update({
|
result.update({
|
||||||
@@ -1059,6 +1064,7 @@ class SuggestDocumentTool:
|
|||||||
"partial": bool(invalid),
|
"partial": bool(invalid),
|
||||||
"rejected": len(invalid),
|
"rejected": len(invalid),
|
||||||
"invalid_suggestions": invalid,
|
"invalid_suggestions": invalid,
|
||||||
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import html
|
import html
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import pwd
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
@@ -31,7 +32,10 @@ _ACTIVE_BROWSER_SESSIONS: set[str] = set()
|
|||||||
def _service_home() -> Path:
|
def _service_home() -> Path:
|
||||||
"""Return the account home even when a task overrides ``HOME``."""
|
"""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]:
|
def _host_npm_roots() -> list[Path]:
|
||||||
|
|||||||
+60
-19
@@ -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"}
|
return {"error": "Edit needs line 2: memory_id, line 3: new text"}
|
||||||
memory_id = lines[1].strip()
|
memory_id = lines[1].strip()
|
||||||
new_text = lines[2].strip()
|
new_text = lines[2].strip()
|
||||||
if not memory_id:
|
|
||||||
return {"error": "Edit needs line 2: memory_id"}
|
|
||||||
if not new_text:
|
if not new_text:
|
||||||
return {"error": "New text cannot be empty"}
|
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:
|
if len(lines) < 2:
|
||||||
return {"error": "Delete needs line 2: memory_id"}
|
return {"error": "Delete needs line 2: memory_id"}
|
||||||
memory_id = lines[1].strip()
|
memory_id = lines[1].strip()
|
||||||
if not memory_id:
|
|
||||||
return {"error": "Delete needs line 2: memory_id"}
|
|
||||||
|
|
||||||
memories = _memory_manager.load_all()
|
memories = _memory_manager.load_all()
|
||||||
original_len = len(memories)
|
original_len = len(memories)
|
||||||
@@ -1155,7 +1151,50 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
# Image generation
|
# 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).
|
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||||
|
|
||||||
Content format:
|
Content format:
|
||||||
@@ -1314,7 +1353,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
else:
|
else:
|
||||||
payload["quality"] = "medium"
|
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:
|
try:
|
||||||
# GPT image models can take 30-120s+ depending on quality
|
# 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}")
|
logger.warning(f"Failed to save gallery record: {_ge}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# GPT image models always return b64_json; DALL-E may return url
|
def _store_image(image_bytes):
|
||||||
if img.get("b64_json"):
|
if incognito:
|
||||||
|
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
img_path.write_bytes(image_bytes)
|
||||||
image_url = f"/api/generated-image/{filename}"
|
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
|
||||||
image_id = _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"):
|
elif img.get("url"):
|
||||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
# 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:
|
try:
|
||||||
dl_resp = httpx.get(result_url, timeout=60)
|
dl_resp = httpx.get(result_url, timeout=60)
|
||||||
if dl_resp.status_code == 200:
|
if dl_resp.status_code == 200:
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
image_url, image_id = _store_image(dl_resp.content)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
image_url = result_url # fallback to external URL
|
image_url = result_url # fallback to external URL
|
||||||
except Exception as _dl_e:
|
except Exception as _dl_e:
|
||||||
@@ -1430,6 +1467,7 @@ async def do_edit_image(
|
|||||||
size: str = "auto",
|
size: str = "auto",
|
||||||
quality: str = "medium",
|
quality: str = "medium",
|
||||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||||
|
incognito: bool = False,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Edit an uploaded image using the configured image endpoint."""
|
"""Edit an uploaded image using the configured image endpoint."""
|
||||||
import base64
|
import base64
|
||||||
@@ -1503,7 +1541,7 @@ async def do_edit_image(
|
|||||||
request_id = uuid.uuid4().hex
|
request_id = uuid.uuid4().hex
|
||||||
payload["request_id"] = request_id
|
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:
|
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||||
try:
|
try:
|
||||||
@@ -1533,6 +1571,9 @@ async def do_edit_image(
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
with Image.open(BytesIO(image_bytes)) as output:
|
with Image.open(BytesIO(image_bytes)) as output:
|
||||||
size = f"{output.width}x{output.height}"
|
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 = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ def compact_browser_observation(value, budget=8000):
|
|||||||
notices.append('Exit code: ' + str(item['exit_code']))
|
notices.append('Exit code: ' + str(item['exit_code']))
|
||||||
if item.get('success') is False:
|
if item.get('success') is False:
|
||||||
notices.append('Browser command failed.')
|
notices.append('Browser command failed.')
|
||||||
snapshot = item.get('snapshot') or item.get('text')
|
snapshot = item.get('snapshot')
|
||||||
if item.get('title'):
|
if not isinstance(snapshot, str) or not snapshot.strip():
|
||||||
notices.append('Title: ' + str(item['title']))
|
snapshot = item.get('text')
|
||||||
url = item.get('url') or item.get('origin')
|
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():
|
if isinstance(snapshot, str) and snapshot.strip():
|
||||||
# Snapshot text already contains labels and refs in DOM order.
|
# Snapshot text already contains labels and refs in DOM order.
|
||||||
# The refs mapping repeats them and buries menus in raw JSON.
|
# The refs mapping repeats them and buries menus in raw JSON.
|
||||||
|
|||||||
+471
-406
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ def auto_compact_threshold_percent() -> int:
|
|||||||
return max(50, min(95, value))
|
return max(50, min(95, value))
|
||||||
|
|
||||||
# Cursor-style self-summarization prompt — produces structured, dense summaries
|
# 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:
|
Use this format:
|
||||||
|
|
||||||
|
|||||||
@@ -102,15 +102,15 @@ class MemoryVectorStore:
|
|||||||
return collections
|
return collections
|
||||||
|
|
||||||
def add(self, memory_id: str, text: str):
|
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:
|
if not self._healthy:
|
||||||
return
|
return
|
||||||
for lane in self._lanes:
|
for lane in self._lanes:
|
||||||
try:
|
try:
|
||||||
existing = lane.collection.get(ids=[memory_id])
|
existing = lane.collection.get(ids=[memory_id], include=["documents"])
|
||||||
if existing["ids"]:
|
if existing["ids"] and existing["documents"] == [text]:
|
||||||
continue
|
continue
|
||||||
lane.collection.add(
|
lane.collection.upsert(
|
||||||
ids=[memory_id],
|
ids=[memory_id],
|
||||||
embeddings=lane.encode([text]),
|
embeddings=lane.encode([text]),
|
||||||
documents=[text],
|
documents=[text],
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def supports_user_thinking_toggle(value: object) -> bool:
|
|||||||
return False
|
return False
|
||||||
return any(pattern in leaf for pattern in (
|
return any(pattern in leaf for pattern in (
|
||||||
"kimi-k2.5", "kimi-k2.6", "kimi-k3",
|
"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",
|
"minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||||
"magistral", "mistral-small", "mistral-medium",
|
"magistral", "mistral-small", "mistral-medium",
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ DEFAULT_SETTINGS = {
|
|||||||
"image_gen_enabled": False,
|
"image_gen_enabled": False,
|
||||||
"image_model": "",
|
"image_model": "",
|
||||||
"image_quality": "medium",
|
"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_model": "",
|
||||||
"vision_enabled": True,
|
"vision_enabled": True,
|
||||||
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
||||||
|
|||||||
+59
-22
@@ -1969,20 +1969,21 @@ class TaskScheduler:
|
|||||||
messages.append(datetime_context_msg)
|
messages.append(datetime_context_msg)
|
||||||
messages.append({"role": "user", "content": user_content})
|
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 = {}
|
headers = {}
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
from src.auth_helpers import owner_filter
|
||||||
resolve_owner_registered_endpoint,
|
|
||||||
)
|
|
||||||
db2 = SessionLocal()
|
db2 = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)
|
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||||
endpoint_url = build_chat_url(base)
|
eps = ep_q.all()
|
||||||
headers = build_headers(api_key, base)
|
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:
|
finally:
|
||||||
db2.close()
|
db2.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2169,23 +2170,26 @@ class TaskScheduler:
|
|||||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise RuntimeError("No model/endpoint configured for research")
|
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).
|
# Record the resolved model for the run record (see _execute_task_locked).
|
||||||
self._last_run_model = model
|
self._last_run_model = model
|
||||||
|
|
||||||
# Authorize the selected URL before normalization can collapse service paths.
|
# Resolve headers
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import (
|
from core.database import ModelEndpoint
|
||||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||||
resolve_owner_registered_endpoint,
|
from src.auth_helpers import owner_filter
|
||||||
)
|
db2 = db
|
||||||
if not headers_from_resolver:
|
if not headers_from_resolver:
|
||||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||||
endpoint_url = build_chat_url(base)
|
eps = ep_q.all()
|
||||||
headers = build_headers(api_key, base)
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
|
||||||
|
|
||||||
max_tokens = int(get_setting("research_max_tokens", 8192))
|
max_tokens = int(get_setting("research_max_tokens", 8192))
|
||||||
extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90)
|
extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90)
|
||||||
@@ -2501,6 +2505,36 @@ class TaskScheduler:
|
|||||||
ScheduledTask.task_type == "action",
|
ScheduledTask.task_type == "action",
|
||||||
).all() if row[0]
|
).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 = []
|
renamed = []
|
||||||
builtin_tasks = db.query(ScheduledTask).filter(
|
builtin_tasks = db.query(ScheduledTask).filter(
|
||||||
ScheduledTask.owner == owner,
|
ScheduledTask.owner == owner,
|
||||||
@@ -2661,12 +2695,15 @@ class TaskScheduler:
|
|||||||
output_target=defs.get("output_target", "none"),
|
output_target=defs.get("output_target", "none"),
|
||||||
notifications_enabled=False,
|
notifications_enabled=False,
|
||||||
)
|
)
|
||||||
|
if action in replaced_actions and replaced_actions[action] in ("active", "paused"):
|
||||||
|
task.status = replaced_actions[action]
|
||||||
db.add(task)
|
db.add(task)
|
||||||
seeded.append(action)
|
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(
|
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,
|
owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count,
|
||||||
|
sorted(replaced_actions),
|
||||||
)
|
)
|
||||||
# Always commit — the orphan-run sweep above may have produced
|
# Always commit — the orphan-run sweep above may have produced
|
||||||
# pending deletes even when no defaults changed.
|
# pending deletes even when no defaults changed.
|
||||||
|
|||||||
+6
-2
@@ -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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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=<keyword> 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.",
|
"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=<keyword> 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.",
|
"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.",
|
"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.",
|
"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.",
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-13
@@ -70,7 +70,7 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
|
|||||||
command = args.get("command")
|
command = args.get("command")
|
||||||
if isinstance(command, str):
|
if isinstance(command, str):
|
||||||
path, separator, content = command.partition("\n")
|
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["path"] = path.strip()
|
||||||
args["content"] = content
|
args["content"] = content
|
||||||
logger.warning("Normalized legacy `command` write shape to path/content")
|
logger.warning("Normalized legacy `command` write shape to path/content")
|
||||||
@@ -494,14 +494,14 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "youtube_tool",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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."},
|
"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"},
|
"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": "YouTube video URL"},
|
"video_url": {"type": "string", "description": "A youtube.com or youtu.be video URL"},
|
||||||
"video_id": {"type": "string", "description": "YouTube video id"},
|
"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"},
|
"channel_url": {"type": "string", "description": "YouTube channel URL or /@handle/videos URL"},
|
||||||
"handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"},
|
"handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"},
|
||||||
"max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"}
|
"max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"}
|
||||||
@@ -1497,7 +1497,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "generate_image",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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\": <question>, \"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\": <brightness-contrast|exposure|white-balance|hue-saturation|vibrance|black-white|shadows-highlights|levels|curves|color-balance|photo-filter|selective-color|gradient-map|invert|posterize|threshold|channel-mixer|color-lookup>, \"params\": {...}}; filters.apply/applyMasked params = {\"id\": <blur-gaussian|blur-motion|blur-zoom|sharpen|sharpen-edges|unsharp-mask|noise-add|noise-despeckle|pixelate-mosaic|pixelate-crystallize|stylize-emboss|stylize-find-edges|stylize-solarize|distort-twirl|distort-pinch|distort-spherize|distort-ripple|render-clouds>}. 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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -2200,13 +2269,7 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
|||||||
elif tool_type == "get_workspace":
|
elif tool_type == "get_workspace":
|
||||||
content = ""
|
content = ""
|
||||||
elif tool_type == "write_file":
|
elif tool_type == "write_file":
|
||||||
body = args.get("content")
|
content = args.get("path", "") + "\n" + 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)
|
|
||||||
elif tool_type == "edit_file":
|
elif tool_type == "edit_file":
|
||||||
content = json.dumps(args)
|
content = json.dumps(args)
|
||||||
elif tool_type == "apply_patch":
|
elif tool_type == "apply_patch":
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ TOOL_TAGS = {
|
|||||||
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
|
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
|
||||||
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
|
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
|
||||||
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
|
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
|
||||||
|
"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image",
|
||||||
"app_api",
|
"app_api",
|
||||||
} | BUILTIN_EMAIL_TOOLS
|
} | BUILTIN_EMAIL_TOOLS
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
|||||||
desc = str(raw_args.get("description") or "")
|
desc = str(raw_args.get("description") or "")
|
||||||
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
|
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
|
||||||
raw = desc
|
raw = desc
|
||||||
if raw in (None, "", False):
|
if raw is None or raw == "" or raw is False:
|
||||||
return None
|
return None
|
||||||
if raw is True:
|
if raw is True:
|
||||||
return 10
|
return 10
|
||||||
@@ -236,10 +236,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
|||||||
text = str(raw).strip().lower()
|
text = str(raw).strip().lower()
|
||||||
if text in {"none", "no", "off", "false"}:
|
if text in {"none", "no", "off", "false"}:
|
||||||
return None
|
return None
|
||||||
m = re.search(r"(?<!\d)(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||||
if m:
|
if m:
|
||||||
return max(0, int(m.group(1)))
|
return max(0, int(m.group(1)))
|
||||||
m = re.search(r"(?<!\d)(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||||
if m:
|
if m:
|
||||||
return max(0, int(m.group(1)) * 60)
|
return max(0, int(m.group(1)) * 60)
|
||||||
if text.isdigit():
|
if text.isdigit():
|
||||||
@@ -251,7 +251,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
|||||||
if minutes_before is None:
|
if minutes_before is None:
|
||||||
return desc
|
return desc
|
||||||
reminder_only = re.compile(
|
reminder_only = re.compile(
|
||||||
r"^\s*(?:remind(?:er)?|alarm)\s*(?::\s*)?\d+\s*"
|
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
|
||||||
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
|
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
@@ -497,8 +497,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
|||||||
delta = None
|
delta = None
|
||||||
if dur:
|
if dur:
|
||||||
import re as _re_d
|
import re as _re_d
|
||||||
h = _re_d.search(r'(?<!\d)(\d+)\s*(?:h|hr|hours?)', dur)
|
h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
|
||||||
m = _re_d.search(r'(?<!\d)(\d+)\s*(?:m|min|minutes?)', dur)
|
m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
|
||||||
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
|
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
|
||||||
if secs > 0:
|
if secs > 0:
|
||||||
delta = timedelta(seconds=secs)
|
delta = timedelta(seconds=secs)
|
||||||
|
|||||||
+1
-1
@@ -322,7 +322,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if looks_like_reminder:
|
if looks_like_reminder:
|
||||||
temporal = re.search(
|
temporal = re.search(
|
||||||
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
|
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",
|
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
|
||||||
lower_combined,
|
lower_combined,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,3 +61,4 @@ def backend_workspace_path(path: str | None) -> str:
|
|||||||
return container_root
|
return container_root
|
||||||
return str(PurePosixPath(container_root) / rel)
|
return str(PurePosixPath(container_root) / rel)
|
||||||
return expanded
|
return expanded
|
||||||
|
|
||||||
|
|||||||
@@ -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 '^(<<<<<<< |=======$|>>>>>>> )' <each modified file>
|
||||||
|
# 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
|
||||||
@@ -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('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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 |
|
| 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 |
|
| Memory | a fact is listed, found by search, and gone after delete |
|
||||||
| Uploads | an attachment reads back byte for byte |
|
| 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 |
|
| Settings | a preference written on one session is still there after a new login |
|
||||||
|
|
||||||
## What is not covered, and why
|
## What is not covered, and why
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ def test_terse_timed_calendar_create_promotes_to_calendar():
|
|||||||
|
|
||||||
assert intent.needs_tools
|
assert intent.needs_tools
|
||||||
assert intent.category == "calendar"
|
assert intent.category == "calendar"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.agent_evidence import (
|
from src.agent_evidence import (
|
||||||
CompletionRequirements,
|
CompletionRequirements,
|
||||||
CompletionStatus,
|
CompletionStatus,
|
||||||
@@ -934,3 +936,66 @@ def test_exhaustion_and_awaiting_user_are_non_completion_states():
|
|||||||
ledger = EvidenceLedger()
|
ledger = EvidenceLedger()
|
||||||
assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED
|
assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED
|
||||||
assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER
|
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 == []
|
||||||
|
|||||||
@@ -87,6 +87,91 @@ def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
|
|||||||
assert metrics["tool_events"][0]["ask_user"] == payload
|
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():
|
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
|
||||||
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
||||||
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def test_login_page_static_assets_resolve_under_mount_path():
|
|||||||
mounted_login_url = "https://example.test/odysseus/login"
|
mounted_login_url = "https://example.test/odysseus/login"
|
||||||
for relative_asset, mounted_path in (
|
for relative_asset, mounted_path in (
|
||||||
("static/manifest.json", "/odysseus/static/manifest.json"),
|
("static/manifest.json", "/odysseus/static/manifest.json"),
|
||||||
|
("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"),
|
||||||
(
|
(
|
||||||
"static/fonts/FiraCode-Regular.woff2",
|
"static/fonts/FiraCode-Regular.woff2",
|
||||||
"/odysseus/static/fonts/FiraCode-Regular.woff2",
|
"/odysseus/static/fonts/FiraCode-Regular.woff2",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"})
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
import json
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.clean_agent_preview import preview_tool_result_text
|
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():
|
def test_legacy_page_text_retains_access_block_evidence():
|
||||||
result = {'url': 'https://example.org', 'title': 'Security verification',
|
result = {'url': 'https://example.org', 'title': 'Security verification',
|
||||||
'text': 'Unusual traffic. Complete the CAPTCHA.'}
|
'text': 'Unusual traffic. Complete the CAPTCHA.'}
|
||||||
|
|||||||
@@ -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():
|
def test_explicit_private_browser_workflow_survives_web_search_disabled():
|
||||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||||
assert "_explicit_private_browser_intent" in source
|
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, (
|
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"
|
"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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -109,12 +109,37 @@ def test_deepseek_flash_keeps_tools_but_drops_unsupported_forced_choice():
|
|||||||
}
|
}
|
||||||
|
|
||||||
compatible = provider_compatible_tool_choice_request(request, 'deepseek-flash')
|
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 'tool_choice' not in compatible
|
||||||
assert request['tool_choice']['function']['name'] == 'inspect_media'
|
assert request['tool_choice']['function']['name'] == 'inspect_media'
|
||||||
assert provider_compatible_tool_choice_request(request, 'qwen3.5-9b') is request
|
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():
|
def test_deepseek_v4_flash_drops_named_choice_for_thinking_mode():
|
||||||
request = {
|
request = {
|
||||||
'tools': [{'type': 'function', 'function': {'name': 'write_file'}}],
|
'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')
|
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']] == [
|
assert [tool['function']['name'] for tool in compatible['tools']] == [
|
||||||
'inspect_media'
|
'inspect_media', 'write_file'
|
||||||
]
|
]
|
||||||
assert 'tool_choice' not in compatible
|
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():
|
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')
|
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_notes')
|
||||||
compact = compact_schemas([schema])[0]['function']
|
compact = compact_schemas([schema])[0]['function']
|
||||||
assert 'add creates a new note' in compact['description']
|
assert 'ONE add call' in compact['description']
|
||||||
assert 'update with id' 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']
|
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_fetch': ({'url': 'https://example.com'}, 'read this page'),
|
||||||
'web_search': ({'query': 'current AI news'}, 'search the web'),
|
'web_search': ({'query': 'current AI news'}, 'search the web'),
|
||||||
'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'),
|
'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 = {
|
schemas = {
|
||||||
schema['function']['name']: schema
|
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_editing'} if name == 'edit_image'
|
||||||
else {'image_generation'} if name == 'generate_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()
|
else frozenset()
|
||||||
),
|
),
|
||||||
contract_required_tools={name},
|
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(
|
(contract_root / 'eval_alltools_unseen_compare.py').write_text(
|
||||||
"def tools_for_mode(tools, mode):\n"
|
"def tools_for_mode(tools, mode):\n"
|
||||||
" assert mode == 'compact_contract_v5'\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",
|
" return tools\n",
|
||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
)
|
)
|
||||||
monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root))
|
monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root))
|
||||||
module.contract_builder.cache_clear()
|
module.contract_builder.cache_clear()
|
||||||
try:
|
try:
|
||||||
# Probe a tool whose compact description the harness does not
|
notes = next(
|
||||||
# replace; manage_notes now carries a full harness-owned override.
|
|
||||||
search = next(
|
|
||||||
s for s in FUNCTION_TOOL_SCHEMAS
|
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]
|
compact = module.compact_schemas([notes])[0]
|
||||||
assert compact['function']['description'].startswith('versioned-contract-loaded')
|
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:
|
finally:
|
||||||
module.contract_builder.cache_clear()
|
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 parameters['required'] == ['edits']
|
||||||
assert set(parameters['properties']) == {'edits', 'more'}
|
assert set(parameters['properties']) == {'edits', 'more'}
|
||||||
assert parameters['properties']['more']['type'] == 'boolean'
|
assert parameters['properties']['more']['type'] == 'boolean'
|
||||||
|
assert 'more' not in parameters['required']
|
||||||
|
|
||||||
|
|
||||||
def test_v3_ui_schema_advertises_only_policy_executable_client_local_actions():
|
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())]
|
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]
|
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 [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')
|
metrics = next(e['data'] for e in events if e.get('type') == 'metrics')
|
||||||
assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}]
|
assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}]
|
||||||
assert raw[-1] == 'data: [DONE]\n\n'
|
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(),
|
disabled_tools=set(), tool_policy=ToolPolicy(),
|
||||||
)]
|
)]
|
||||||
assert raw[-1].startswith('event: error\ndata: ')
|
assert raw[-1].startswith('event: error\ndata: ')
|
||||||
assert 'selected model provider failed' in raw[-1]
|
assert 'Qwen3_5MTPDraftModel' in raw[-1]
|
||||||
assert 'provider_stream_error' in raw[-1]
|
|
||||||
assert 'Qwen3_5MTPDraftModel' not in raw[-1]
|
|
||||||
assert all('returned no answer' not in chunk for chunk in raw)
|
assert all('returned no answer' not in chunk for chunk in raw)
|
||||||
assert all('"type": "metrics"' 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
|
@pytest.mark.asyncio
|
||||||
async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch):
|
async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch):
|
||||||
import src.clean_agent_preview as module
|
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
|
@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
|
from dataclasses import replace
|
||||||
import src.clean_agent_preview as module
|
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',
|
routing_experiment='recent_model_choice',
|
||||||
)
|
)
|
||||||
raw = [chunk async for chunk in stream_preview(
|
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.'}],
|
messages=[{'role': 'user', 'content': 'Tell me more about that Reuters report.'}],
|
||||||
headers={}, turn_contract=contract, session_id='test', owner='test',
|
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']
|
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(
|
assert all(
|
||||||
schema['function']['name'] != 'web_fetch'
|
schema['function']['name'] != 'web_fetch'
|
||||||
for schema in requests[1].get('tools', [])
|
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
|
@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
|
import src.clean_agent_preview as module
|
||||||
|
|
||||||
responses = iter([
|
responses = iter([
|
||||||
@@ -5594,10 +5701,8 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
|
|||||||
},
|
},
|
||||||
}]}}]},
|
}]}}]},
|
||||||
{'choices': [{'delta': {'content': (
|
{'choices': [{'delta': {'content': (
|
||||||
'Recent AI developments include new model releases and policy updates. '
|
'I found the [AI update](https://example.com/ai) page. '
|
||||||
'The search results identify the relevant primary sources and dates for each item, '
|
'The browser search was blocked, so I used native search to locate it.'
|
||||||
'including publication details, concrete findings, and links readers can inspect '
|
|
||||||
'for the full context behind each development.'
|
|
||||||
)}}]},
|
)}}]},
|
||||||
])
|
])
|
||||||
requests = []
|
requests = []
|
||||||
@@ -5626,6 +5731,9 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
|
|||||||
if block.tool_type == 'private_browser':
|
if block.tool_type == 'private_browser':
|
||||||
return 'private_browser', {
|
return 'private_browser', {
|
||||||
'output': json.dumps({
|
'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',
|
'url': 'https://www.google.com/sorry/index?continue=search',
|
||||||
'text': 'Our systems have detected unusual traffic. Complete the reCAPTCHA.',
|
'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(
|
raw = [chunk async for chunk in stream_preview(
|
||||||
endpoint_url='http://test', model='test',
|
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',
|
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,
|
||||||
)]
|
)]
|
||||||
@@ -5659,6 +5767,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
|
|||||||
assert executions == ['private_browser', 'web_search']
|
assert executions == ['private_browser', 'web_search']
|
||||||
assert requests[1]['tool_choice'] == 'required'
|
assert requests[1]['tool_choice'] == 'required'
|
||||||
assert [s['function']['name'] for s in requests[1]['tools']] == ['web_search']
|
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]
|
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
|
||||||
assert any(
|
assert any(
|
||||||
event.get('type') == 'tool_loop_recovery'
|
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):
|
async def execute(block, **kwargs):
|
||||||
executed.append(block.tool_type)
|
executed.append(block.tool_type)
|
||||||
return block.tool_type, {"output": "ok", "exit_code": 0,
|
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
|
||||||
"materialized_artifacts": ["/tmp_workspace/results"]
|
return block.tool_type, {'output': 'ok', 'exit_code': 0,
|
||||||
if block.tool_type == "python" and "out.md" in block.content else []}
|
'materialized_artifacts': ['/tmp_workspace/results']}
|
||||||
|
return block.tool_type, {"output": "ok", "exit_code": 0}
|
||||||
|
|
||||||
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
|
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
|
||||||
monkeypatch.setattr(module, "execute_tool_block", execute)
|
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)
|
executed.append(block.tool_type)
|
||||||
if block.tool_type == "web_search":
|
if block.tool_type == "web_search":
|
||||||
now[0] = 450.0
|
now[0] = 450.0
|
||||||
return block.tool_type, {"output": "ok", "exit_code": 0,
|
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
|
||||||
"materialized_artifacts": ["/tmp_workspace/results"]
|
return block.tool_type, {'output': 'ok', 'exit_code': 0,
|
||||||
if block.tool_type == "python" and "out.md" in block.content else []}
|
'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.time, "monotonic", lambda: now[0])
|
||||||
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
|
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
|
@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
|
import src.clean_agent_preview as module
|
||||||
arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"})
|
arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"})
|
||||||
responses = iter([
|
responses = iter([
|
||||||
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": {
|
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": {
|
||||||
"name": "inspect_media", "arguments": arguments,
|
"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."}}]}])
|
] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}])
|
||||||
|
|
||||||
class Response:
|
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 'tools' not in requests[3]
|
||||||
assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower()
|
assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower()
|
||||||
final = [event for event in events if event.get("type") == "final_response"]
|
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')
|
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
|
@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.",
|
"I'll browse IKEA and look at their chairs.",
|
||||||
'The most epic option is the DYVLINGE swivel chair.',
|
'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 ['<think>', 'abc', '</think>', '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 ['<think>a', '</thi', 'nk>', '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 '</thi' is never leaked
|
||||||
|
assert answer == 'B'
|
||||||
|
assert all(e.get('delta') for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen_think_split_is_inert_without_a_think_block():
|
||||||
|
from src.clean_agent_preview import qwen_think_split_events
|
||||||
|
|
||||||
|
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
|
||||||
|
assert qwen_think_split_events('plain answer', state) == []
|
||||||
|
assert qwen_think_split_events('', state) == []
|
||||||
|
assert state == {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
|
||||||
|
|
||||||
|
# source-level: stream_preview wires the incremental split
|
||||||
|
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
|
||||||
|
assert 'qwen_think_split_events(' in source
|
||||||
|
assert "think_answer_streamed" in source
|
||||||
|
|||||||
@@ -17,6 +17,42 @@ ERROR = 'event: error\ndata: {"status": 504, "error": {"message": "stream timeou
|
|||||||
DONE = 'data: [DONE]\n\n'
|
DONE = 'data: [DONE]\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_answer_deltas_are_visible_before_terminal_completion_check():
|
||||||
|
finish = asyncio.Event()
|
||||||
|
|
||||||
|
@with_completion_gate
|
||||||
|
async def stream(messages):
|
||||||
|
yield _event({'delta': 'Live answer starts. '})
|
||||||
|
await finish.wait()
|
||||||
|
yield _event({'type': 'metrics', 'data': {}})
|
||||||
|
yield DONE
|
||||||
|
|
||||||
|
output = stream([])
|
||||||
|
first = await asyncio.wait_for(anext(output), timeout=0.2)
|
||||||
|
assert json.loads(first[6:])['delta'] == 'Live answer starts. '
|
||||||
|
finish.set()
|
||||||
|
remaining = [chunk async for chunk in output]
|
||||||
|
assert _decision([first, *remaining])['can_complete'] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_completion_replacement_replaces_already_streamed_answer_for_turn():
|
||||||
|
@with_completion_gate
|
||||||
|
async def stream(messages):
|
||||||
|
yield _event({'delta': 'The function returns a boolean. I ran pytest and all tests passed.'})
|
||||||
|
yield _event({'type': 'metrics', 'data': {}})
|
||||||
|
yield DONE
|
||||||
|
|
||||||
|
chunks = [chunk async for chunk in stream([{'role': 'user', 'content': 'Explain this code.'}])]
|
||||||
|
frames = [data for event, data in _frames(chunks) if event == 'message' and isinstance(data, dict)]
|
||||||
|
assert any(item.get('delta') == 'The function returns a boolean.' for item in frames)
|
||||||
|
assert not any('I ran pytest' in str(item.get('delta', '')) for item in frames)
|
||||||
|
replacement = next(item for item in frames if item.get('type') == 'final_response')
|
||||||
|
assert 'I ran pytest' not in replacement['content']
|
||||||
|
assert replacement['replacement_scope'] == 'turn'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize('terminal_texts', [[], ['', ''], ['Recovered answer with literal [DONE] text.']])
|
@pytest.mark.parametrize('terminal_texts', [[], ['', ''], ['Recovered answer with literal [DONE] text.']])
|
||||||
async def test_terminal_round_retraction_does_not_resurrect_buffered_drafts(terminal_texts):
|
async def test_terminal_round_retraction_does_not_resurrect_buffered_drafts(terminal_texts):
|
||||||
|
|||||||
@@ -338,8 +338,8 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
|
|||||||
assert str(store) not in listed
|
assert str(store) not in listed
|
||||||
assert resources._control_plane_path(str(alias), snapshot=snapshot) is True
|
assert resources._control_plane_path(str(alias), snapshot=snapshot) is True
|
||||||
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
|
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
|
||||||
# Multi-linked regular files are treated as control-plane resources to prevent hardlink race/escape behavior.
|
# Multiply linked files elsewhere stay ordinary.
|
||||||
elsewhere = tmp_path / "other.txt"
|
elsewhere = tmp_path / "other.txt"
|
||||||
elsewhere.write_text("y")
|
elsewhere.write_text("y")
|
||||||
os.link(elsewhere, workspace / "pnpm-style.txt")
|
os.link(elsewhere, workspace / "pnpm-style.txt")
|
||||||
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
|
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is False
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ def _chat_stream_endpoint(
|
|||||||
endpoint_url="https://selected.example/v1",
|
endpoint_url="https://selected.example/v1",
|
||||||
session_model="selected-model",
|
session_model="selected-model",
|
||||||
session_history=(),
|
session_history=(),
|
||||||
|
attachment_meta=(),
|
||||||
):
|
):
|
||||||
def add_message(message):
|
def add_message(message):
|
||||||
captured.setdefault("added_messages", []).append(message)
|
captured.setdefault("added_messages", []).append(message)
|
||||||
@@ -172,7 +173,7 @@ def _chat_stream_endpoint(
|
|||||||
{"role": "assistant", "content": "old answer"},
|
{"role": "assistant", "content": "old answer"},
|
||||||
{"role": "user", "content": "hello"},
|
{"role": "user", "content": "hello"},
|
||||||
],
|
],
|
||||||
preprocessed=SimpleNamespace(attachment_meta=[]),
|
preprocessed=SimpleNamespace(attachment_meta=list(attachment_meta)),
|
||||||
auto_opened_docs=[],
|
auto_opened_docs=[],
|
||||||
rag_sources=[],
|
rag_sources=[],
|
||||||
web_sources=[],
|
web_sources=[],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.llm_core import _supports_thinking
|
from src.llm_core import _supports_thinking
|
||||||
|
from src.model_profiles import supports_user_thinking_toggle
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -25,3 +26,16 @@ def test_deepseek_v4_models_support_thinking(model):
|
|||||||
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
|
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
|
||||||
def test_other_deepseek_models_are_not_promoted_to_thinking(model):
|
def test_other_deepseek_models_are_not_promoted_to_thinking(model):
|
||||||
assert _supports_thinking(model) is False
|
assert _supports_thinking(model) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"model",
|
||||||
|
[
|
||||||
|
"deepseek-v4",
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"DeepSeek-V4-Flash",
|
||||||
|
"deepseek/deepseek-v4-flash",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_deepseek_v4_models_expose_the_user_thinking_toggle(model):
|
||||||
|
assert supports_user_thinking_toggle(model) is True
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
|
|||||||
|
|
||||||
mdToHtml defers math to a document-scoped flush, which cannot reach a
|
mdToHtml defers math to a document-scoped flush, which cannot reach a
|
||||||
detached node, so the export has to typeset its own container before
|
detached node, so the export has to typeset its own container before
|
||||||
handing it to browser printing. This is that container: pending spans in, real
|
handing it to html2pdf. This is that container: pending spans in, real
|
||||||
KaTeX markup out, no .katex-error and nothing left pending.
|
KaTeX markup out, no .katex-error and nothing left pending.
|
||||||
"""
|
"""
|
||||||
out = _run_node(
|
out = _run_node(
|
||||||
@@ -400,7 +400,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
|
|||||||
assert "ody-math-pending" not in out["written"]
|
assert "ody-math-pending" not in out["written"]
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_export_typesets_its_container_before_print():
|
def test_pdf_export_typesets_its_container_before_html2pdf():
|
||||||
"""Ordering in a call site, so pin the call site. No node needed."""
|
"""Ordering in a call site, so pin the call site. No node needed."""
|
||||||
source = document_source()
|
source = document_source()
|
||||||
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
||||||
@@ -410,7 +410,7 @@ def test_pdf_export_typesets_its_container_before_print():
|
|||||||
render = "await markdownModule.renderMath(container);"
|
render = "await markdownModule.renderMath(container);"
|
||||||
assert render in body, "the export never typesets its detached container"
|
assert render in body, "the export never typesets its detached container"
|
||||||
assert body.index("container.innerHTML = html;") < body.index(render)
|
assert body.index("container.innerHTML = html;") < body.index(render)
|
||||||
assert body.index(render) < body.index("frame.contentWindow.print()")
|
assert body.index(render) < body.index("window.html2pdf()")
|
||||||
|
|
||||||
|
|
||||||
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
|
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Model-picker backend: a slow/dead endpoint must not stall /api/models refresh.
|
||||||
|
|
||||||
|
Covers the fixes in routes/model_routes.py:
|
||||||
|
- the local endpoint refresh timeout default is 2s (was 10s);
|
||||||
|
- a recently failed/timed-out endpoint keeps a ~60s cooldown even on a
|
||||||
|
user-forced refresh, so a forced refresh does not re-probe a dead endpoint
|
||||||
|
on every picker open;
|
||||||
|
- probing is parallel, so one slow endpoint does not push the whole refresh
|
||||||
|
past its own timeout.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from tests.helpers.import_state import (
|
||||||
|
clear_fake_endpoint_resolver_modules,
|
||||||
|
preserve_import_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model_routes():
|
||||||
|
with preserve_import_state(
|
||||||
|
"core.database", "src.database", "core.session_manager", "routes.model_routes"
|
||||||
|
):
|
||||||
|
clear_fake_endpoint_resolver_modules()
|
||||||
|
if "core.database" not in sys.modules:
|
||||||
|
stub = types.ModuleType("core.database")
|
||||||
|
for name in (
|
||||||
|
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||||
|
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note", "CalendarCal",
|
||||||
|
"CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
|
||||||
|
"ProviderAuthSession", "Base",
|
||||||
|
):
|
||||||
|
setattr(stub, name, MagicMock())
|
||||||
|
stub.utcnow_naive = MagicMock()
|
||||||
|
sys.modules["core.database"] = stub
|
||||||
|
import routes.model_routes as model_routes
|
||||||
|
return model_routes
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(**kw):
|
||||||
|
base = dict(
|
||||||
|
id="ep", base_url="http://127.0.0.1:9/v1", api_key="k",
|
||||||
|
provider_auth_id=None, cached_models="[]", pinned_models=[],
|
||||||
|
is_enabled=True, model_refresh_mode="auto",
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_refresh_timeout_is_two_seconds():
|
||||||
|
model_routes = _load_model_routes()
|
||||||
|
ep = _endpoint(base_url="http://100.67.207.85:8071/v1")
|
||||||
|
assert model_routes._endpoint_refresh_timeout(ep, "local") == 2.0
|
||||||
|
assert model_routes._endpoint_refresh_timeout(ep, "remote") == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_failure_is_not_reprobed_even_when_forced():
|
||||||
|
model_routes = _load_model_routes()
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
should = router._should_refresh_endpoint
|
||||||
|
ep = _endpoint(id="remote-dead", base_url="http://100.67.207.85:8071/v1")
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
ok, info = should(ep, now, force=True)
|
||||||
|
assert ok is True and info.get("base")
|
||||||
|
key = info["key"]
|
||||||
|
|
||||||
|
# A failure one second ago keeps a 60s cooldown even on a forced refresh.
|
||||||
|
router._refresh_state[key] = {
|
||||||
|
"fail_count": 1, "last_failure": now - 1.0, "inflight": False,
|
||||||
|
}
|
||||||
|
ok_recent, _ = should(ep, time.time(), force=True)
|
||||||
|
assert ok_recent is False
|
||||||
|
|
||||||
|
# A failure long past the 60s floor is re-probed again.
|
||||||
|
router._refresh_state[key] = {
|
||||||
|
"fail_count": 1, "last_failure": now - 120.0, "inflight": False,
|
||||||
|
}
|
||||||
|
ok_old, _ = should(ep, time.time(), force=True)
|
||||||
|
assert ok_old is True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, rows, first_value):
|
||||||
|
self._rows = rows
|
||||||
|
self._first = first_value
|
||||||
|
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return self._first
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self, rows, first_value):
|
||||||
|
self._rows = rows
|
||||||
|
self._first = first_value
|
||||||
|
self.committed = False
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
return _FakeQuery(self._rows, self._first)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_slow_endpoint_does_not_stall_the_forced_refresh(monkeypatch):
|
||||||
|
model_routes = _load_model_routes()
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
healthy = _endpoint(id="healthy", base_url="http://127.0.0.1:7021/v1")
|
||||||
|
dead = _endpoint(id="dead", base_url="http://127.0.0.1:9/v1")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDB([healthy, dead], healthy))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fake_probe(base, api_key=None, timeout=5):
|
||||||
|
if "127.0.0.1:9" in base:
|
||||||
|
time.sleep(min(float(timeout or 2), 2.0))
|
||||||
|
raise RuntimeError("timed out")
|
||||||
|
return ["healthy-model"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", _fake_probe)
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
done = router._refresh_caches_bg(force=True)
|
||||||
|
assert done is not None
|
||||||
|
assert done.wait(5) is True
|
||||||
|
elapsed = time.time() - started
|
||||||
|
|
||||||
|
assert elapsed <= 3.0, f"forced refresh took {elapsed:.2f}s"
|
||||||
|
assert json.loads(healthy.cached_models) == ["healthy-model"]
|
||||||
@@ -8,6 +8,6 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
|
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
|
||||||
source = document_source()
|
source = document_source()
|
||||||
|
|
||||||
assert "if (!isForm && (_isDocxLang(lang) || typeof window.print === 'function')) {" in source
|
assert "if (!isForm) {" in source
|
||||||
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print / save PDF'" in source
|
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
|
||||||
assert "destroy the original page layout, images, and form structure" in source
|
assert "destroy the original page layout, images, and form structure" in source
|
||||||
|
|||||||
@@ -240,7 +240,8 @@ def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
|
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
|
||||||
"""Model/route fallback cannot bypass caller-disabled tools; denials persist."""
|
"""The answering candidate's notes mode must unblock the managers for
|
||||||
|
execution, not just enable them in its own route schemas."""
|
||||||
|
|
||||||
_install_route_probe(monkeypatch)
|
_install_route_probe(monkeypatch)
|
||||||
stream_round = 0
|
stream_round = 0
|
||||||
@@ -298,9 +299,8 @@ def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Caller hard denials are absolute: route fallback cannot re-enable disabled tools.
|
assert seen_exec["disabled_tools"].isdisjoint(
|
||||||
assert {"manage_notes", "manage_calendar", "manage_tasks"}.issubset(
|
{"manage_notes", "manage_calendar", "manage_tasks"}
|
||||||
seen_exec["disabled_tools"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Regression guard for the README title presentation.
|
"""Regression guard for the README title presentation.
|
||||||
|
|
||||||
The original ASCII-art banner needed a code fence to preserve whitespace.
|
Originally (#1390) the README opened with an ASCII-art banner that had to live
|
||||||
Keep the wordmark title identity and the historical ASCII-fencing guard.
|
inside a ``` code fence, otherwise GitHub's markdown collapsed its leading
|
||||||
|
whitespace and box-drawing rules and rendered it misaligned. The README refresh
|
||||||
|
(#4306) dropped that banner in favour of a centered wordmark image, so the guard
|
||||||
|
now pins the wordmark identity instead, while still catching the original failure
|
||||||
|
mode if an un-fenced ASCII banner is ever reintroduced.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,6 +23,8 @@ def _fenced_segments(text: str):
|
|||||||
|
|
||||||
|
|
||||||
def test_readme_opens_with_wordmark_title():
|
def test_readme_opens_with_wordmark_title():
|
||||||
|
# The README must still open with a recognizable Odysseus title: now the
|
||||||
|
# centered wordmark image rather than an H1 / ASCII banner.
|
||||||
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
|
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
|
||||||
assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
|
assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
|
||||||
|
|
||||||
|
|||||||
@@ -64,12 +64,57 @@ def test_explicit_request_classes_remain_narrow(user_text, allowed, denied):
|
|||||||
assert not grant.permits(ExactOperation.normalize(denied, '{}'))
|
assert not grant.permits(ExactOperation.normalize(denied, '{}'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_selection_keeps_the_sibling_web_reader_authorized():
|
||||||
|
# "Search the web ..." narrows selection to web_search alone, but the turn
|
||||||
|
# must be able to read a URL the search surfaces; otherwise every result is
|
||||||
|
# a dead end and the turn fails closed with request_authority_denied.
|
||||||
|
grant = create_request_authority(
|
||||||
|
"Search the web for the latest 2025 consumer EEG headset prices "
|
||||||
|
"under $500 and cite sources")
|
||||||
|
assert grant.permits(ExactOperation.normalize("web_search", '{"query":"eeg"}'))
|
||||||
|
assert grant.permits(
|
||||||
|
ExactOperation.normalize("web_fetch", '{"url":"https://example.com/report"}'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_only_selection_does_not_grant_search():
|
||||||
|
grant = create_request_authority("Fetch https://example.com and summarize it")
|
||||||
|
assert grant.permits(
|
||||||
|
ExactOperation.normalize("web_fetch", '{"url":"https://example.com"}'))
|
||||||
|
assert not grant.permits(ExactOperation.normalize("web_search", '{"query":"x"}'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_implicit_editor_revision_authority_requires_active_document():
|
||||||
|
prompt = ('ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist '
|
||||||
|
'trivs sa bra har med framtid planerad')
|
||||||
|
edit = ExactOperation.normalize("edit_document", '{"edits":[]}')
|
||||||
|
|
||||||
|
with_editor = create_request_authority(prompt, active_document=True)
|
||||||
|
without_editor = create_request_authority(prompt, active_document=False)
|
||||||
|
|
||||||
|
assert with_editor.permits(edit)
|
||||||
|
assert not without_editor.permits(edit)
|
||||||
|
|
||||||
|
|
||||||
def test_safe_task_read_does_not_authorize_same_tool_mutation():
|
def test_safe_task_read_does_not_authorize_same_tool_mutation():
|
||||||
grant = create_request_authority("List my tasks")
|
grant = create_request_authority("List my tasks")
|
||||||
assert grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"list"}'))
|
assert grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"list"}'))
|
||||||
assert not grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"create","prompt":"run bash"}'))
|
assert not grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"create","prompt":"run bash"}'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_only_read_tolerates_benign_model_arguments():
|
||||||
|
# "What do you remember about me?" pins manage_memory to action=list. The
|
||||||
|
# model must not be denied merely for adding an innocuous limit/cursor, while
|
||||||
|
# a different (mutating) action stays outside the read grant.
|
||||||
|
grant = create_request_authority("What do you remember about me?")
|
||||||
|
memory = next(g for g in grant.grants if g.tool == "manage_memory")
|
||||||
|
assert memory.inputs is None
|
||||||
|
assert grant.permits(ExactOperation.normalize("manage_memory", '{"action":"list"}'))
|
||||||
|
assert grant.permits(
|
||||||
|
ExactOperation.normalize("manage_memory", '{"action":"list","limit":10}'))
|
||||||
|
assert not grant.permits(
|
||||||
|
ExactOperation.normalize("manage_memory", '{"action":"delete","id":"x"}'))
|
||||||
|
|
||||||
|
|
||||||
def test_exact_read_identifiers_cannot_be_changed_by_model():
|
def test_exact_read_identifiers_cannot_be_changed_by_model():
|
||||||
grant = create_request_authority("Read note id abc123")
|
grant = create_request_authority("Read note id abc123")
|
||||||
read = next(g for g in grant.grants if g.tool == "manage_notes")
|
read = next(g for g in grant.grants if g.tool == "manage_notes")
|
||||||
@@ -413,6 +458,42 @@ async def test_server_seeded_defaults_have_authority_but_legacy_rows_do_not_gain
|
|||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsealed_housekeeping_row_is_replaced_with_an_authorized_copy(monkeypatch, tmp_path):
|
||||||
|
"""A built-in row that predates the authority seal (NULL snapshot) must be
|
||||||
|
replaced by a freshly seeded, authorized copy, not left unrunnable. This is
|
||||||
|
the 'Email Tags' -> '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
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch):
|
async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch):
|
||||||
from src import tool_execution as execution
|
from src import tool_execution as execution
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def run_terminal_branch(monkeypatch, native_calls, result, operation=None, *, ma
|
|||||||
_required_read_native_id=al._required_read_native_id,
|
_required_read_native_id=al._required_read_native_id,
|
||||||
_dispatch_required_safe_read=al._dispatch_required_safe_read,
|
_dispatch_required_safe_read=al._dispatch_required_safe_read,
|
||||||
_compute_final_metrics=lambda *a, **kw: {"tool_events": a[8]},
|
_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,
|
owner="fixture", workspace=None, run_security=context, active_document=None,
|
||||||
client_runtime_context=None, round_num=1, model="fixture-model",
|
client_runtime_context=None, round_num=1, model="fixture-model",
|
||||||
actual_endpoint_id="fixture-endpoint", actual_endpoint_label="fixture",
|
actual_endpoint_id="fixture-endpoint", actual_endpoint_label="fixture",
|
||||||
|
|||||||
@@ -549,3 +549,34 @@ def test_host_shell_native_arguments_use_the_same_verifier_classification(comman
|
|||||||
encoded = json.dumps({'command': command, 'timeout': 120})
|
encoded = json.dumps({'command': command, 'timeout': 120})
|
||||||
assert command_is_test(encoded) is expected
|
assert command_is_test(encoded) is expected
|
||||||
assert command_is_validation(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
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Session-load 404 regression (passover item #9).
|
||||||
|
|
||||||
|
Loading a chat session used to fire two benign 404s in the browser console:
|
||||||
|
GET /api/research/status/<id> (no research for this session)
|
||||||
|
GET /api/chat/stream_status/<id> (no active stream for this session)
|
||||||
|
|
||||||
|
Both are now a normal "empty" state: HTTP 200 with an explicit status
|
||||||
|
("none" / "idle"). The clients already branch on the body status, so this is
|
||||||
|
backward compatible (an older server's 404 is still tolerated).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from routes import chat_routes
|
||||||
|
from routes import research_routes
|
||||||
|
|
||||||
|
|
||||||
|
def _route_endpoint(router, path, method="GET"):
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") != path:
|
||||||
|
continue
|
||||||
|
if method in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"{method} {path} not registered")
|
||||||
|
|
||||||
|
|
||||||
|
def _req(user="alice"):
|
||||||
|
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||||
|
|
||||||
|
|
||||||
|
# --- /api/research/status/<id> -------------------------------------------
|
||||||
|
|
||||||
|
def test_research_status_returns_200_none_when_absent(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
|
||||||
|
handler = MagicMock()
|
||||||
|
handler._active_tasks = {}
|
||||||
|
router = research_routes.setup_research_routes(handler)
|
||||||
|
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
|
||||||
|
|
||||||
|
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
|
||||||
|
|
||||||
|
assert out == {"status": "none"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_status_returns_status_when_present(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
|
||||||
|
handler = MagicMock()
|
||||||
|
handler._active_tasks = {"sess-abc": {"owner": "alice"}}
|
||||||
|
handler.get_status.return_value = {"status": "running", "progress": {"phase": "searching"}}
|
||||||
|
handler.get_source_state.return_value = None
|
||||||
|
handler.get_source_coverage.return_value = None
|
||||||
|
handler.get_navigation_trace.return_value = None
|
||||||
|
handler.get_action_trace.return_value = None
|
||||||
|
router = research_routes.setup_research_routes(handler)
|
||||||
|
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
|
||||||
|
|
||||||
|
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
|
||||||
|
|
||||||
|
assert out["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
# --- /api/chat/stream_status/<id> ----------------------------------------
|
||||||
|
|
||||||
|
def _chat_stream_status_endpoint(monkeypatch):
|
||||||
|
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda request, session_id: None)
|
||||||
|
router = chat_routes.setup_chat_routes(
|
||||||
|
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
||||||
|
)
|
||||||
|
return _route_endpoint(router, "/api/chat/stream_status/{session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_status_returns_200_idle_when_no_stream(monkeypatch):
|
||||||
|
endpoint = _chat_stream_status_endpoint(monkeypatch)
|
||||||
|
chat_routes._active_streams.pop("sess-idle", None)
|
||||||
|
|
||||||
|
out = asyncio.run(endpoint(SimpleNamespace(), "sess-idle"))
|
||||||
|
|
||||||
|
assert out == {"status": "idle"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_status_returns_record_when_streaming(monkeypatch):
|
||||||
|
endpoint = _chat_stream_status_endpoint(monkeypatch)
|
||||||
|
chat_routes._active_streams["sess-live"] = {"status": "streaming", "mode": "agent"}
|
||||||
|
try:
|
||||||
|
out = asyncio.run(endpoint(SimpleNamespace(), "sess-live"))
|
||||||
|
finally:
|
||||||
|
chat_routes._active_streams.pop("sess-live", None)
|
||||||
|
|
||||||
|
assert out["status"] == "streaming"
|
||||||
|
|
||||||
|
|
||||||
|
# --- client compatibility (source-level) ---------------------------------
|
||||||
|
|
||||||
|
def test_chat_watchdog_accepts_200_idle_body():
|
||||||
|
src = inspect.getsource(chat_routes) # ensure chat_routes imported
|
||||||
|
chatjs = open("static/js/chat.js", encoding="utf-8").read()
|
||||||
|
# The stale-stream watchdog must key off the body status, not only a 404.
|
||||||
|
assert "_noActiveStream" in chatjs
|
||||||
|
assert "_probe.status !== 'streaming'" in chatjs
|
||||||
|
assert src # keep flake happy
|
||||||
|
|
||||||
|
|
||||||
|
def test_sessions_check_uses_body_status_not_ok_only():
|
||||||
|
sj = open("static/js/sessions.js", encoding="utf-8").read()
|
||||||
|
assert "info.status !== 'streaming'" in sj
|
||||||
|
assert "status !== 'streaming'" in sj
|
||||||
@@ -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
|
||||||
@@ -328,6 +328,30 @@ def test_conversational_request_wrappers_preserve_explicit_family(message, expec
|
|||||||
assert requested_capabilities(message) == expected
|
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", [
|
@pytest.mark.parametrize("message", [
|
||||||
"can u also pop the notes panel open for me?",
|
"can u also pop the notes panel open for me?",
|
||||||
"k leave it for now - also pop open the calendar panel",
|
"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/input/video.mp4,为每款零食截图并拼成清单图,"
|
||||||
"输出到 /workspace/snack_checklist.jpg,交付前打开成品图检查。"
|
"输出到 /workspace/snack_checklist.jpg,交付前打开成品图检查。"
|
||||||
) == {"media_inspection", "shell_files"}
|
) == {"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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user