mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-17 04:12:24 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bdea753ae | ||
|
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
|||||||
from src.tool_approval_scopes import (
|
from src.tool_approval_scopes import (
|
||||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||||
CHAT_SESSION_APPROVAL_DECISION,
|
CHAT_SESSION_APPROVAL_DECISION,
|
||||||
CHAT_SESSION_APPROVAL_SIGNATURE_FIELD,
|
|
||||||
verify_chat_session_grant,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -62,14 +60,6 @@ def _history_grants_chat_session_approval(
|
|||||||
ask_user.get("kind") == "tool_approval"
|
ask_user.get("kind") == "tool_approval"
|
||||||
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
||||||
and str(ask_user.get("session_id") or "") == expected_session
|
and str(ask_user.get("session_id") or "") == expected_session
|
||||||
# Shape proves nothing here: routes that accept a
|
|
||||||
# caller-supplied metadata blob write into this same history.
|
|
||||||
and verify_chat_session_grant(
|
|
||||||
ask_user.get(CHAT_SESSION_APPROVAL_SIGNATURE_FIELD),
|
|
||||||
expected_session,
|
|
||||||
ask_user.get("approval_id"),
|
|
||||||
CHAT_SESSION_APPROVAL_DECISION,
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
+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"}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.endpoint_resolver import resolve_owner_registered_endpoint_url
|
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.task_scheduler import compute_next_run
|
from src.task_scheduler import compute_next_run
|
||||||
|
|
||||||
@@ -179,13 +178,7 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
|
|||||||
if payload.model is not None:
|
if payload.model is not None:
|
||||||
crew_db.model = payload.model or None
|
crew_db.model = payload.model or None
|
||||||
if payload.endpoint_url is not None:
|
if payload.endpoint_url is not None:
|
||||||
try:
|
crew_db.endpoint_url = payload.endpoint_url or None
|
||||||
crew_db.endpoint_url = (
|
|
||||||
resolve_owner_registered_endpoint_url(db, payload.endpoint_url, owner)
|
|
||||||
if payload.endpoint_url else None
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(400, str(exc)) from exc
|
|
||||||
if payload.timezone is not None:
|
if payload.timezone is not None:
|
||||||
crew_db.timezone = payload.timezone or None
|
crew_db.timezone = payload.timezone or None
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -98,9 +98,7 @@ def clean_repeated_assistant_content(text: object) -> str:
|
|||||||
"",
|
"",
|
||||||
value,
|
value,
|
||||||
).strip()
|
).strip()
|
||||||
# No leading `\s*`: .strip() removes that whitespace anyway, and scanning
|
value = re.sub(r"(?is)\s*</\s*think\s*>\s*$", "", value).strip()
|
||||||
# it from every offset of a long whitespace run was quadratic (ReDoS).
|
|
||||||
value = re.sub(r"(?is)</\s*think\s*>\s*$", "", value).strip()
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
_CASUAL_OPENING_RE = re.compile(
|
_CASUAL_OPENING_RE = re.compile(
|
||||||
@@ -1326,17 +1324,13 @@ def _normalize_thinking(text: str) -> str:
|
|||||||
|
|
||||||
# Handle garbled <think> tags: reasoning text followed by <think> as separator
|
# Handle garbled <think> tags: reasoning text followed by <think> as separator
|
||||||
# e.g. "The user said...I should respond.\n<think>Hey! What's up?"
|
# e.g. "The user said...I should respond.\n<think>Hey! What's up?"
|
||||||
# Linear form of `^([\s\S]+?)\n*<think>\s*([\s\S]*?)(?:</think>)?\s*$`:
|
|
||||||
# the lookbehind stops the lazy prefix re-scanning a newline run from every
|
|
||||||
# offset, and the optional trailing closer is dropped after the match
|
|
||||||
# instead of being retried at every body offset (both were quadratic).
|
|
||||||
garbled = re.match(
|
garbled = re.match(
|
||||||
r'^([\s\S]+?)(?<![\s\S]\n)\n*<think(?:ing)?>\s*([\s\S]*)$',
|
r'^([\s\S]+?)\n*<think(?:ing)?>\s*([\s\S]*?)(?:</think(?:ing)?>)?\s*$',
|
||||||
text, re.IGNORECASE
|
text, re.IGNORECASE
|
||||||
)
|
)
|
||||||
if garbled:
|
if garbled:
|
||||||
before = garbled.group(1).strip()
|
before = garbled.group(1).strip()
|
||||||
after = re.sub(r'</think(?:ing)?>$', '', garbled.group(2).rstrip(), flags=re.IGNORECASE).strip()
|
after = garbled.group(2).strip()
|
||||||
# Only treat as garbled if the part before <think> looks like reasoning
|
# Only treat as garbled if the part before <think> looks like reasoning
|
||||||
reasoning_starts = (
|
reasoning_starts = (
|
||||||
'The user ', 'I need ', 'I should ', 'I will ',
|
'The user ', 'I need ', 'I should ', 'I will ',
|
||||||
|
|||||||
+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:])
|
||||||
|
|||||||
@@ -701,17 +701,13 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
repo_id = "cyankiwi/MiniMax-M3-AWQ-INT4"
|
repo_id = "cyankiwi/MiniMax-M3-AWQ-INT4"
|
||||||
hf_home = Path(os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface")))
|
snapshot = (
|
||||||
hf_cache = Path(os.environ.get("HUGGINGFACE_HUB_CACHE", str(hf_home / "hub")))
|
"/home/pewds/.cache/huggingface/hub/"
|
||||||
snapshot_root = hf_cache / "models--cyankiwi--MiniMax-M3-AWQ-INT4" / "snapshots"
|
"models--cyankiwi--MiniMax-M3-AWQ-INT4/"
|
||||||
if body[serve_i + 1] == repo_id and snapshot_root.is_dir():
|
"snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b"
|
||||||
installed_snapshots = sorted(
|
)
|
||||||
(p for p in snapshot_root.iterdir() if p.is_dir()),
|
if body[serve_i + 1] == repo_id:
|
||||||
key=lambda p: p.stat().st_mtime,
|
body[serve_i + 1] = snapshot
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
if installed_snapshots:
|
|
||||||
body[serve_i + 1] = str(installed_snapshots[0])
|
|
||||||
|
|
||||||
def add_env(key: str, value: str) -> None:
|
def add_env(key: str, value: str) -> None:
|
||||||
if not any(p.startswith(f"{key}=") for p in env_parts):
|
if not any(p.startswith(f"{key}=") for p in env_parts):
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||||
from src.auth_helpers import effective_user, require_chat_api_token_scope
|
from src.auth_helpers import effective_user
|
||||||
from src.topic_analyzer import analyze_topics
|
from src.topic_analyzer import analyze_topics
|
||||||
from src.upload_handler import reserve_message_upload_references
|
from src.upload_handler import reserve_message_upload_references
|
||||||
from src.tool_approval_scopes import sanitize_client_message_metadata
|
|
||||||
from routes.session_routes import (
|
from routes.session_routes import (
|
||||||
_message_role,
|
_message_role,
|
||||||
_message_text,
|
_message_text,
|
||||||
@@ -238,10 +237,7 @@ def _keep_count_before_message(db_messages, before_msg_id: str | None) -> int |
|
|||||||
|
|
||||||
|
|
||||||
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||||
router = APIRouter(
|
router = APIRouter(tags=["history"])
|
||||||
tags=["history"],
|
|
||||||
dependencies=[Depends(require_chat_api_token_scope)],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _reserve_message_uploads(
|
def _reserve_message_uploads(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -436,7 +432,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
content = body.get("content", "")
|
content = body.get("content", "")
|
||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(400, "content is required")
|
raise HTTPException(400, "content is required")
|
||||||
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
metadata = body.get("metadata")
|
||||||
_reserve_message_uploads(request, content, metadata)
|
_reserve_message_uploads(request, content, metadata)
|
||||||
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||||
session_manager.add_message(session_id, msg)
|
session_manager.add_message(session_id, msg)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -43,22 +43,11 @@ def setup_search_routes(config) -> APIRouter:
|
|||||||
router = APIRouter(tags=["search"])
|
router = APIRouter(tags=["search"])
|
||||||
|
|
||||||
@router.get("/search/web", response_class=HTMLResponse)
|
@router.get("/search/web", response_class=HTMLResponse)
|
||||||
async def web_search_page(request: Request, q: str = Query("", min_length=0)) -> HTMLResponse:
|
async def web_search_page(q: str = Query("", min_length=0)) -> HTMLResponse:
|
||||||
"""Browser-facing search results page for clickable agent web_search rows."""
|
"""Browser-facing search results page for clickable agent web_search rows."""
|
||||||
# The site CSP allows inline script only with the per-request nonce
|
|
||||||
# (SecurityHeadersMiddleware); without it this page's script never ran.
|
|
||||||
nonce = html.escape(getattr(request.state, "csp_nonce", "") or "", quote=True)
|
|
||||||
safe_q = str(q or "").strip()
|
safe_q = str(q or "").strip()
|
||||||
title = html.escape(safe_q or "Web search")
|
title = html.escape(safe_q or "Web search")
|
||||||
# json.dumps leaves `<`, `>` and `&` alone, so q=</script><script ...>
|
q_json = json.dumps(safe_q)
|
||||||
# would close this inline <script> and inject markup (reflected XSS).
|
|
||||||
# Unicode-escape them; the JS string value is unchanged.
|
|
||||||
q_json = (
|
|
||||||
json.dumps(safe_q)
|
|
||||||
.replace("<", "\\u003c")
|
|
||||||
.replace(">", "\\u003e")
|
|
||||||
.replace("&", "\\u0026")
|
|
||||||
)
|
|
||||||
page = f"""<!doctype html>
|
page = f"""<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -90,7 +79,7 @@ def setup_search_routes(config) -> APIRouter:
|
|||||||
<div class="status" id="status">Loading...</div>
|
<div class="status" id="status">Loading...</div>
|
||||||
<div id="results"></div>
|
<div id="results"></div>
|
||||||
</main>
|
</main>
|
||||||
<script nonce="{nonce}">
|
<script>
|
||||||
const initialQuery = {q_json};
|
const initialQuery = {q_json};
|
||||||
const input = document.getElementById('query');
|
const input = document.getElementById('query');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
@@ -114,9 +103,7 @@ def setup_search_routes(config) -> APIRouter:
|
|||||||
}}
|
}}
|
||||||
statusEl.textContent = sources.length ? `${{sources.length}} results` : 'No results';
|
statusEl.textContent = sources.length ? `${{sources.length}} results` : 'No results';
|
||||||
resultsEl.innerHTML = sources.map(s => {{
|
resultsEl.innerHTML = sources.map(s => {{
|
||||||
const rawUrl = String(s.url || s.link || '');
|
const url = s.url || s.link || '';
|
||||||
// Result URLs come from third-party search results: only http(s) may become a link.
|
|
||||||
const url = /^https?:[/][/]/i.test(rawUrl) ? rawUrl : '';
|
|
||||||
const title = s.title || url || 'Untitled';
|
const title = s.title || url || 'Untitled';
|
||||||
const snippet = s.snippet || s.content || '';
|
const snippet = s.snippet || s.content || '';
|
||||||
return `<a class="result" href="${{esc(url)}}" target="_blank" rel="noopener noreferrer">
|
return `<a class="result" href="${{esc(url)}}" target="_blank" rel="noopener noreferrer">
|
||||||
|
|||||||
@@ -6,24 +6,17 @@ import uuid
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Form, HTTPException, Response, Request, Depends, Query
|
from fastapi import APIRouter, Form, HTTPException, Response, Request, Query
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from core.session_manager import SessionManager
|
from core.session_manager import SessionManager
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from src.request_models import SessionResponse
|
from src.request_models import SessionResponse
|
||||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||||
from src.auth_helpers import (
|
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
||||||
effective_user,
|
|
||||||
_auth_disabled,
|
|
||||||
owner_filter,
|
|
||||||
is_delegated_credential,
|
|
||||||
require_chat_api_token_scope,
|
|
||||||
)
|
|
||||||
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||||
from src.session_actions import is_session_recently_active
|
from src.session_actions import is_session_recently_active
|
||||||
from src.upload_handler import reserve_message_upload_references
|
from src.upload_handler import reserve_message_upload_references
|
||||||
from src.tool_approval_scopes import sanitize_client_message_metadata
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_export_filename(name: str) -> str:
|
def _sanitize_export_filename(name: str) -> str:
|
||||||
@@ -241,15 +234,9 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(prefix="/api", tags=["sessions"])
|
||||||
prefix="/api",
|
|
||||||
tags=["sessions"],
|
|
||||||
dependencies=[Depends(require_chat_api_token_scope)],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||||
if is_delegated_credential(request):
|
|
||||||
return False
|
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
@@ -280,22 +267,6 @@ def _reject_raw_endpoint_url_for_non_admin(
|
|||||||
raise HTTPException(403, "Choose a registered model endpoint")
|
raise HTTPException(403, "Choose a registered model endpoint")
|
||||||
|
|
||||||
|
|
||||||
def _reject_delegated_session_options(
|
|
||||||
request: Request,
|
|
||||||
*,
|
|
||||||
skip_validation: bool = False,
|
|
||||||
api_key: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Keep bearer credentials from exercising interactive-admin options."""
|
|
||||||
if is_delegated_credential(request) and (
|
|
||||||
skip_validation or bool((api_key or "").strip())
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
403,
|
|
||||||
"API tokens cannot supply endpoint credentials or skip endpoint validation",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _persist_session_headers(session_id: str, headers: dict | None) -> bool:
|
def _persist_session_headers(session_id: str, headers: dict | None) -> bool:
|
||||||
"""Persist endpoint auth headers for DB-backed session metadata."""
|
"""Persist endpoint auth headers for DB-backed session metadata."""
|
||||||
delays = (0.05, 0.15, 0.35)
|
delays = (0.05, 0.15, 0.35)
|
||||||
@@ -530,11 +501,6 @@ def setup_session_routes(
|
|||||||
):
|
):
|
||||||
skip_val = str(skip_validation).lower() == "true"
|
skip_val = str(skip_validation).lower() == "true"
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
_reject_delegated_session_options(
|
|
||||||
request,
|
|
||||||
skip_validation=skip_val,
|
|
||||||
api_key=api_key,
|
|
||||||
)
|
|
||||||
endpoint_api_key = ""
|
endpoint_api_key = ""
|
||||||
endpoint_base_url = ""
|
endpoint_base_url = ""
|
||||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||||
@@ -791,11 +757,7 @@ def setup_session_routes(
|
|||||||
except (AttributeError, TypeError, ValueError) as exc:
|
except (AttributeError, TypeError, ValueError) as exc:
|
||||||
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
||||||
for m in messages:
|
for m in messages:
|
||||||
sess.add_message(ChatMessage(
|
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
|
||||||
m["role"],
|
|
||||||
m["content"],
|
|
||||||
metadata=sanitize_client_message_metadata(m.get("metadata")),
|
|
||||||
))
|
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
return {"ok": True, "count": len(messages)}
|
return {"ok": True, "count": len(messages)}
|
||||||
|
|
||||||
@@ -1156,8 +1118,6 @@ def setup_session_routes(
|
|||||||
model: str = Form("gpt-4o"),
|
model: str = Form("gpt-4o"),
|
||||||
rag: str = Form(None)
|
rag: str = Form(None)
|
||||||
):
|
):
|
||||||
if is_delegated_credential(request):
|
|
||||||
raise HTTPException(403, "This session type requires an interactive session")
|
|
||||||
if not OPENAI_API_KEY:
|
if not OPENAI_API_KEY:
|
||||||
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
||||||
sid = str(uuid.uuid4())
|
sid = str(uuid.uuid4())
|
||||||
@@ -1601,13 +1561,7 @@ def setup_session_routes(
|
|||||||
raise HTTPException(404, "Session not found")
|
raise HTTPException(404, "Session not found")
|
||||||
skills = _context_info_skill_inventory(skills_manager, owner=owner)
|
skills = _context_info_skill_inventory(skills_manager, owner=owner)
|
||||||
tools = _context_info_tool_inventory()
|
tools = _context_info_tool_inventory()
|
||||||
# `cwd` is caller-chosen. Walking it for AGENTS.md and reporting whether
|
agents_md = _context_info_agents_md_inventory(cwd)
|
||||||
# it exists confirms paths on the host, which /api/workspace/browse and
|
|
||||||
# /vet reserve for admins (or single-user mode); keep that boundary here.
|
|
||||||
from src.tool_security import owner_is_admin_or_single_user
|
|
||||||
|
|
||||||
_host_paths_visible = owner_is_admin_or_single_user(owner)
|
|
||||||
agents_md = _context_info_agents_md_inventory(cwd) if _host_paths_visible else []
|
|
||||||
# Workspace visibility: lets the TUI answer "can the backend actually
|
# Workspace visibility: lets the TUI answer "can the backend actually
|
||||||
# see this directory?" (mounted vs bridge-only) without probing.
|
# see this directory?" (mounted vs bridge-only) without probing.
|
||||||
from src.workspace_paths import backend_workspace_path, workspace_mount_pairs
|
from src.workspace_paths import backend_workspace_path, workspace_mount_pairs
|
||||||
@@ -1617,22 +1571,19 @@ def setup_session_routes(
|
|||||||
# Server-side tool policy: non-admin owners silently lose the computer
|
# Server-side tool policy: non-admin owners silently lose the computer
|
||||||
# tools (src/tool_security); surface that so the TUI can show it.
|
# tools (src/tool_security); surface that so the TUI can show it.
|
||||||
try:
|
try:
|
||||||
from src.tool_security import blocked_tools_for_owner, delegated_credential_blocked_tools
|
from src.tool_security import blocked_tools_for_owner
|
||||||
|
|
||||||
_blocked = blocked_tools_for_owner(owner)
|
_blocked = blocked_tools_for_owner(owner)
|
||||||
if is_delegated_credential(request):
|
|
||||||
_blocked.update(delegated_credential_blocked_tools())
|
|
||||||
except Exception:
|
except Exception:
|
||||||
_blocked = set()
|
_blocked = set()
|
||||||
_computer = {"bash", "python", "read_file", "write_file", "host_shell"}
|
_computer = {"bash", "python", "read_file", "write_file", "host_shell"}
|
||||||
_policy = {
|
_policy = {
|
||||||
"computer_tools": "restricted" if _computer & _blocked else "full",
|
"computer_tools": "restricted" if _computer & _blocked else "full",
|
||||||
"reason": "API-token caller" if is_delegated_credential(request) else
|
"reason": "non-admin owner" if _blocked else "single-user or admin",
|
||||||
"non-admin owner" if _blocked else "single-user or admin",
|
|
||||||
}
|
}
|
||||||
_workspace = {
|
_workspace = {
|
||||||
"backend_path": _backend_cwd,
|
"backend_path": _backend_cwd,
|
||||||
"exists_in_backend": _host_paths_visible and bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
|
"exists_in_backend": bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
|
||||||
"mount_configured": bool(workspace_mount_pairs()),
|
"mount_configured": bool(workspace_mount_pairs()),
|
||||||
"via_mount": bool(_raw_cwd) and backend_workspace_path(_raw_cwd) != _raw_cwd,
|
"via_mount": bool(_raw_cwd) and backend_workspace_path(_raw_cwd) != _raw_cwd,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+15
-22
@@ -19,7 +19,7 @@ from pydantic import BaseModel, Field
|
|||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from src.text_helpers import strip_closed_think_blocks
|
from src.model_profiles import uses_odysseus_progressive_thinking
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -251,7 +251,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
|||||||
# Strip closed think blocks. If a <think> was opened but never closed
|
# Strip closed think blocks. If a <think> was opened but never closed
|
||||||
# (the model ran out of budget mid-reasoning), drop everything from it
|
# (the model ran out of budget mid-reasoning), drop everything from it
|
||||||
# onward so its stray braces don't poison JSON extraction.
|
# onward so its stray braces don't poison JSON extraction.
|
||||||
text = strip_closed_think_blocks(text)
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I)
|
||||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||||
|
|
||||||
def _coerce(d):
|
def _coerce(d):
|
||||||
@@ -348,6 +348,8 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
|||||||
url, model, msgs,
|
url, model, msgs,
|
||||||
temperature=0.1, max_tokens=32768, headers=headers, timeout=180,
|
temperature=0.1, max_tokens=32768, headers=headers, timeout=180,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
thinking_mode=('on' if attempt == 0 else 'off')
|
||||||
|
if uses_odysseus_progressive_thinking(model) else None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't give up on a transient first-attempt error — let the second
|
# Don't give up on a transient first-attempt error — let the second
|
||||||
@@ -400,11 +402,12 @@ async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: st
|
|||||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||||
temperature=0.1, max_tokens=8192, headers=headers, timeout=120,
|
temperature=0.1, max_tokens=8192, headers=headers, timeout=120,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
thinking_mode='on' if uses_odysseus_progressive_thinking(model) else None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Necessity check failed: {e}")
|
logger.warning(f"Necessity check failed: {e}")
|
||||||
return None
|
return None
|
||||||
text = strip_closed_think_blocks(raw or '')
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||||
data = None
|
data = None
|
||||||
a, b = text.find('{'), text.rfind('}')
|
a, b = text.find('{'), text.rfind('}')
|
||||||
@@ -490,11 +493,12 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
|||||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||||
temperature=0.1, max_tokens=4096, headers=headers, timeout=90,
|
temperature=0.1, max_tokens=4096, headers=headers, timeout=90,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
thinking_mode='on' if uses_odysseus_progressive_thinking(model) else None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Retrieval precision check failed: {e}")
|
logger.warning(f"Retrieval precision check failed: {e}")
|
||||||
return None
|
return None
|
||||||
text = strip_closed_think_blocks(raw or '')
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||||
data = None
|
data = None
|
||||||
a, b = text.find('{'), text.rfind('}')
|
a, b = text.find('{'), text.rfind('}')
|
||||||
@@ -1185,11 +1189,12 @@ async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url,
|
|||||||
[{"role": "system", "content": sys_prompt},
|
[{"role": "system", "content": sys_prompt},
|
||||||
{"role": "user", "content": user_msg}],
|
{"role": "user", "content": user_msg}],
|
||||||
temperature=0.2, max_tokens=16384, headers=headers, timeout=180,
|
temperature=0.2, max_tokens=16384, headers=headers, timeout=180,
|
||||||
workload=workload)
|
workload=workload,
|
||||||
|
thinking_mode='on' if uses_odysseus_progressive_thinking(model) else None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Audit: improve call failed: {e}")
|
logger.warning(f"Audit: improve call failed: {e}")
|
||||||
return None
|
return None
|
||||||
text = strip_closed_think_blocks(raw or '')
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I)
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I)
|
||||||
text = _re.sub(r'</think(?:ing)?>', '', text, flags=_re.I).strip()
|
text = _re.sub(r'</think(?:ing)?>', '', text, flags=_re.I).strip()
|
||||||
if text.startswith("```"):
|
if text.startswith("```"):
|
||||||
@@ -2004,11 +2009,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
it untouched). It never changes the skill's published/draft STATUS."""
|
it untouched). It never changes the skill's published/draft STATUS."""
|
||||||
import time as _time
|
import time as _time
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
from core.database import SessionLocal
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.endpoint_resolver import (
|
|
||||||
build_chat_url, build_headers, resolve_endpoint,
|
|
||||||
resolve_endpoint_runtime, resolve_owner_registered_endpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -2032,18 +2033,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
# session's model. Fall back to the caller's session model only if unset.
|
# session's model. Fall back to the caller's session model only if unset.
|
||||||
url, model, headers = resolve_endpoint("utility", owner=user)
|
url, model, headers = resolve_endpoint("utility", owner=user)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
if not url and body.get("endpoint_url") is not None:
|
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
endpoint = resolve_owner_registered_endpoint(db, body["endpoint_url"], user)
|
|
||||||
base, api_key = resolve_endpoint_runtime(endpoint, owner=user)
|
|
||||||
url = build_chat_url(base)
|
|
||||||
headers = build_headers(api_key, base)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(400, str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
model = model or ((body.get("model") or "").strip() or None)
|
model = model or ((body.get("model") or "").strip() or None)
|
||||||
|
if headers is None and isinstance(body.get("headers"), dict):
|
||||||
|
headers = body.get("headers")
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
raise HTTPException(400, "No model configured — set a Default or Utility model in Settings.")
|
raise HTTPException(400, "No model configured — set a Default or Utility model in Settings.")
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from core.database import SessionLocal, ScheduledTask, TaskRun, NotificationLog
|
|||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
||||||
from src.endpoint_resolver import resolve_owner_registered_endpoint_url
|
|
||||||
from src.task_action_policy import (
|
from src.task_action_policy import (
|
||||||
ADMIN_ONLY_TASK_ACTIONS,
|
ADMIN_ONLY_TASK_ACTIONS,
|
||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
@@ -520,12 +519,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
then_task_id = _validate_then_task_id(db, req.then_task_id, user)
|
then_task_id = _validate_then_task_id(db, req.then_task_id, user)
|
||||||
endpoint_url = None
|
|
||||||
if req.endpoint_url:
|
|
||||||
try:
|
|
||||||
endpoint_url = resolve_owner_registered_endpoint_url(db, req.endpoint_url, user)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(400, str(exc)) from exc
|
|
||||||
notifications_enabled = (
|
notifications_enabled = (
|
||||||
False if req.task_type == "action" and req.notifications_enabled is None
|
False if req.task_type == "action" and req.notifications_enabled is None
|
||||||
else bool(req.notifications_enabled) if req.notifications_enabled is not None
|
else bool(req.notifications_enabled) if req.notifications_enabled is not None
|
||||||
@@ -562,7 +555,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
status="active" if (req.trigger_type in ("event", "webhook") or next_run) else "completed",
|
status="active" if (req.trigger_type in ("event", "webhook") or next_run) else "completed",
|
||||||
output_target=req.output_target,
|
output_target=req.output_target,
|
||||||
model=req.model or None,
|
model=req.model or None,
|
||||||
endpoint_url=endpoint_url,
|
endpoint_url=req.endpoint_url or None,
|
||||||
then_task_id=then_task_id,
|
then_task_id=then_task_id,
|
||||||
webhook_token=webhook_token,
|
webhook_token=webhook_token,
|
||||||
notifications_enabled=notifications_enabled,
|
notifications_enabled=notifications_enabled,
|
||||||
@@ -618,8 +611,12 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
async def create_notification_log(request: Request):
|
async def create_notification_log(request: Request):
|
||||||
"""Persist an in-app toast so Settings can show notification history."""
|
"""Persist an in-app toast so Settings can show notification history."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
|
# Toast logging is best-effort. With auth disabled (or no user) there is
|
||||||
|
# nothing meaningful to persist, so return success rather than a 401 —
|
||||||
|
# a 401 here would trip the global fetch redirect-to-/login handler and
|
||||||
|
# yank users out of the editor mid-edit.
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "Authentication required")
|
return {"success": False}
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
message = str(body.get("body") or "").strip()[:2000]
|
message = str(body.get("body") or "").strip()[:2000]
|
||||||
if not message:
|
if not message:
|
||||||
@@ -762,14 +759,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
if req.model is not None:
|
if req.model is not None:
|
||||||
task.model = req.model or None
|
task.model = req.model or None
|
||||||
if req.endpoint_url is not None:
|
if req.endpoint_url is not None:
|
||||||
try:
|
task.endpoint_url = req.endpoint_url or None
|
||||||
# An empty override restores the existing default-model workflow.
|
|
||||||
task.endpoint_url = (
|
|
||||||
resolve_owner_registered_endpoint_url(db, req.endpoint_url, user)
|
|
||||||
if req.endpoint_url else None
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(400, str(exc)) from exc
|
|
||||||
if req.trigger_type is not None:
|
if req.trigger_type is not None:
|
||||||
# Generate webhook token when switching to webhook trigger
|
# Generate webhook token when switching to webhook trigger
|
||||||
if req.trigger_type == "webhook" and not task.webhook_token:
|
if req.trigger_type == "webhook" and not task.webhook_token:
|
||||||
@@ -1252,14 +1242,8 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
if not out.get("prompt"):
|
if not out.get("prompt"):
|
||||||
return {"success": False, "message": "Could not extract a task instruction"}
|
return {"success": False, "message": "Could not extract a task instruction"}
|
||||||
return {"success": True, "draft": out}
|
return {"success": True, "draft": out}
|
||||||
except HTTPException as e:
|
|
||||||
# llm_core raises HTTPException with curated upstream text.
|
|
||||||
logger.error(f"parse_task failed: {e.detail}")
|
|
||||||
return {"success": False, "message": str(e.detail)}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Anything else (JSON/parse/runtime errors) can carry internals;
|
|
||||||
# log it and keep the client message generic.
|
|
||||||
logger.error(f"parse_task failed: {e}")
|
logger.error(f"parse_task failed: {e}")
|
||||||
return {"success": False, "message": f"Could not draft a task ({type(e).__name__})"}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -610,9 +610,7 @@ async def update_persona_memory(
|
|||||||
)
|
)
|
||||||
|
|
||||||
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
|
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
|
||||||
# No leading `\s*` before the closing fence: .strip() drops that
|
updated = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", updated, flags=re.I | re.S).strip()
|
||||||
# whitespace anyway, and scanning it from every offset was quadratic.
|
|
||||||
updated = re.sub(r"^```(?:text|markdown)?\s*|```$", "", updated, flags=re.I | re.S).strip()
|
|
||||||
if len(updated) > 6000:
|
if len(updated) > 6000:
|
||||||
updated = updated[:6000].rstrip()
|
updated = updated[:6000].rstrip()
|
||||||
if updated == existing_memory:
|
if updated == existing_memory:
|
||||||
@@ -696,9 +694,8 @@ async def audit_memories(
|
|||||||
# Parse the JSON list, tolerating reasoning-model noise: <think> blocks,
|
# Parse the JSON list, tolerating reasoning-model noise: <think> blocks,
|
||||||
# markdown fences, leading prose, and trailing commas.
|
# markdown fences, leading prose, and trailing commas.
|
||||||
import re as _re
|
import re as _re
|
||||||
from src.text_helpers import strip_closed_think_blocks
|
|
||||||
text = (raw or "").strip()
|
text = (raw or "").strip()
|
||||||
text = strip_closed_think_blocks(text).strip()
|
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I).strip()
|
||||||
|
|
||||||
def _loads_list(s):
|
def _loads_list(s):
|
||||||
if not s:
|
if not s:
|
||||||
@@ -714,9 +711,7 @@ async def audit_memories(
|
|||||||
|
|
||||||
cleaned = _loads_list(text)
|
cleaned = _loads_list(text)
|
||||||
if cleaned is None:
|
if cleaned is None:
|
||||||
# Possessive `\s*+`: handing fence whitespace back to the body on a
|
_m = _re.search(r'```(?:json)?\s*\n?([\s\S]*?)```', text)
|
||||||
# missing closing fence only rescanned the same tail (ReDoS).
|
|
||||||
_m = _re.search(r'```(?:json)?\s*+\n?([\s\S]*?)```', text)
|
|
||||||
if _m:
|
if _m:
|
||||||
cleaned = _loads_list(_m.group(1).strip())
|
cleaned = _loads_list(_m.group(1).strip())
|
||||||
if cleaned is None:
|
if cleaned is None:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
+282
-909
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,9 +114,11 @@ def _control_plane_snapshot():
|
|||||||
uploader = get_upload_handler()
|
uploader = get_upload_handler()
|
||||||
if uploader is not None and isinstance(getattr(uploader, "upload_dir", None), (str, os.PathLike)):
|
if uploader is not None and isinstance(getattr(uploader, "upload_dir", None), (str, os.PathLike)):
|
||||||
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
|
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
|
||||||
# Prefix protection covers the complete runtime trees. Public policy denies
|
for directory in job_dirs:
|
||||||
# every regular hardlink alias, so collecting all child inodes here would
|
jobs = Path(directory)
|
||||||
# add an unbounded recursive state inventory without widening protection.
|
if jobs.exists():
|
||||||
|
# Uninspectable state fails closed; hardlinks retain object identity.
|
||||||
|
protected.update(canonical_root(p) for p in jobs.rglob("*") if p.is_file())
|
||||||
protected.update(canonical_root(getattr(constants, name) + suffix)
|
protected.update(canonical_root(getattr(constants, name) + suffix)
|
||||||
for name in ("APP_DB", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB")
|
for name in ("APP_DB", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB")
|
||||||
for suffix in ("-wal", "-shm", "-journal"))
|
for suffix in ("-wal", "-shm", "-journal"))
|
||||||
@@ -129,7 +131,7 @@ def _control_plane_snapshot():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
identities.add((observed.st_dev, observed.st_ino))
|
identities.add((observed.st_dev, observed.st_ino))
|
||||||
# Effect directories are protected by prefix without inventorying children.
|
# Effect directories join the prefix set only after the recursive inventory.
|
||||||
return frozenset(job_dirs | effect_dirs), frozenset(protected), frozenset(identities)
|
return frozenset(job_dirs | effect_dirs), frozenset(protected), frozenset(identities)
|
||||||
|
|
||||||
|
|
||||||
@@ -137,11 +139,6 @@ def _control_plane_path(path, *, snapshot=None):
|
|||||||
# A scan-local snapshot bounds repeated hardlink checks. Ordinary resource
|
# A scan-local snapshot bounds repeated hardlink checks. Ordinary resource
|
||||||
# resolution always observes fresh state. Neither form is an atomic kernel
|
# resolution always observes fresh state. Neither form is an atomic kernel
|
||||||
# access policy, and snapshots must never survive a workspace guard call.
|
# access policy, and snapshots must never survive a workspace guard call.
|
||||||
# Public state and hardlink denial also applies to sealed resources. Lazy
|
|
||||||
# import avoids coupling inert identity definitions to dispatcher startup.
|
|
||||||
from src.tool_execution import _is_app_state_path, _is_hardlinked_regular_file
|
|
||||||
if _is_sensitive_path(path) or _is_app_state_path(path) or _is_hardlinked_regular_file(path):
|
|
||||||
return True
|
|
||||||
directories, protected, identities = _control_plane_snapshot() if snapshot is None else snapshot
|
directories, protected, identities = _control_plane_snapshot() if snapshot is None else snapshot
|
||||||
if any(Path(path).is_relative_to(directory) for directory in directories) or path in protected:
|
if any(Path(path).is_relative_to(directory) for directory in directories) or path in protected:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+114
-572
@@ -3,9 +3,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import difflib
|
import difflib
|
||||||
import secrets
|
import fnmatch
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Optional, Dict, Any, Tuple, List
|
from typing import Optional, Dict, Any, Tuple, List
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@ _CODENAV_SKIP_DIRS = frozenset({
|
|||||||
})
|
})
|
||||||
_CODENAV_MAX_HITS = 200
|
_CODENAV_MAX_HITS = 200
|
||||||
_CODENAV_MAX_LINE = 400
|
_CODENAV_MAX_LINE = 400
|
||||||
_GREP_TIMEOUT_SECONDS = 20
|
|
||||||
_GREP_STDERR_PREFIX = 20_000
|
|
||||||
_STRUCTURED_DOCUMENT_SUFFIXES = frozenset({
|
_STRUCTURED_DOCUMENT_SUFFIXES = frozenset({
|
||||||
".doc", ".docx", ".epub", ".pdf", ".pptx", ".xls", ".xlsx",
|
".doc", ".docx", ".epub", ".pdf", ".pptx", ".xls", ".xlsx",
|
||||||
})
|
})
|
||||||
@@ -91,79 +88,6 @@ def _glob_to_regex(pat: str) -> "re.Pattern":
|
|||||||
i += 1
|
i += 1
|
||||||
return re.compile("".join(out))
|
return re.compile("".join(out))
|
||||||
|
|
||||||
|
|
||||||
def _validate_grep_descriptor(descriptor):
|
|
||||||
"""Revalidate an inert parent snapshot without inherited ContextVars."""
|
|
||||||
path, identity, ancestors = descriptor
|
|
||||||
if os.path.islink(path) or os.path.realpath(path) != path:
|
|
||||||
raise ValueError("grep: resource path changed")
|
|
||||||
for parent, observed in ancestors:
|
|
||||||
info = os.stat(parent, follow_symlinks=False)
|
|
||||||
if not os.path.isdir(parent) or (info.st_dev, info.st_ino) != tuple(observed):
|
|
||||||
raise ValueError("grep: resource ancestor changed")
|
|
||||||
info = os.stat(path, follow_symlinks=False)
|
|
||||||
import stat
|
|
||||||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink > 1
|
|
||||||
or (info.st_dev, info.st_ino) != tuple(identity)):
|
|
||||||
raise ValueError("grep: resource identity changed")
|
|
||||||
|
|
||||||
|
|
||||||
def _python_grep_worker(payload: dict, output_queue) -> None:
|
|
||||||
"""Spawn-safe fallback grep worker used when ripgrep is unavailable.
|
|
||||||
|
|
||||||
Keep this at module scope: a frozen Windows executable cannot safely be
|
|
||||||
relaunched as ``sys.executable -c ...``, while multiprocessing can invoke a
|
|
||||||
top-level target through its frozen-process bootstrap.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
flags = re.IGNORECASE if payload["ignore_case"] else 0
|
|
||||||
try:
|
|
||||||
regex = re.compile(payload["pattern"], flags)
|
|
||||||
glob_regex = (
|
|
||||||
_glob_to_regex(payload["glob"].replace("\\", "/"))
|
|
||||||
if payload["glob"]
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
except re.error as exc:
|
|
||||||
output_queue.put(("error", f"grep: bad pattern: {exc}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
max_hits = payload["max_hits"]
|
|
||||||
hits = 0
|
|
||||||
|
|
||||||
for descriptor in payload["files"]:
|
|
||||||
if hits >= max_hits:
|
|
||||||
break
|
|
||||||
path, identity, ancestors = descriptor
|
|
||||||
_validate_grep_descriptor(descriptor)
|
|
||||||
relative = os.path.relpath(path, payload["base"]).replace(os.sep, "/")
|
|
||||||
if glob_regex and not (
|
|
||||||
glob_regex.fullmatch(relative) or glob_regex.fullmatch(os.path.basename(path))
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8", errors="strict") as handle:
|
|
||||||
info = os.fstat(handle.fileno())
|
|
||||||
if (info.st_dev, info.st_ino) != tuple(identity) or info.st_nlink > 1:
|
|
||||||
raise ValueError("grep: resource identity changed before read")
|
|
||||||
for number, line in enumerate(handle, 1):
|
|
||||||
if regex.search(line):
|
|
||||||
output_queue.put(("match", path, number, line.rstrip()[:_CODENAV_MAX_LINE]))
|
|
||||||
hits += 1
|
|
||||||
if hits >= max_hits:
|
|
||||||
break
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
continue
|
|
||||||
except OSError as error:
|
|
||||||
output_queue.put(("error", f"grep: {error}"))
|
|
||||||
return
|
|
||||||
output_queue.put(("done",))
|
|
||||||
except BaseException as exc:
|
|
||||||
try:
|
|
||||||
output_queue.put(("error", f"grep: fallback worker failed: {exc}"))
|
|
||||||
except BaseException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||||
if old == new:
|
if old == new:
|
||||||
return None
|
return None
|
||||||
@@ -344,82 +268,32 @@ class ReadFileTool:
|
|||||||
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
|
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
|
||||||
return {"output": data, "exit_code": 0}
|
return {"output": data, "exit_code": 0}
|
||||||
|
|
||||||
|
|
||||||
def _write_new_file_without_overwrite(path: str, body: str) -> None:
|
|
||||||
"""Publish a new file without exposing a writable placeholder at its path.
|
|
||||||
|
|
||||||
Stage beside the destination, then hard-link it into place. The link is
|
|
||||||
atomic and fails if another writer created the destination first.
|
|
||||||
"""
|
|
||||||
directory = os.path.dirname(path) or "."
|
|
||||||
temporary_path = os.path.join(
|
|
||||||
directory, f".odysseus-write-{secrets.token_hex(16)}.tmp"
|
|
||||||
)
|
|
||||||
fd = os.open(
|
|
||||||
temporary_path,
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
||||||
0o666,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as temporary_file:
|
|
||||||
fd = None
|
|
||||||
temporary_file.write(body)
|
|
||||||
os.link(temporary_path, path)
|
|
||||||
finally:
|
|
||||||
if fd is not None:
|
|
||||||
os.close(fd)
|
|
||||||
try:
|
|
||||||
os.unlink(temporary_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _EmptyBodyWouldTruncate(Exception):
|
|
||||||
"""Raised inside the write thread when an undeclared empty body is about to
|
|
||||||
replace a file that holds bytes. Carries the size at risk so the caller can be
|
|
||||||
told what it would have lost (#6414)."""
|
|
||||||
|
|
||||||
def __init__(self, path: str, existing_bytes: int):
|
|
||||||
super().__init__(path)
|
|
||||||
self.path = path
|
|
||||||
self.existing_bytes = existing_bytes
|
|
||||||
|
|
||||||
def _parse_write_intent(content: str) -> tuple[str, str, bool, bool]:
|
|
||||||
"""Classify the original transport, before binding or source normalization.
|
|
||||||
|
|
||||||
The returned clear flag is derived here, never from a caller-supplied key.
|
|
||||||
"""
|
|
||||||
if not isinstance(content, str):
|
|
||||||
raise ValueError("write_file: expected string arguments")
|
|
||||||
if content.lstrip().startswith("{"):
|
|
||||||
try:
|
|
||||||
args = json.loads(content)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise ValueError("write_file: expected valid JSON arguments") from error
|
|
||||||
if not isinstance(args, dict):
|
|
||||||
raise ValueError("write_file: expected a JSON object")
|
|
||||||
path, body = args.get("path"), args.get("content")
|
|
||||||
if not isinstance(body, str):
|
|
||||||
raise ValueError("write_file: content required and must be a string")
|
|
||||||
if not isinstance(path, str):
|
|
||||||
raise ValueError("write_file: path required and must be a string")
|
|
||||||
return path.strip(), body, True, not body.strip()
|
|
||||||
path, delimiter, body = content.partition("\n")
|
|
||||||
return path.strip(), body, bool(delimiter), False
|
|
||||||
|
|
||||||
|
|
||||||
class WriteFileTool:
|
class WriteFileTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.tool_execution import _display_tool_path, _resolve_tool_path
|
from src.tool_execution import _display_tool_path, _resolve_tool_path, _resolve_search_root, _truncate
|
||||||
from src.agent_runtime.resource_binding import active_resource_operation
|
lines = content.split("\n", 1)
|
||||||
bound = active_resource_operation()
|
raw_path = lines[0].strip()
|
||||||
original = bound.operation.input if bound is not None else content
|
body = lines[1] if len(lines) > 1 else ""
|
||||||
try:
|
# Decode JSON-object args (the fenced inline-args shape
|
||||||
_, _, has_section, declared_clear = _parse_write_intent(original)
|
# ```write_file {"path": "...", "content": "..."}```), matching
|
||||||
raw_path, body, _, _ = _parse_write_intent(content)
|
# ReadFileTool above. Without this the whole JSON string becomes the
|
||||||
except ValueError as error:
|
# path and the file is written under a garbage name. This is the live
|
||||||
return {"error": str(error), "exit_code": 1}
|
# path: there is no filesystem MCP server, so write_file always runs
|
||||||
|
# here via _direct_fallback, not through _build_mcp_args.
|
||||||
|
_stripped = content.strip()
|
||||||
|
if _stripped.startswith("{"):
|
||||||
|
try:
|
||||||
|
_a = json.loads(_stripped)
|
||||||
|
if not isinstance(_a, dict):
|
||||||
|
return {"error": "write_file: expected a JSON object", "exit_code": 1}
|
||||||
|
raw_path_value = _a.get("path")
|
||||||
|
body_value = _a.get("content")
|
||||||
|
raw_path = raw_path_value.strip() if isinstance(raw_path_value, str) else ""
|
||||||
|
if not isinstance(body_value, str):
|
||||||
|
return {"error": "write_file: content required", "exit_code": 1}
|
||||||
|
body = body_value
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
return {"error": "write_file: expected valid JSON arguments", "exit_code": 1}
|
||||||
if not raw_path:
|
if not raw_path:
|
||||||
return {"error": "write_file: path required", "exit_code": 1}
|
return {"error": "write_file: path required", "exit_code": 1}
|
||||||
try:
|
try:
|
||||||
@@ -449,6 +323,16 @@ class WriteFileTool:
|
|||||||
"exit_code": 1,
|
"exit_code": 1,
|
||||||
"artifact_format_error": True,
|
"artifact_format_error": True,
|
||||||
}
|
}
|
||||||
|
if not body:
|
||||||
|
return {
|
||||||
|
"error": (
|
||||||
|
f"write_file: {path}: content required; refusing to create an "
|
||||||
|
"empty file. Call write_file again with the exact filename and "
|
||||||
|
"non-empty content. If you need a directory, create it from "
|
||||||
|
"bash/python instead."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
# write_file is a UTF-8 text writer. Refuse to silently destroy an
|
# write_file is a UTF-8 text writer. Refuse to silently destroy an
|
||||||
# existing PDF, image, archive, or media artifact produced by a
|
# existing PDF, image, archive, or media artifact produced by a
|
||||||
# format-aware tool, especially after the agent has verified it.
|
# format-aware tool, especially after the agent has verified it.
|
||||||
@@ -463,10 +347,6 @@ class WriteFileTool:
|
|||||||
"exit_code": 1,
|
"exit_code": 1,
|
||||||
"binary_artifact_preserved": target_existed,
|
"binary_artifact_preserved": target_existed,
|
||||||
}
|
}
|
||||||
if not has_section:
|
|
||||||
return {"error": "write_file: content required; missing content section", "exit_code": 1}
|
|
||||||
if raw_path.endswith(("/", "\\")) or os.path.isdir(path):
|
|
||||||
return {"error": "write_file: target is a directory", "exit_code": 1}
|
|
||||||
# This writer truncates in place. Once that stage is reached, a failure
|
# This writer truncates in place. Once that stage is reached, a failure
|
||||||
# may leave a partial file; report it so effect evidence stays honest.
|
# may leave a partial file; report it so effect evidence stays honest.
|
||||||
attempted = []
|
attempted = []
|
||||||
@@ -481,67 +361,21 @@ class WriteFileTool:
|
|||||||
d = os.path.dirname(path)
|
d = os.path.dirname(path)
|
||||||
if d:
|
if d:
|
||||||
os.makedirs(d, exist_ok=True)
|
os.makedirs(d, exist_ok=True)
|
||||||
if not body.strip() and not declared_clear:
|
|
||||||
# Empty/whitespace-only writes to an already-empty file are no-ops.
|
|
||||||
# Avoid reopening in truncating mode: another writer may have added
|
|
||||||
# data since the read above.
|
|
||||||
if os.path.isfile(path):
|
|
||||||
existing_bytes = os.path.getsize(path)
|
|
||||||
if existing_bytes > 0:
|
|
||||||
raise _EmptyBodyWouldTruncate(path, existing_bytes)
|
|
||||||
return old, 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
if body:
|
|
||||||
# Publish whitespace content atomically. Writing it
|
|
||||||
# after exclusive creation could overwrite bytes from
|
|
||||||
# a writer that filled the new placeholder meanwhile.
|
|
||||||
attempted.append(True)
|
|
||||||
_write_new_file_without_overwrite(path, body)
|
|
||||||
else:
|
|
||||||
# An exact empty body needs no staged data, so create
|
|
||||||
# the file exclusively and never write through it.
|
|
||||||
attempted.append(True)
|
|
||||||
with open(path, "x", encoding="utf-8"):
|
|
||||||
pass
|
|
||||||
except FileExistsError:
|
|
||||||
if os.path.isfile(path):
|
|
||||||
existing_bytes = os.path.getsize(path)
|
|
||||||
if existing_bytes > 0:
|
|
||||||
raise _EmptyBodyWouldTruncate(path, existing_bytes)
|
|
||||||
return old, 0
|
|
||||||
raise
|
|
||||||
return old, len(body)
|
|
||||||
|
|
||||||
attempted.append(True)
|
attempted.append(True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write(body)
|
f.write(body)
|
||||||
return old, len(body)
|
return old, len(body)
|
||||||
old_content, size = await asyncio.to_thread(_write)
|
old_content, size = await asyncio.to_thread(_write)
|
||||||
except _EmptyBodyWouldTruncate as e:
|
|
||||||
clear_call = json.dumps({"path": raw_path, "content": ""})
|
|
||||||
return {
|
|
||||||
"error": (
|
|
||||||
f"write_file: refused to write an empty body over {e.path} — it holds "
|
|
||||||
f"{e.existing_bytes} bytes, which the write would have destroyed, so "
|
|
||||||
f"the file is unchanged. To clear it on purpose, resend with an "
|
|
||||||
f"explicit empty content: {clear_call}"
|
|
||||||
),
|
|
||||||
"exit_code": 1,
|
|
||||||
}
|
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return {"error": f"write_file: {path}: permission denied", "exit_code": 1,
|
return {"error": f"write_file: {path}: permission denied", "exit_code": 1,
|
||||||
**({"mutation_attempted": True} if attempted else {})}
|
**({"mutation_attempted": True} if attempted else {})}
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return {"error": f"write_file: {path}: {e}", "exit_code": 1,
|
return {"error": f"write_file: {path}: {e}", "exit_code": 1,
|
||||||
**({"mutation_attempted": True} if attempted else {})}
|
**({"mutation_attempted": True} if attempted else {})}
|
||||||
committed_body = old_content if size == 0 and not declared_clear and not body.strip() else body
|
diff = _unified_diff(old_content, body, path)
|
||||||
diff = _unified_diff(old_content, committed_body, path)
|
|
||||||
result = {
|
result = {
|
||||||
"output": (f"Wrote {size} bytes to {_display_tool_path(path)}" if attempted else
|
"output": f"Wrote {size} bytes to {_display_tool_path(path)}",
|
||||||
f"No write performed for {_display_tool_path(path)} (implicit empty body)"),
|
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
**({"write_noop": True} if not attempted else {}),
|
|
||||||
}
|
}
|
||||||
if diff:
|
if diff:
|
||||||
result["diff"] = diff
|
result["diff"] = diff
|
||||||
@@ -785,7 +619,7 @@ def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str
|
|||||||
|
|
||||||
class LsTool:
|
class LsTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.tool_execution import _display_tool_path, _is_denied_tool_path, _resolve_search_root, _truncate
|
from src.tool_execution import _display_tool_path, _resolve_search_root, _truncate
|
||||||
raw_path = ""
|
raw_path = ""
|
||||||
_s = (content or "").strip()
|
_s = (content or "").strip()
|
||||||
if _s.startswith("{"):
|
if _s.startswith("{"):
|
||||||
@@ -801,18 +635,15 @@ class LsTool:
|
|||||||
return {"error": f"ls: {e}", "exit_code": 1}
|
return {"error": f"ls: {e}", "exit_code": 1}
|
||||||
|
|
||||||
def _ls():
|
def _ls():
|
||||||
from src.agent_runtime.resources import _control_plane_snapshot
|
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
return None, f"ls: {root}: not a directory"
|
return None, f"ls: {root}: not a directory"
|
||||||
rows = []
|
rows = []
|
||||||
snapshot = _control_plane_snapshot()
|
|
||||||
try:
|
try:
|
||||||
with os.scandir(root) as it:
|
with os.scandir(root) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
if entry.name.startswith("."):
|
if entry.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
if (_is_denied_tool_path(os.path.realpath(entry.path), snapshot=snapshot)
|
if not _visible_bound_resource(entry.path):
|
||||||
or not _visible_bound_resource(entry.path)):
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
is_dir = entry.is_dir(follow_symlinks=False)
|
is_dir = entry.is_dir(follow_symlinks=False)
|
||||||
@@ -841,8 +672,6 @@ class GlobTool:
|
|||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.tool_execution import (
|
from src.tool_execution import (
|
||||||
_SENSITIVE_BASENAMES,
|
_SENSITIVE_BASENAMES,
|
||||||
_can_traverse_tool_path,
|
|
||||||
_is_denied_tool_path,
|
|
||||||
_display_tool_path,
|
_display_tool_path,
|
||||||
_is_sensitive_path,
|
_is_sensitive_path,
|
||||||
_resolve_tool_path,
|
_resolve_tool_path,
|
||||||
@@ -867,12 +696,10 @@ class GlobTool:
|
|||||||
return {"error": f"glob: {e}", "exit_code": 1}
|
return {"error": f"glob: {e}", "exit_code": 1}
|
||||||
|
|
||||||
def _glob():
|
def _glob():
|
||||||
from src.agent_runtime.resources import _control_plane_snapshot
|
|
||||||
base = os.path.abspath(root)
|
base = os.path.abspath(root)
|
||||||
if not os.path.isdir(base):
|
if not os.path.isdir(base):
|
||||||
return None, f"glob: {root}: not a directory"
|
return None, f"glob: {root}: not a directory"
|
||||||
rbase = os.path.realpath(base)
|
rbase = os.path.realpath(base)
|
||||||
snapshot = _control_plane_snapshot()
|
|
||||||
norm_pat = pattern.replace("\\", "/")
|
norm_pat = pattern.replace("\\", "/")
|
||||||
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
||||||
if not any(c in norm_pat for c in "*?["):
|
if not any(c in norm_pat for c in "*?["):
|
||||||
@@ -889,8 +716,7 @@ class GlobTool:
|
|||||||
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
||||||
# otherwise glob would surface secret paths that read_file /
|
# otherwise glob would surface secret paths that read_file /
|
||||||
# grep already refuse to touch.
|
# grep already refuse to touch.
|
||||||
if (inside and os.path.exists(cand) and not _is_denied_tool_path(cand, snapshot=snapshot)
|
if inside and os.path.exists(cand) and not _is_sensitive_path(cand) and _visible_bound_resource(cand):
|
||||||
and _visible_bound_resource(cand)):
|
|
||||||
return [cand], None
|
return [cand], None
|
||||||
# Literal not at exact path — fall through to walk so
|
# Literal not at exact path — fall through to walk so
|
||||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||||
@@ -900,18 +726,13 @@ class GlobTool:
|
|||||||
cap = _CODENAV_MAX_HITS * 5
|
cap = _CODENAV_MAX_HITS * 5
|
||||||
try:
|
try:
|
||||||
for dp, dns, fns in os.walk(base):
|
for dp, dns, fns in os.walk(base):
|
||||||
if not _can_traverse_tool_path(os.path.realpath(dp), snapshot=snapshot):
|
|
||||||
dns[:] = []
|
|
||||||
continue
|
|
||||||
# Prune skipped dirs before descending (unlike rglob which
|
# Prune skipped dirs before descending (unlike rglob which
|
||||||
# descends first then filters — fatal on large node_modules).
|
# descends first then filters — fatal on large node_modules).
|
||||||
# Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob
|
# Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob
|
||||||
# never enumerates the keys/tokens inside them.
|
# never enumerates the keys/tokens inside them.
|
||||||
dns[:] = [
|
dns[:] = [
|
||||||
d for d in dns
|
d for d in dns
|
||||||
if d not in _CODENAV_SKIP_DIRS
|
if d not in _CODENAV_SKIP_DIRS and d not in _SENSITIVE_BASENAMES
|
||||||
and d not in _SENSITIVE_BASENAMES
|
|
||||||
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)), snapshot=snapshot)
|
|
||||||
]
|
]
|
||||||
for name in fns + dns:
|
for name in fns + dns:
|
||||||
full = os.path.join(dp, name)
|
full = os.path.join(dp, name)
|
||||||
@@ -919,8 +740,7 @@ class GlobTool:
|
|||||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||||
# Skip deny-listed sensitive files (.env, id_rsa,
|
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||||
# known_hosts, …) the same way grep does.
|
# known_hosts, …) the same way grep does.
|
||||||
if (_is_denied_tool_path(os.path.realpath(full), snapshot=snapshot)
|
if _is_sensitive_path(os.path.realpath(full)) or not _visible_bound_resource(full):
|
||||||
or not _visible_bound_resource(full)):
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
mtime = os.stat(full).st_mtime
|
mtime = os.stat(full).st_mtime
|
||||||
@@ -947,14 +767,10 @@ class GlobTool:
|
|||||||
class GrepTool:
|
class GrepTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.tool_execution import (
|
from src.tool_execution import (
|
||||||
_SENSITIVE_BASENAMES,
|
|
||||||
_SENSITIVE_FILE_PATTERNS,
|
_SENSITIVE_FILE_PATTERNS,
|
||||||
_agent_readable_data_subdirs,
|
|
||||||
_is_denied_tool_path,
|
|
||||||
_display_tool_path,
|
_display_tool_path,
|
||||||
_can_traverse_tool_path,
|
|
||||||
_is_sensitive_path,
|
_is_sensitive_path,
|
||||||
_path_within,
|
_resolve_tool_path,
|
||||||
_resolve_search_root,
|
_resolve_search_root,
|
||||||
_truncate,
|
_truncate,
|
||||||
)
|
)
|
||||||
@@ -983,357 +799,83 @@ class GrepTool:
|
|||||||
return {"error": f"grep: {e}", "exit_code": 1}
|
return {"error": f"grep: {e}", "exit_code": 1}
|
||||||
|
|
||||||
def _grep():
|
def _grep():
|
||||||
import multiprocessing
|
import re as _re
|
||||||
import queue
|
import shutil
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from src.agent_runtime.resources import _control_plane_snapshot
|
|
||||||
from src.constants import DATA_DIR
|
|
||||||
|
|
||||||
rg = shutil.which("rg")
|
|
||||||
real_root = os.path.realpath(root)
|
|
||||||
deadline = time.monotonic() + _GREP_TIMEOUT_SECONDS
|
|
||||||
base = real_root if os.path.isdir(real_root) else os.path.dirname(real_root)
|
|
||||||
from src.agent_runtime.resource_binding import active_resource_operation
|
from src.agent_runtime.resource_binding import active_resource_operation
|
||||||
bound = active_resource_operation()
|
if not os.path.exists(root):
|
||||||
if bound is not None:
|
return None, f"grep: search target not found: {_display_tool_path(root)}"
|
||||||
bound.validate()
|
# The pathname-only fast path scans before individual resources can
|
||||||
files = []
|
# be checked. Bound searches must validate every file before read.
|
||||||
snapshot = _control_plane_snapshot()
|
rg = None if active_resource_operation() is not None else shutil.which("rg")
|
||||||
|
|
||||||
def check_deadline():
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise TimeoutError("grep: timed out")
|
|
||||||
|
|
||||||
def observe_file(path):
|
|
||||||
check_deadline()
|
|
||||||
if os.path.islink(path):
|
|
||||||
return
|
|
||||||
canonical = os.path.realpath(path)
|
|
||||||
if not _path_within(canonical, base) or _is_denied_tool_path(canonical, snapshot=snapshot):
|
|
||||||
return
|
|
||||||
if bound is not None:
|
|
||||||
try:
|
|
||||||
bound.resolve_path(canonical)
|
|
||||||
except ValueError:
|
|
||||||
# Intentionally denied publication control-plane paths
|
|
||||||
# are omitted before any producer is allowed to read.
|
|
||||||
return
|
|
||||||
info = os.stat(canonical, follow_symlinks=False)
|
|
||||||
if not os.path.isfile(canonical):
|
|
||||||
return
|
|
||||||
ancestors = []
|
|
||||||
parent = os.path.dirname(canonical)
|
|
||||||
while _path_within(parent, base):
|
|
||||||
observed = os.stat(parent, follow_symlinks=False)
|
|
||||||
ancestors.append((parent, (observed.st_dev, observed.st_ino)))
|
|
||||||
if parent == base:
|
|
||||||
break
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
files.append((canonical, (info.st_dev, info.st_ino), tuple(ancestors)))
|
|
||||||
if len(files) > 100_000:
|
|
||||||
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if os.path.islink(root):
|
|
||||||
raise ValueError("grep: symlink search root is not allowed")
|
|
||||||
if os.path.isfile(real_root):
|
|
||||||
observe_file(real_root)
|
|
||||||
elif os.path.isdir(real_root):
|
|
||||||
pending_directories = [real_root]
|
|
||||||
enumerated = 0
|
|
||||||
while pending_directories:
|
|
||||||
check_deadline()
|
|
||||||
directory = pending_directories.pop()
|
|
||||||
if not _can_traverse_tool_path(directory, snapshot=snapshot):
|
|
||||||
continue
|
|
||||||
with os.scandir(directory) as entries:
|
|
||||||
for entry in entries:
|
|
||||||
check_deadline()
|
|
||||||
enumerated += 1
|
|
||||||
if enumerated > 100_000:
|
|
||||||
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
|
|
||||||
if entry.is_symlink():
|
|
||||||
continue
|
|
||||||
canonical = os.path.realpath(entry.path)
|
|
||||||
if not _path_within(canonical, base):
|
|
||||||
raise ValueError("grep: directory identity changed during enumeration")
|
|
||||||
if entry.is_dir(follow_symlinks=False):
|
|
||||||
if (entry.name not in _CODENAV_SKIP_DIRS
|
|
||||||
and _can_traverse_tool_path(canonical, snapshot=snapshot)
|
|
||||||
and (bound is None or _is_denied_tool_path(canonical, snapshot=snapshot)
|
|
||||||
or _visible_bound_resource(canonical))):
|
|
||||||
pending_directories.append(canonical)
|
|
||||||
else:
|
|
||||||
observe_file(entry.path)
|
|
||||||
else:
|
|
||||||
raise FileNotFoundError(f"grep: {root}: not found")
|
|
||||||
check_deadline()
|
|
||||||
except (OSError, ValueError) as error:
|
|
||||||
return None, str(error) if str(error).startswith("grep:") else f"grep: {error}"
|
|
||||||
descriptors = {record[0]: record for record in files}
|
|
||||||
targets = list(descriptors)
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
def parse_rg_result(raw: str) -> Optional[str]:
|
|
||||||
try:
|
|
||||||
record = json.loads(raw)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
if record.get("type") != "match":
|
|
||||||
return None
|
|
||||||
data = record.get("data") or {}
|
|
||||||
path = (data.get("path") or {}).get("text")
|
|
||||||
text_value = (data.get("lines") or {}).get("text")
|
|
||||||
number = data.get("line_number")
|
|
||||||
if not isinstance(path, str) or not isinstance(text_value, str):
|
|
||||||
return None
|
|
||||||
absolute = path if os.path.isabs(path) else os.path.join(base, path)
|
|
||||||
canonical = os.path.realpath(absolute)
|
|
||||||
if canonical not in descriptors:
|
|
||||||
raise ValueError("grep: producer returned an undeclared resource")
|
|
||||||
_validate_grep_descriptor(descriptors[canonical])
|
|
||||||
if bound is not None:
|
|
||||||
bound.resolve_path(canonical)
|
|
||||||
return f"{_display_tool_path(canonical)}:{number}:{text_value.rstrip()[:_CODENAV_MAX_LINE]}"
|
|
||||||
|
|
||||||
def run_rg(cmd: list[str]) -> Optional[str]:
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
cwd=base,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return f"grep: {exc}"
|
|
||||||
output: queue.Queue[Optional[str]] = queue.Queue(maxsize=max_hits + 2)
|
|
||||||
stderr_prefix: list[str] = []
|
|
||||||
stderr_size = 0
|
|
||||||
stop_reader = threading.Event()
|
|
||||||
|
|
||||||
def enqueue_stdout(value: Optional[str]) -> bool:
|
|
||||||
# The consumer stops at the result cap or deadline. Never
|
|
||||||
# leave a producer blocked on its bounded queue afterward.
|
|
||||||
while not stop_reader.is_set():
|
|
||||||
try:
|
|
||||||
output.put(value, timeout=0.05)
|
|
||||||
return True
|
|
||||||
except queue.Full:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read_stdout() -> None:
|
|
||||||
assert process.stdout is not None
|
|
||||||
try:
|
|
||||||
for line in process.stdout:
|
|
||||||
if not enqueue_stdout(line.rstrip("\n")):
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
enqueue_stdout(None)
|
|
||||||
|
|
||||||
def read_stderr() -> None:
|
|
||||||
nonlocal stderr_size
|
|
||||||
assert process.stderr is not None
|
|
||||||
while True:
|
|
||||||
chunk = process.stderr.read(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
if stderr_size < _GREP_STDERR_PREFIX:
|
|
||||||
kept = chunk[:_GREP_STDERR_PREFIX - stderr_size]
|
|
||||||
stderr_prefix.append(kept)
|
|
||||||
stderr_size += len(kept)
|
|
||||||
|
|
||||||
stdout_thread = threading.Thread(target=read_stdout, daemon=True)
|
|
||||||
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
|
||||||
stdout_thread.start()
|
|
||||||
stderr_thread.start()
|
|
||||||
timed_out = False
|
|
||||||
capped = False
|
|
||||||
try:
|
|
||||||
while len(lines) < max_hits:
|
|
||||||
remaining = deadline - time.monotonic()
|
|
||||||
if remaining <= 0:
|
|
||||||
timed_out = True
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
raw = output.get(timeout=remaining)
|
|
||||||
except queue.Empty:
|
|
||||||
timed_out = True
|
|
||||||
break
|
|
||||||
if raw is None:
|
|
||||||
break
|
|
||||||
parsed = parse_rg_result(raw)
|
|
||||||
if parsed and parsed not in lines:
|
|
||||||
lines.append(parsed)
|
|
||||||
capped = len(lines) >= max_hits
|
|
||||||
finally:
|
|
||||||
stop_reader.set()
|
|
||||||
if (timed_out or capped) and process.poll() is None:
|
|
||||||
process.terminate()
|
|
||||||
try:
|
|
||||||
remaining = max(0.01, deadline - time.monotonic())
|
|
||||||
return_code = process.wait(timeout=min(1, remaining))
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
return_code = process.wait()
|
|
||||||
stdout_thread.join()
|
|
||||||
stderr_thread.join()
|
|
||||||
if timed_out:
|
|
||||||
return "grep: timed out"
|
|
||||||
if not capped and return_code not in (0, 1):
|
|
||||||
detail = "".join(stderr_prefix).strip()
|
|
||||||
return f"grep: {detail or f'process exited {return_code}'}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
if rg:
|
if rg:
|
||||||
|
cmd = [rg, "--line-number", "--with-filename", "--no-heading", "--color=never",
|
||||||
|
"--max-count", str(max_hits)]
|
||||||
|
if ignore_case:
|
||||||
|
cmd.append("--ignore-case")
|
||||||
if glob_pat:
|
if glob_pat:
|
||||||
try:
|
cmd += ["--glob", glob_pat]
|
||||||
glob_regex = _glob_to_regex(glob_pat.replace("\\", "/"))
|
# --iglob (not --glob) so the exclusion is case-insensitive:
|
||||||
targets = [
|
# on a case-insensitive filesystem "ID_RSA"/"Known_Hosts"
|
||||||
target for target in targets
|
# resolve to the same secret as their lowercase forms, and the
|
||||||
if glob_regex.fullmatch(os.path.relpath(target, base).replace(os.sep, "/"))
|
# Python fallback below already folds case via _is_sensitive_path.
|
||||||
or glob_regex.fullmatch(os.path.basename(target))
|
for _pat in _SENSITIVE_FILE_PATTERNS:
|
||||||
]
|
cmd += ["--iglob", f"!*{_pat}*"]
|
||||||
except re.error:
|
for _d in _CODENAV_SKIP_DIRS:
|
||||||
pass
|
cmd += ["--glob", f"!**/{_d}/**"]
|
||||||
# Validate even when policy filtering leaves no search targets.
|
cmd += ["--regexp", pattern, root]
|
||||||
if not targets:
|
|
||||||
cmd_probe = [rg, "--json", "--no-config"]
|
|
||||||
if glob_pat:
|
|
||||||
cmd_probe += ["--glob", glob_pat]
|
|
||||||
cmd_probe += ["--regexp", pattern, "--", "-"]
|
|
||||||
error = run_rg(cmd_probe)
|
|
||||||
return (None, error) if error else ([], None)
|
|
||||||
relative_targets = [os.path.relpath(target, base) for target in targets]
|
|
||||||
for offset in range(0, len(relative_targets), 128):
|
|
||||||
if len(lines) >= max_hits:
|
|
||||||
break
|
|
||||||
cmd = [
|
|
||||||
rg, "--json", "--no-config", "--no-follow",
|
|
||||||
"--max-count", str(max_hits - len(lines)),
|
|
||||||
"--max-columns", str(_CODENAV_MAX_LINE),
|
|
||||||
"--max-columns-preview",
|
|
||||||
]
|
|
||||||
if ignore_case:
|
|
||||||
cmd.append("--ignore-case")
|
|
||||||
if glob_pat:
|
|
||||||
cmd += ["--glob", glob_pat]
|
|
||||||
for sensitive_pattern in _SENSITIVE_FILE_PATTERNS:
|
|
||||||
cmd += ["--iglob", f"!{sensitive_pattern}"]
|
|
||||||
for skipped_dir in _CODENAV_SKIP_DIRS:
|
|
||||||
cmd += ["--glob", f"!**/{skipped_dir}/**"]
|
|
||||||
cmd += ["--regexp", pattern, "--", *relative_targets[offset:offset + 128]]
|
|
||||||
try:
|
|
||||||
for path in targets[offset:offset + 128]:
|
|
||||||
check_deadline()
|
|
||||||
_validate_grep_descriptor(descriptors[path])
|
|
||||||
if bound is not None:
|
|
||||||
bound.resolve_path(path)
|
|
||||||
error = run_rg(cmd)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
return None, f"grep: {exc}"
|
|
||||||
if error:
|
|
||||||
return None, error
|
|
||||||
return lines, None
|
|
||||||
|
|
||||||
# This runs inside asyncio.to_thread(), so forking would clone a
|
|
||||||
# multithreaded process and can deadlock. Spawn is platform-safe and
|
|
||||||
# PyInstaller-compatible via launcher's early freeze_support().
|
|
||||||
payload = {
|
|
||||||
"root": base,
|
|
||||||
"base": base,
|
|
||||||
"files": tuple(files),
|
|
||||||
"pattern": pattern,
|
|
||||||
"ignore_case": ignore_case,
|
|
||||||
"glob": glob_pat,
|
|
||||||
"max_hits": max_hits,
|
|
||||||
"skip_dirs": tuple(_CODENAV_SKIP_DIRS),
|
|
||||||
"sensitive_names": tuple(
|
|
||||||
set(_SENSITIVE_BASENAMES) | set(_SENSITIVE_FILE_PATTERNS)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
context = multiprocessing.get_context("spawn")
|
|
||||||
output_queue = context.Queue(maxsize=max_hits + 2)
|
|
||||||
worker = context.Process(
|
|
||||||
target=_python_grep_worker, args=(payload, output_queue)
|
|
||||||
)
|
|
||||||
worker.start()
|
|
||||||
except Exception as exc:
|
|
||||||
try:
|
try:
|
||||||
output_queue.close()
|
import subprocess
|
||||||
except (NameError, OSError, ValueError):
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||||
pass
|
# ripgrep: 0 = matches, 1 = no matches, 2 = failed scan.
|
||||||
return None, f"grep: could not start fallback worker: {exc}"
|
# Do not present invalid patterns or IO failures as absence.
|
||||||
error = None
|
if p.returncode not in (0, 1):
|
||||||
completed = False
|
detail = (p.stderr or '').strip()[:1200]
|
||||||
|
return None, f"grep: search failed (exit {p.returncode}): {detail or 'no diagnostic available'}"
|
||||||
|
lines = [ln for ln in (p.stdout or "").splitlines() if ln][:max_hits]
|
||||||
|
return lines, None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, "grep: timed out"
|
||||||
|
except Exception as _e:
|
||||||
|
return None, f"grep: {_e}"
|
||||||
try:
|
try:
|
||||||
while len(lines) < max_hits:
|
rx = _re.compile(pattern, _re.IGNORECASE if ignore_case else 0)
|
||||||
remaining = deadline - time.monotonic()
|
except _re.error as _e:
|
||||||
if remaining <= 0:
|
return None, f"grep: bad pattern: {_e}"
|
||||||
error = "grep: timed out"
|
hits = []
|
||||||
break
|
scan_errors = []
|
||||||
try:
|
if os.path.isfile(root):
|
||||||
# Keep queue waits short enough to observe a spawn
|
file_iter = [root]
|
||||||
# worker that dies during bootstrap/import before it
|
else:
|
||||||
# can enqueue either an error or the done sentinel.
|
file_iter = []
|
||||||
record = output_queue.get(timeout=min(0.05, remaining))
|
for dp, dns, fns in os.walk(root, onerror=scan_errors.append):
|
||||||
except queue.Empty:
|
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
||||||
if worker.is_alive():
|
for fn in fns:
|
||||||
|
if glob_pat and not fnmatch.fnmatch(fn, glob_pat):
|
||||||
continue
|
continue
|
||||||
worker.join(timeout=0)
|
file_iter.append(os.path.join(dp, fn))
|
||||||
try:
|
for fp in file_iter:
|
||||||
# A multiprocessing queue's feeder can make the
|
if len(hits) >= max_hits:
|
||||||
# final record visible at process-exit time. Give
|
break
|
||||||
# that record precedence over the exit status.
|
try:
|
||||||
remaining = deadline - time.monotonic()
|
resolved_file = _resolve_tool_path(fp)
|
||||||
record = output_queue.get(
|
except ValueError:
|
||||||
timeout=min(0.05, max(0, remaining))
|
# Apply the same workspace/sensitive-path checks to each
|
||||||
)
|
# discovered file, not just the initial search directory.
|
||||||
except queue.Empty:
|
continue
|
||||||
error = f"grep: fallback worker exited {worker.exitcode}"
|
try:
|
||||||
break
|
with open(resolved_file, "r", encoding="utf-8", errors="strict") as f:
|
||||||
if record[0] == "done":
|
for i, line in enumerate(f, 1):
|
||||||
completed = True
|
if rx.search(line):
|
||||||
break
|
hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}")
|
||||||
if record[0] == "error":
|
if len(hits) >= max_hits:
|
||||||
error = record[1]
|
break
|
||||||
break
|
except UnicodeDecodeError:
|
||||||
_, path, number, text_value = record
|
continue
|
||||||
canonical = os.path.realpath(path)
|
except OSError as error:
|
||||||
if canonical not in descriptors:
|
scan_errors.append(error)
|
||||||
error = "grep: fallback returned an undeclared resource"
|
if scan_errors:
|
||||||
break
|
return None, "grep: search incomplete; one or more files or directories could not be read"
|
||||||
try:
|
return hits, None
|
||||||
_validate_grep_descriptor(descriptors[canonical])
|
|
||||||
if bound is not None:
|
|
||||||
bound.resolve_path(canonical)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
error = f"grep: {exc}"
|
|
||||||
break
|
|
||||||
rendered = f"{_display_tool_path(canonical)}:{number}:{text_value}"
|
|
||||||
if rendered not in lines:
|
|
||||||
lines.append(rendered)
|
|
||||||
finally:
|
|
||||||
if completed:
|
|
||||||
worker.join(timeout=min(1, max(0.01, deadline - time.monotonic())))
|
|
||||||
if worker.is_alive():
|
|
||||||
worker.terminate()
|
|
||||||
worker.join(timeout=1)
|
|
||||||
if worker.is_alive():
|
|
||||||
worker.kill()
|
|
||||||
worker.join()
|
|
||||||
output_queue.close()
|
|
||||||
if error:
|
|
||||||
return None, error
|
|
||||||
if (not completed or worker.exitcode not in (0, None)) and len(lines) < max_hits:
|
|
||||||
return None, f"grep: fallback worker exited {worker.exitcode}"
|
|
||||||
return lines, None
|
|
||||||
|
|
||||||
lines, err = await asyncio.to_thread(_grep)
|
lines, err = await asyncio.to_thread(_grep)
|
||||||
if err:
|
if err:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse, urlsplit, urlunsplit
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -76,14 +76,12 @@ def _resolve_fontfile_for_text(text: str) -> str:
|
|||||||
async def _cancel_host_shell_bridge_request(
|
async def _cancel_host_shell_bridge_request(
|
||||||
url: str, token: str, request_id: str,
|
url: str, token: str, request_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not token or not is_host_shell_bridge_url_allowed(url):
|
base = url.rsplit("/", 1)[0]
|
||||||
return
|
|
||||||
target = host_shell_bridge_endpoint_url(url, "/cancel")
|
|
||||||
try:
|
try:
|
||||||
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
||||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
await client.post(
|
await client.post(
|
||||||
target,
|
f"{base}/cancel",
|
||||||
json={"request_id": request_id},
|
json={"request_id": request_id},
|
||||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||||
)
|
)
|
||||||
@@ -218,12 +216,6 @@ def is_host_shell_bridge_url_allowed(url: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def host_shell_bridge_endpoint_url(url: str, path: str) -> str:
|
|
||||||
"""Replace a validated bridge URL's path without changing its authority."""
|
|
||||||
parsed = urlsplit(url)
|
|
||||||
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_workspace_alias(content: str, cwd: str) -> str:
|
def _replace_workspace_alias(content: str, cwd: str) -> str:
|
||||||
"""Map virtual /workspace paths without corrupting absolute host paths."""
|
"""Map virtual /workspace paths without corrupting absolute host paths."""
|
||||||
return re.sub(
|
return re.sub(
|
||||||
@@ -690,7 +682,6 @@ class HostShellTool:
|
|||||||
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
|
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
|
||||||
if not token:
|
if not token:
|
||||||
return {"error": "host_shell: bridge token missing", "exit_code": 1}
|
return {"error": "host_shell: bridge token missing", "exit_code": 1}
|
||||||
run_url = host_shell_bridge_endpoint_url(url, "/run")
|
|
||||||
|
|
||||||
job_id = str(args.get("job_id") or "").strip()
|
job_id = str(args.get("job_id") or "").strip()
|
||||||
if not command and not job_id:
|
if not command and not job_id:
|
||||||
@@ -725,9 +716,9 @@ class HostShellTool:
|
|||||||
request_body["request_id"] = request_id
|
request_body["request_id"] = request_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout + 5, trust_env=False) as client:
|
async with httpx.AsyncClient(timeout=timeout + 5) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
run_url,
|
url,
|
||||||
json=request_body,
|
json=request_body,
|
||||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||||
)
|
)
|
||||||
@@ -755,7 +746,7 @@ class HostShellTool:
|
|||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
poll = await client.post(
|
poll = await client.post(
|
||||||
run_url,
|
url,
|
||||||
json={"job_id": auto_job_id},
|
json={"job_id": auto_job_id},
|
||||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
+1
-30
@@ -2,11 +2,10 @@
|
|||||||
"""Initialize all application components and dependencies."""
|
"""Initialize all application components and dependencies."""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import stat
|
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from src.constants import (
|
from src.constants import (
|
||||||
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR, AGENT_WORKSPACE_DIR,
|
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR,
|
||||||
SESSIONS_FILE, DEFAULT_HOST, OPENAI_API_KEY
|
SESSIONS_FILE, DEFAULT_HOST, OPENAI_API_KEY
|
||||||
)
|
)
|
||||||
from src.memory import MemoryManager
|
from src.memory import MemoryManager
|
||||||
@@ -32,34 +31,6 @@ def create_directories():
|
|||||||
for directory in (DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR):
|
for directory in (DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR):
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
# The model-controlled workspace must be a real child of DATA_DIR. Never
|
|
||||||
# follow a pre-existing symlink here: it would silently move the default
|
|
||||||
# native-file root outside the application volume before any resolver runs.
|
|
||||||
data_root = os.path.realpath(os.path.abspath(os.path.expanduser(DATA_DIR)))
|
|
||||||
workspace = os.path.abspath(os.path.expanduser(AGENT_WORKSPACE_DIR))
|
|
||||||
expected_workspace = os.path.join(data_root, "agent_workspace")
|
|
||||||
# Validate the real parent so a supported DATA_DIR bind/symlink works, but
|
|
||||||
# require the fixed internal carve-out name and reject a link at the model-
|
|
||||||
# controlled workspace entry itself.
|
|
||||||
if (
|
|
||||||
os.path.basename(workspace) != "agent_workspace"
|
|
||||||
or os.path.realpath(os.path.dirname(workspace)) != data_root
|
|
||||||
):
|
|
||||||
raise RuntimeError("agent workspace must be the canonical child of DATA_DIR")
|
|
||||||
if os.path.lexists(workspace):
|
|
||||||
mode = os.lstat(workspace).st_mode
|
|
||||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
|
||||||
raise RuntimeError("agent workspace must be a real directory")
|
|
||||||
else:
|
|
||||||
os.mkdir(workspace, 0o700)
|
|
||||||
resolved_workspace = os.path.realpath(workspace)
|
|
||||||
if resolved_workspace != expected_workspace:
|
|
||||||
raise RuntimeError("agent workspace must be the canonical child of DATA_DIR")
|
|
||||||
try:
|
|
||||||
os.chmod(workspace, 0o700)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Initialize all manager and handler instances.
|
Initialize all manager and handler instances.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+9
-32
@@ -2297,10 +2297,10 @@ async def action_audit_skills(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
|
|
||||||
async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Background note-due scanner. Fires a reminder for any note whose
|
"""Background note-due scanner. Fires a reminder for any note whose
|
||||||
`due_date` falls in the current ±90-second window and hasn't been delivered
|
`due_date` falls in the current ±5-minute window and hasn't been pinged
|
||||||
through its configured channel in the last 25 minutes.
|
within the last 25 minutes. Mirrors `action_ping_events` for calendar.
|
||||||
|
|
||||||
Per-owner state: {note_id: {at, channel}}, with legacy timestamp support. Pruned
|
State (`data/note_pings.json`): {note_id: iso_ts_of_last_ping}. Pruned
|
||||||
on each run by dropping entries for notes that are gone/archived/replied.
|
on each run by dropping entries for notes that are gone/archived/replied.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -2309,11 +2309,6 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||||||
from pathlib import Path as _P
|
from pathlib import Path as _P
|
||||||
from core.database import SessionLocal as _SL, Note as _N
|
from core.database import SessionLocal as _SL, Note as _N
|
||||||
from src.settings import load_settings
|
|
||||||
|
|
||||||
channel = load_settings().get("reminder_channel", "browser")
|
|
||||||
external_channel = channel in ("email", "ntfy", "webhook")
|
|
||||||
delivery_key = f"{channel}_sent" if external_channel else "browser_sent"
|
|
||||||
|
|
||||||
# Per-owner state file so cache-pruning doesn't cross-delete other
|
# Per-owner state file so cache-pruning doesn't cross-delete other
|
||||||
# users' entries (review C4). Legacy path kept as fallback so a
|
# users' entries (review C4). Legacy path kept as fallback so a
|
||||||
@@ -2378,27 +2373,20 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
due = _parse_due(n.due_date)
|
due = _parse_due(n.due_date)
|
||||||
if not due:
|
if not due:
|
||||||
continue
|
continue
|
||||||
# Inside the due window?
|
# Inside the ±5min window?
|
||||||
if abs((due - now).total_seconds()) > window.total_seconds():
|
if abs((due - now).total_seconds()) > window.total_seconds():
|
||||||
continue
|
continue
|
||||||
# Recently pinged? Skip.
|
# Recently pinged? Skip.
|
||||||
last = cache.get(n.id)
|
last = cache.get(n.id)
|
||||||
browser_recent = False
|
|
||||||
if last:
|
if last:
|
||||||
try:
|
try:
|
||||||
last_channel = None
|
|
||||||
if isinstance(last, dict):
|
if isinstance(last, dict):
|
||||||
last_channel = last.get("channel")
|
|
||||||
last = last.get("at")
|
last = last.get("at")
|
||||||
last_dt = _dt.fromisoformat(str(last))
|
last_dt = _dt.fromisoformat(str(last))
|
||||||
if last_dt.tzinfo is None:
|
if last_dt.tzinfo is None:
|
||||||
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
||||||
if last_dt >= reping_cutoff:
|
if last_dt >= reping_cutoff:
|
||||||
if not external_channel or last_channel == channel:
|
continue
|
||||||
continue
|
|
||||||
# Browser-only receipts do not prove external
|
|
||||||
# delivery, but the fallback must not be queued twice.
|
|
||||||
browser_recent = last_channel in (None, "browser")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Compose + dispatch.
|
# Compose + dispatch.
|
||||||
@@ -2422,26 +2410,15 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
body = "\n\n".join(p for p in body_parts if p) or title
|
body = "\n\n".join(p for p in body_parts if p) or title
|
||||||
try:
|
try:
|
||||||
from routes.note_routes import dispatch_reminder
|
from routes.note_routes import dispatch_reminder
|
||||||
result = await dispatch_reminder(
|
await dispatch_reminder(
|
||||||
title=title, note_body=body, note_id=n.id,
|
title=title, note_body=body, note_id=n.id,
|
||||||
owner=n.owner or owner or "",
|
owner=n.owner or owner or "",
|
||||||
queue_browser=not browser_recent,
|
|
||||||
)
|
)
|
||||||
if result.get("skipped"):
|
cache[n.id] = now.isoformat()
|
||||||
continue
|
sent.append(title)
|
||||||
if result.get(delivery_key):
|
|
||||||
sent.append(title)
|
|
||||||
else:
|
|
||||||
logger.warning("ping_notes: %s delivery failed for %s", channel, n.id)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"ping_notes: dispatch failed for {n.id}: {e}")
|
logger.warning(f"ping_notes: dispatch failed for {n.id}: {e}")
|
||||||
|
|
||||||
# Dispatch owns delivery receipts. Reload before pruning so this
|
|
||||||
# scanner cannot replace a fresh channel receipt with a timestamp.
|
|
||||||
try:
|
|
||||||
cache = _json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Prune cache entries for notes that no longer exist.
|
# Prune cache entries for notes that no longer exist.
|
||||||
for stale in [k for k in cache if k not in seen_ids]:
|
for stale in [k for k in cache if k not in seen_ids]:
|
||||||
cache.pop(stale, None)
|
cache.pop(stale, None)
|
||||||
@@ -2452,7 +2429,7 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
logger.warning(f"ping_notes: cache write failed: {e}")
|
logger.warning(f"ping_notes: cache write failed: {e}")
|
||||||
|
|
||||||
if not sent:
|
if not sent:
|
||||||
raise TaskNoop(f"scanned {len(notes)} note(s), no reminders delivered")
|
raise TaskNoop(f"scanned {len(notes)} note(s), none due in ±{WINDOW_SEC}s")
|
||||||
preview = "; ".join(sent[:3])
|
preview = "; ".join(sent[:3])
|
||||||
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
|
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
|
||||||
return f"Pinged {len(sent)} note(s): {preview}{extra}", True
|
return f"Pinged {len(sent)} note(s): {preview}{extra}", True
|
||||||
|
|||||||
+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:
|
||||||
|
|
||||||
|
|||||||
+3
-13
@@ -3249,7 +3249,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
|||||||
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ollama stream error: {e}")
|
logger.error(f"Ollama stream error: {e}")
|
||||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── Anthropic streaming ──
|
# ── Anthropic streaming ──
|
||||||
@@ -3402,7 +3402,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
|||||||
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Anthropic stream error: {e}")
|
logger.error(f"Anthropic stream error: {e}")
|
||||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── OpenAI-compatible streaming ──
|
# ── OpenAI-compatible streaming ──
|
||||||
@@ -3872,17 +3872,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
|||||||
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Stream error: {e}")
|
logger.error(f"Stream error: {e}")
|
||||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||||
|
|
||||||
|
|
||||||
def _stream_failure_message(error: BaseException) -> str:
|
|
||||||
"""Client-facing text for an unexpected streaming failure.
|
|
||||||
|
|
||||||
The raw exception can carry request URLs, local paths or provider internals;
|
|
||||||
callers log it server-side and stream only this generic message, like the
|
|
||||||
named transport failures above it.
|
|
||||||
"""
|
|
||||||
return f"Model stream failed ({type(error).__name__})"
|
|
||||||
|
|
||||||
|
|
||||||
def _summarize_stream_error(err_chunk: Optional[str]) -> str:
|
def _summarize_stream_error(err_chunk: Optional[str]) -> str:
|
||||||
|
|||||||
@@ -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",
|
||||||
))
|
))
|
||||||
|
|||||||
+2
-9
@@ -6,14 +6,11 @@ writable, and storage is local-first. Served by ``GET /api/ready`` and suitable
|
|||||||
for an orchestrator readiness probe (200 only when every critical check passes).
|
for an orchestrator readiness probe (200 only when every critical check passes).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def check_readiness() -> Dict[str, object]:
|
def check_readiness() -> Dict[str, object]:
|
||||||
"""Run the readiness checks and return a JSON-serialisable report.
|
"""Run the readiness checks and return a JSON-serialisable report.
|
||||||
@@ -36,10 +33,7 @@ def check_readiness() -> Dict[str, object]:
|
|||||||
conn.execute(sql_text("SELECT 1"))
|
conn.execute(sql_text("SELECT 1"))
|
||||||
checks["database"] = {"ok": True}
|
checks["database"] = {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# The raw driver error can carry the DB host/user/path; keep it in the
|
checks["database"] = {"ok": False, "error": str(e)}
|
||||||
# server log and give the client only the exception type.
|
|
||||||
logger.warning("Readiness database check failed: %s", e)
|
|
||||||
checks["database"] = {"ok": False, "error_type": type(e).__name__}
|
|
||||||
|
|
||||||
# Data directory present and writable — home must be able to hold its own data.
|
# Data directory present and writable — home must be able to hold its own data.
|
||||||
try:
|
try:
|
||||||
@@ -50,8 +44,7 @@ def check_readiness() -> Dict[str, object]:
|
|||||||
os.remove(probe)
|
os.remove(probe)
|
||||||
checks["data_dir"] = {"ok": True, "path": DATA_DIR}
|
checks["data_dir"] = {"ok": True, "path": DATA_DIR}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Readiness data_dir check failed: %s", e)
|
checks["data_dir"] = {"ok": False, "error": str(e)}
|
||||||
checks["data_dir"] = {"ok": False, "error_type": type(e).__name__}
|
|
||||||
|
|
||||||
# Local-first: storage stays on the home machine (informational, never fatal).
|
# Local-first: storage stays on the home machine (informational, never fatal).
|
||||||
local_first = (
|
local_first = (
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Process-wide shutdown signalling for graceful restarts.
|
||||||
|
|
||||||
|
Server-Sent-Events / streaming responses never complete on their own, so
|
||||||
|
uvicorn's graceful shutdown would wait for an open browser stream up to the
|
||||||
|
full ``timeout_graceful_shutdown`` window before exiting. A SIGTERM/SIGINT
|
||||||
|
handler flips this flag and tells uvicorn to exit; every streaming generator
|
||||||
|
checks the flag on each loop and ends promptly, so a restart completes in
|
||||||
|
seconds while ordinary in-flight requests still finish.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SHUTTING_DOWN = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def is_shutting_down() -> bool:
|
||||||
|
"""True once a shutdown (SIGTERM/SIGINT) has been requested."""
|
||||||
|
return _SHUTTING_DOWN.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def request_shutdown() -> None:
|
||||||
|
"""Mark the process as shutting down (idempotent)."""
|
||||||
|
if not _SHUTTING_DOWN.is_set():
|
||||||
|
_SHUTTING_DOWN.set()
|
||||||
|
logger.info("shutdown requested; streaming responses will end promptly")
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> None:
|
||||||
|
"""Clear the flag (for tests)."""
|
||||||
|
_SHUTTING_DOWN.clear()
|
||||||
+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.
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hmac
|
|
||||||
import logging
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from hashlib import sha256
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# Keep the existing wire values so the current route and no-build frontend do
|
# Keep the existing wire values so the current route and no-build frontend do
|
||||||
@@ -21,126 +16,6 @@ DENY_APPROVAL_DECISION = "deny"
|
|||||||
# session history contains a matching, resolved chat-session approval.
|
# session history contains a matching, resolved chat-session approval.
|
||||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
|
||||||
|
|
||||||
# The server's proof that IT resolved this approval. More than one route
|
|
||||||
# writes caller-supplied metadata into session history, so a client can write
|
|
||||||
# the shape of a resolved card directly; only the server can produce this.
|
|
||||||
CHAT_SESSION_APPROVAL_SIGNATURE_FIELD = "_server_grant"
|
|
||||||
|
|
||||||
|
|
||||||
def _grant_key() -> bytes | None:
|
|
||||||
"""Key material for grant signatures, or None when it is unavailable.
|
|
||||||
|
|
||||||
Reuses the persistent application key so a grant survives a restart the
|
|
||||||
way the transcript holding it does.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from src.secret_storage import _load_or_create_key
|
|
||||||
|
|
||||||
return _load_or_create_key()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Tool approval grant key unavailable: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def sign_chat_session_grant(
|
|
||||||
session_id: object,
|
|
||||||
approval_id: object,
|
|
||||||
decision: object,
|
|
||||||
) -> str | None:
|
|
||||||
"""Return the server's signature for one resolved chat-session grant."""
|
|
||||||
|
|
||||||
key = _grant_key()
|
|
||||||
if key is None:
|
|
||||||
return None
|
|
||||||
payload = "\x00".join(
|
|
||||||
(
|
|
||||||
str(session_id or ""),
|
|
||||||
str(approval_id or ""),
|
|
||||||
str(decision or "").strip().lower(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return hmac.new(key, payload.encode("utf-8"), sha256).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
# Message-metadata keys the server writes and a caller never should. Both are
|
|
||||||
# read back as authority: ``tool_events`` carries the approval cards, and the
|
|
||||||
# context marker is projected onto a turn once a grant is found.
|
|
||||||
_SERVER_OWNED_METADATA_KEYS = (
|
|
||||||
"tool_events",
|
|
||||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_client_message_metadata(metadata):
|
|
||||||
"""Drop server-owned keys from a caller-supplied message metadata blob.
|
|
||||||
|
|
||||||
Routes that persist a message on the caller's behalf accept this blob
|
|
||||||
verbatim, which lets a caller write the shape of a resolved approval into
|
|
||||||
its own transcript. The grant check verifies a signature, so this is not
|
|
||||||
the control that closes that path; it keeps the state out of the
|
|
||||||
transcript in the first place. Anything else in the blob is left alone.
|
|
||||||
"""
|
|
||||||
if not isinstance(metadata, dict):
|
|
||||||
return metadata
|
|
||||||
if not any(key in metadata for key in _SERVER_OWNED_METADATA_KEYS):
|
|
||||||
return metadata
|
|
||||||
return {
|
|
||||||
key: value
|
|
||||||
for key, value in metadata.items()
|
|
||||||
if key not in _SERVER_OWNED_METADATA_KEYS
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def stamp_chat_session_grant(
|
|
||||||
ask_user: dict,
|
|
||||||
session_id: object,
|
|
||||||
decision: object,
|
|
||||||
) -> None:
|
|
||||||
"""Record the server's grant on a card it has just resolved.
|
|
||||||
|
|
||||||
Call this only from the server-side resolve path. A decision that does not
|
|
||||||
grant chat-session scope leaves no signature behind, so downgrading a
|
|
||||||
``deny`` to an ``approve`` in the transcript does not carry a usable one.
|
|
||||||
"""
|
|
||||||
if not isinstance(ask_user, dict):
|
|
||||||
return
|
|
||||||
if str(decision or "").strip().lower() != CHAT_SESSION_APPROVAL_DECISION:
|
|
||||||
ask_user.pop(CHAT_SESSION_APPROVAL_SIGNATURE_FIELD, None)
|
|
||||||
return
|
|
||||||
signature = sign_chat_session_grant(
|
|
||||||
session_id,
|
|
||||||
ask_user.get("approval_id"),
|
|
||||||
CHAT_SESSION_APPROVAL_DECISION,
|
|
||||||
)
|
|
||||||
if signature:
|
|
||||||
ask_user[CHAT_SESSION_APPROVAL_SIGNATURE_FIELD] = signature
|
|
||||||
|
|
||||||
|
|
||||||
def verify_chat_session_grant(
|
|
||||||
signature: object,
|
|
||||||
session_id: object,
|
|
||||||
approval_id: object,
|
|
||||||
decision: object,
|
|
||||||
) -> bool:
|
|
||||||
"""Whether *signature* is this server's grant for that exact approval.
|
|
||||||
|
|
||||||
Fails CLOSED: an absent, malformed, or unverifiable signature is not a
|
|
||||||
grant. Binding the session and approval ids into the payload means a
|
|
||||||
signature lifted from one chat cannot be replayed into another.
|
|
||||||
"""
|
|
||||||
# compare_digest accepts only ASCII strings. Treat arbitrary persisted
|
|
||||||
# metadata as untrusted and require the exact representation we sign.
|
|
||||||
if (
|
|
||||||
not isinstance(signature, str)
|
|
||||||
or len(signature) != sha256().digest_size * 2
|
|
||||||
or any(character not in "0123456789abcdef" for character in signature)
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
expected = sign_chat_session_grant(session_id, approval_id, decision)
|
|
||||||
if expected is None:
|
|
||||||
return False
|
|
||||||
return hmac.compare_digest(signature, expected)
|
|
||||||
|
|
||||||
|
|
||||||
class ToolApprovalScope(str, Enum):
|
class ToolApprovalScope(str, Enum):
|
||||||
# Surfaces without a resumable chat (the skill tester, unattended audits)
|
# Surfaces without a resumable chat (the skill tester, unattended audits)
|
||||||
|
|||||||
+18
-76
@@ -17,7 +17,7 @@ from types import MappingProxyType
|
|||||||
from typing import Any, Iterable, Mapping
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||||
from src.tool_security import BUILTIN_EMAIL_TOOLS, is_public_blocked_tool
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
|
|
||||||
class ToolEffect(str, Enum):
|
class ToolEffect(str, Enum):
|
||||||
@@ -216,6 +216,20 @@ _register(
|
|||||||
ToolEffect.WRITE_PRIVATE,
|
ToolEffect.WRITE_PRIVATE,
|
||||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
)
|
)
|
||||||
|
_register(
|
||||||
|
{"ai_edit_image"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"get_editor_screenshot"},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"plan_edit", "run_editor_command"},
|
||||||
|
ToolEffect.UI_SIDE_EFFECT,
|
||||||
|
)
|
||||||
_register(
|
_register(
|
||||||
{
|
{
|
||||||
"archive_email",
|
"archive_email",
|
||||||
@@ -501,43 +515,6 @@ def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
|
|||||||
if not isinstance(tool_name, str):
|
if not isinstance(tool_name, str):
|
||||||
return base
|
return base
|
||||||
|
|
||||||
if tool_name in {"web_fetch", "pdf_extract"}:
|
|
||||||
payload = content
|
|
||||||
raw = content.strip() if isinstance(content, str) else ""
|
|
||||||
if isinstance(payload, str):
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw) if raw.startswith("{") else {}
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
payload = {}
|
|
||||||
if not isinstance(payload, Mapping):
|
|
||||||
payload = {}
|
|
||||||
if tool_name == "web_fetch" and "urls" in payload:
|
|
||||||
urls = payload["urls"]
|
|
||||||
sources = []
|
|
||||||
if isinstance(urls, list):
|
|
||||||
for item in urls:
|
|
||||||
source = item.get("url") if isinstance(item, Mapping) else item
|
|
||||||
sources.append(str(source or "").strip())
|
|
||||||
else:
|
|
||||||
source = payload.get("url") or (payload.get("path") if tool_name == "pdf_extract" else "")
|
|
||||||
sources = [str(source or "").strip() or raw.split("\n", 1)[0].strip()]
|
|
||||||
# Match the native readers' local selectors without resolving or
|
|
||||||
# opening files. A mixed batch retains its network effects as well.
|
|
||||||
if tool_name == "web_fetch":
|
|
||||||
local = [source.startswith("/workspace/") or source.lower().startswith("file:///workspace/")
|
|
||||||
for source in sources]
|
|
||||||
else:
|
|
||||||
local = [os.path.isabs(source) or source.lower().startswith("file://") for source in sources]
|
|
||||||
if any(local):
|
|
||||||
effects = {ToolEffect.READ_WORKSPACE}
|
|
||||||
if not all(local):
|
|
||||||
effects.update(base.effects)
|
|
||||||
return ToolCapabilities(
|
|
||||||
frozenset(effects),
|
|
||||||
ResultIntegrity.WORKSPACE_UNTRUSTED if all(local) else base.result_integrity,
|
|
||||||
known=base.known,
|
|
||||||
)
|
|
||||||
|
|
||||||
if tool_name == "extract_text":
|
if tool_name == "extract_text":
|
||||||
payload = content
|
payload = content
|
||||||
if isinstance(payload, str):
|
if isinstance(payload, str):
|
||||||
@@ -678,13 +655,9 @@ POST_EXTERNAL_BLOCKED_EFFECTS = frozenset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# On by default: until agent processes run without network and side-effecting
|
|
||||||
# non-process tools have their own exact-approval boundary, this gate is the
|
|
||||||
# only check between injected external content and those tools. Set it to a
|
|
||||||
# falsy value to opt out.
|
|
||||||
TOOL_APPROVAL_GATE_ENABLED = (
|
TOOL_APPROVAL_GATE_ENABLED = (
|
||||||
str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "1")).strip().lower()
|
str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "0")).strip().lower()
|
||||||
not in {"0", "false", "no", "off"}
|
in {"1", "true", "yes", "on"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -751,18 +724,6 @@ def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> boo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def delegated_tool_is_blocked(tool_name: Any, content: Any = None) -> bool:
|
|
||||||
"""Filter tool names for discovery and concrete workspace reads at dispatch."""
|
|
||||||
# The bridge exposes these spellings for the same filesystem surfaces.
|
|
||||||
if isinstance(tool_name, str):
|
|
||||||
tool_name = tool_name.strip()
|
|
||||||
tool_name = {"list_dir": "ls", "find_files": "glob"}.get(tool_name, tool_name)
|
|
||||||
return is_public_blocked_tool(tool_name) or (
|
|
||||||
content is not None
|
|
||||||
and ToolEffect.READ_WORKSPACE in capabilities_for_action(tool_name, content).effects
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolRunSecurityContext:
|
class ToolRunSecurityContext:
|
||||||
"""Server-owned integrity state for one agent run."""
|
"""Server-owned integrity state for one agent run."""
|
||||||
@@ -779,19 +740,11 @@ class ToolRunSecurityContext:
|
|||||||
# The bypass affects only this automatic gate; current tool policy, ownership,
|
# The bypass affects only this automatic gate; current tool policy, ownership,
|
||||||
# workspace confinement, and execution/sandbox restrictions still apply.
|
# workspace confinement, and execution/sandbox restrictions still apply.
|
||||||
approval_gate_bypassed: bool = False
|
approval_gate_bypassed: bool = False
|
||||||
# Driven by a bearer API token, not a person at a browser. Privileged
|
|
||||||
# tools are refused outright and no approval can lift that.
|
|
||||||
delegated_credential: bool = False
|
|
||||||
|
|
||||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||||
message_list = list(messages or ())
|
message_list = list(messages or ())
|
||||||
if self.delegated_credential:
|
if any(
|
||||||
# A delegated run has no human to grant chat-session scope, so a
|
|
||||||
# grant sitting in this chat's history (left by the owner's own
|
|
||||||
# browser) must not be picked up by a token driving the same chat.
|
|
||||||
self.approval_gate_bypassed = False
|
|
||||||
elif any(
|
|
||||||
isinstance(message, dict)
|
isinstance(message, dict)
|
||||||
and isinstance(message.get("metadata"), dict)
|
and isinstance(message.get("metadata"), dict)
|
||||||
and message["metadata"].get(
|
and message["metadata"].get(
|
||||||
@@ -816,17 +769,6 @@ class ToolRunSecurityContext:
|
|||||||
self.external_untrusted_context_seen = True
|
self.external_untrusted_context_seen = True
|
||||||
|
|
||||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||||
# Checked before the bypasses below, because neither may lift it, and
|
|
||||||
# kept independent of external_untrusted_context_seen so it holds on a
|
|
||||||
# run where that gate never arms and raises no prompt to bypass.
|
|
||||||
if self.delegated_credential and delegated_tool_is_blocked(tool_name, content):
|
|
||||||
return ToolGateDecision(
|
|
||||||
False,
|
|
||||||
(
|
|
||||||
f"Tool '{tool_name}' is not available to API-token callers. "
|
|
||||||
"It requires an interactive session."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if not TOOL_APPROVAL_GATE_ENABLED:
|
if not TOOL_APPROVAL_GATE_ENABLED:
|
||||||
return ToolGateDecision(True)
|
return ToolGateDecision(True)
|
||||||
if self.approval_gate_bypassed:
|
if self.approval_gate_bypassed:
|
||||||
|
|||||||
+39
-308
@@ -15,7 +15,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import stat
|
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -37,7 +36,6 @@ from src.tool_policy import ToolPolicy
|
|||||||
from src.client_tool_contract import TUI_ROUTED_BRIDGE_TOOL_NAMES
|
from src.client_tool_contract import TUI_ROUTED_BRIDGE_TOOL_NAMES
|
||||||
from src.constants import (
|
from src.constants import (
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
AGENT_WORKSPACE_DIR,
|
|
||||||
MAX_DIFF_LINES,
|
MAX_DIFF_LINES,
|
||||||
MAX_OUTPUT_CHARS,
|
MAX_OUTPUT_CHARS,
|
||||||
MAX_READ_CHARS,
|
MAX_READ_CHARS,
|
||||||
@@ -71,11 +69,11 @@ _MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
|
|||||||
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
|
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
|
||||||
|
|
||||||
# Persistent working directory for agent subprocesses.
|
# Persistent working directory for agent subprocesses.
|
||||||
# Resolves to <repo_root>/data/agent_workspace, inside the bind-mounted volume
|
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||||
# in Docker (/app/data), so files survive a rebuild as before. The subdirectory
|
# (/app/data) and the local data directory for manual installs.
|
||||||
# rather than data/ itself keeps agent scratch files and dotfiles out of the
|
# Using this as cwd and HOME prevents the agent from silently creating files
|
||||||
# directory holding the session store and the auth database.
|
# in ephemeral container layers that are lost on the next rebuild.
|
||||||
_AGENT_WORKDIR = AGENT_WORKSPACE_DIR
|
_AGENT_WORKDIR = DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
ExecutionBridgeHandler = Callable[
|
ExecutionBridgeHandler = Callable[
|
||||||
@@ -135,12 +133,6 @@ def get_active_execution_bridge() -> AgentExecutionBridge | None:
|
|||||||
return _active_execution_bridge.get()
|
return _active_execution_bridge.get()
|
||||||
|
|
||||||
|
|
||||||
def _tui_host_bridge_endpoint_url(url: str, path: str) -> str:
|
|
||||||
"""Replace a validated bridge URL's path without changing its authority."""
|
|
||||||
from src.agent_tools.subprocess_tools import host_shell_bridge_endpoint_url
|
|
||||||
return host_shell_bridge_endpoint_url(url, path)
|
|
||||||
|
|
||||||
|
|
||||||
def _tui_host_bridge_patch_url(
|
def _tui_host_bridge_patch_url(
|
||||||
client_runtime_context: Optional[Dict[str, Any]],
|
client_runtime_context: Optional[Dict[str, Any]],
|
||||||
) -> tuple[str, str] | None:
|
) -> tuple[str, str] | None:
|
||||||
@@ -161,7 +153,11 @@ def _tui_host_bridge_patch_url(
|
|||||||
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
||||||
if not is_host_shell_bridge_url_allowed(url):
|
if not is_host_shell_bridge_url_allowed(url):
|
||||||
return None
|
return None
|
||||||
return _tui_host_bridge_endpoint_url(url, "/patch"), token
|
if url.endswith("/run"):
|
||||||
|
url = url[:-4] + "/patch"
|
||||||
|
elif not url.endswith("/patch"):
|
||||||
|
url += "/patch"
|
||||||
|
return url, token
|
||||||
|
|
||||||
|
|
||||||
async def _apply_patch_via_tui_host_bridge(
|
async def _apply_patch_via_tui_host_bridge(
|
||||||
@@ -177,7 +173,7 @@ async def _apply_patch_via_tui_host_bridge(
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
timeout = httpx.Timeout(125.0, connect=5.0, write=10.0, pool=5.0)
|
timeout = httpx.Timeout(125.0, connect=5.0, write=10.0, pool=5.0)
|
||||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
url,
|
url,
|
||||||
headers={"x-odysseus-tui-bridge-token": token},
|
headers={"x-odysseus-tui-bridge-token": token},
|
||||||
@@ -237,7 +233,7 @@ async def _bridge_post(bridge: Dict, path: str, payload: Dict, *, timeout_s: flo
|
|||||||
"error": f"{err_prefix}: invalid TUI host bridge",
|
"error": f"{err_prefix}: invalid TUI host bridge",
|
||||||
"exit_code": 1,
|
"exit_code": 1,
|
||||||
}
|
}
|
||||||
target = _tui_host_bridge_endpoint_url(url, path)
|
base = url.rsplit("/", 1)[0] if url.endswith(("/run", "/read", "/write")) else url.rstrip("/")
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
timeout = httpx.Timeout(
|
timeout = httpx.Timeout(
|
||||||
@@ -246,9 +242,9 @@ async def _bridge_post(bridge: Dict, path: str, payload: Dict, *, timeout_s: flo
|
|||||||
write=10.0,
|
write=10.0,
|
||||||
pool=5.0,
|
pool=5.0,
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
target,
|
f"{base}{path}",
|
||||||
headers={"x-odysseus-tui-bridge-token": token},
|
headers={"x-odysseus-tui-bridge-token": token},
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
@@ -304,13 +300,13 @@ async def _cancel_bridge_request(bridge: Dict, request_id: str) -> None:
|
|||||||
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
||||||
if not token or not is_host_shell_bridge_url_allowed(url):
|
if not token or not is_host_shell_bridge_url_allowed(url):
|
||||||
return
|
return
|
||||||
target = _tui_host_bridge_endpoint_url(url, "/cancel")
|
base = url.rsplit("/", 1)[0]
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
||||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
await client.post(
|
await client.post(
|
||||||
target,
|
f"{base}/cancel",
|
||||||
headers={"x-odysseus-tui-bridge-token": token},
|
headers={"x-odysseus-tui-bridge-token": token},
|
||||||
json={"request_id": request_id},
|
json={"request_id": request_id},
|
||||||
)
|
)
|
||||||
@@ -733,7 +729,7 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
|||||||
path = path_value.strip() if isinstance(path_value, str) else ""
|
path = path_value.strip() if isinstance(path_value, str) else ""
|
||||||
if "\n" in path or "\r" in path:
|
if "\n" in path or "\r" in path:
|
||||||
path = ""
|
path = ""
|
||||||
body_value = args.get("content")
|
body_value = args.get("content", "")
|
||||||
if not isinstance(body_value, str):
|
if not isinstance(body_value, str):
|
||||||
return "write_file: invalid arguments", {
|
return "write_file: invalid arguments", {
|
||||||
"error": "write_file: content must be a string",
|
"error": "write_file: content must be a string",
|
||||||
@@ -743,7 +739,7 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
|||||||
else:
|
else:
|
||||||
path, _, body = content.partition("\n")
|
path, _, body = content.partition("\n")
|
||||||
path = path.strip()
|
path = path.strip()
|
||||||
if not path or path.endswith(("/", "\\")):
|
if not path:
|
||||||
return "write_file: invalid arguments", {
|
return "write_file: invalid arguments", {
|
||||||
"error": "write_file: path is required",
|
"error": "write_file: path is required",
|
||||||
"exit_code": 1,
|
"exit_code": 1,
|
||||||
@@ -751,17 +747,6 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
|||||||
rejected = _text_write_to_binary_artifact_result(content)
|
rejected = _text_write_to_binary_artifact_result(content)
|
||||||
if rejected is not None:
|
if rejected is not None:
|
||||||
return rejected
|
return rejected
|
||||||
from src.agent_tools.filesystem_tools import _parse_write_intent, _unwrap_fenced_source_body
|
|
||||||
try:
|
|
||||||
_, original_body, section, explicit_clear = _parse_write_intent(content)
|
|
||||||
except ValueError as error:
|
|
||||||
return "write_file: invalid arguments", {"error": str(error), "exit_code": 1}
|
|
||||||
body = _unwrap_fenced_source_body(body, path)
|
|
||||||
if not section or (not body.strip() and not explicit_clear):
|
|
||||||
return "write_file: ambiguous content", {
|
|
||||||
"error": "write_file: bridge cannot safely enforce implicit empty content; use explicit JSON string content",
|
|
||||||
"exit_code": 1,
|
|
||||||
}
|
|
||||||
return f"write_file: {path[:80]}", await _bridge_post(
|
return f"write_file: {path[:80]}", await _bridge_post(
|
||||||
bridge,
|
bridge,
|
||||||
"/write",
|
"/write",
|
||||||
@@ -788,15 +773,10 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
|||||||
# 1. Sensitive-subpath deny list — checked FIRST. Blocks .ssh,
|
# 1. Sensitive-subpath deny list — checked FIRST. Blocks .ssh,
|
||||||
# .gnupg, shell rc files, token/env files even if the root above
|
# .gnupg, shell rc files, token/env files even if the root above
|
||||||
# them is on the allowlist.
|
# them is on the allowlist.
|
||||||
# 2. Application-state deny (_is_app_state_path) - DATA_DIR holds the
|
# 2. Allowlist — only the directories the agent legitimately needs
|
||||||
# session store, auth database, app key and settings, so only
|
# (project data/, system tmp). $HOME is NOT on the default list.
|
||||||
# _agent_readable_data_subdirs() is readable inside it.
|
# 3. Opt-in extra roots — admin can add broader roots via the
|
||||||
# 3. Allowlist - only the directories the agent legitimately needs
|
# "tool_path_extra_roots" setting (list of path strings).
|
||||||
# (its data/ workspace, user content, system tmp). $HOME is NOT on
|
|
||||||
# the default list.
|
|
||||||
# 4. Opt-in extra roots - admin can add broader roots via the
|
|
||||||
# "tool_path_extra_roots" setting. These cannot re-open DATA_DIR;
|
|
||||||
# rule 2 is independent of which root a path arrived through.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Compatibility exports: the same deny predicate protects tool access and
|
# Compatibility exports: the same deny predicate protects tool access and
|
||||||
@@ -807,191 +787,6 @@ from src.agent_runtime.path_policy import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _path_within(resolved: str, root: str) -> bool:
|
|
||||||
"""True when *resolved* is *root* itself or sits underneath it.
|
|
||||||
|
|
||||||
Use the platform's path-case rules. This helper participates in allow
|
|
||||||
decisions, so unconditional case-folding would let a distinct ``/DATA``
|
|
||||||
tree masquerade as a descendant of ``/data`` on case-sensitive systems.
|
|
||||||
"""
|
|
||||||
resolved, root = os.path.normcase(resolved), os.path.normcase(root)
|
|
||||||
if resolved == root:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
if os.path.commonpath([resolved, root]) == root:
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
# normcase is intentionally conservative about assumptions (notably on
|
|
||||||
# POSIX), so consult the filesystem when paths exist. This recognizes a
|
|
||||||
# case alias on a case-insensitive volume without treating distinct
|
|
||||||
# case-sensitive paths as the same allow root.
|
|
||||||
if os.path.exists(root):
|
|
||||||
candidate = resolved
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
if os.path.exists(candidate) and os.path.samefile(candidate, root):
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
parent = os.path.dirname(candidate)
|
|
||||||
if parent == candidate:
|
|
||||||
break
|
|
||||||
candidate = parent
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _path_within_conservative(resolved: str, root: str) -> bool:
|
|
||||||
"""Containment for deny decisions, folding case to fail closed."""
|
|
||||||
resolved, root = resolved.casefold(), root.casefold()
|
|
||||||
if resolved == root:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return os.path.commonpath([resolved, root]) == root
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_readable_data_subdirs() -> tuple[str, ...]:
|
|
||||||
"""The only parts of DATA_DIR the agent's file tools may reach.
|
|
||||||
|
|
||||||
The agent's own scratch folder, plus the directories of user content whose
|
|
||||||
paths the application itself gives to the model, which it would then be
|
|
||||||
unable to open. These normally live under DATA_DIR; the documented mail
|
|
||||||
attachment override may instead name a disjoint external directory:
|
|
||||||
|
|
||||||
UPLOAD_DIR the chat upload manifest renders "path=<p>" and
|
|
||||||
says to read it with read_file (agent_loop.py)
|
|
||||||
MAIL_ATTACHMENTS_DIR download_attachment returns the path and its own
|
|
||||||
description tells the model to read it
|
|
||||||
PERSONAL_DIR GET /api/personal returns a path per file and is
|
|
||||||
reachable through the app_api tool; RUNBOOK_DIR
|
|
||||||
nests under it
|
|
||||||
PERSONAL_UPLOADS_DIR indexed as a personal-docs directory, which
|
|
||||||
manage_rag lists as an absolute path
|
|
||||||
|
|
||||||
Order matters: the first entry is roots[0], which _resolve_search_root uses
|
|
||||||
when grep/glob/ls are called with no path.
|
|
||||||
"""
|
|
||||||
from src.constants import (
|
|
||||||
DATA_DIR,
|
|
||||||
MAIL_ATTACHMENTS_DIR,
|
|
||||||
PERSONAL_DIR,
|
|
||||||
PERSONAL_UPLOADS_DIR,
|
|
||||||
UPLOAD_DIR,
|
|
||||||
)
|
|
||||||
configured = (
|
|
||||||
(AGENT_WORKSPACE_DIR, "agent_workspace", False),
|
|
||||||
(UPLOAD_DIR, "uploads", False),
|
|
||||||
# This has a documented environment override and may legitimately
|
|
||||||
# live outside DATA_DIR, but it must never equal/contain DATA_DIR.
|
|
||||||
(MAIL_ATTACHMENTS_DIR, "mail-attachments", True),
|
|
||||||
(PERSONAL_DIR, "personal_docs", False),
|
|
||||||
(PERSONAL_UPLOADS_DIR, "personal_uploads", False),
|
|
||||||
)
|
|
||||||
configured_data_dir = os.path.abspath(os.path.expanduser(str(DATA_DIR)))
|
|
||||||
data_dir = os.path.realpath(configured_data_dir)
|
|
||||||
safe: list[str] = []
|
|
||||||
for raw, internal_name, external_ok in configured:
|
|
||||||
value = str(raw or "").strip()
|
|
||||||
# These paths are security-policy roots, not ordinary allowlist
|
|
||||||
# entries. Internal roles may inherit a relative DATA_DIR, but must
|
|
||||||
# still resolve to their exact canonical child below. External mail
|
|
||||||
# overrides require an absolute, disjoint directory.
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
expanded = os.path.abspath(os.path.expanduser(value))
|
|
||||||
# A policy root must not acquire an exemption by redirecting its final
|
|
||||||
# path component to protected state or to an unrelated external tree.
|
|
||||||
if os.path.islink(expanded):
|
|
||||||
continue
|
|
||||||
resolved = os.path.realpath(expanded)
|
|
||||||
if os.path.exists(resolved) and not os.path.isdir(resolved):
|
|
||||||
continue
|
|
||||||
expected_internal = os.path.join(data_dir, internal_name)
|
|
||||||
expected_configured = os.path.join(configured_data_dir, internal_name)
|
|
||||||
inside_data = (
|
|
||||||
os.path.normcase(expanded)
|
|
||||||
in {
|
|
||||||
os.path.normcase(expected_configured),
|
|
||||||
os.path.normcase(expected_internal),
|
|
||||||
}
|
|
||||||
and resolved == expected_internal
|
|
||||||
)
|
|
||||||
external_safe = (
|
|
||||||
external_ok
|
|
||||||
and os.path.isabs(os.path.expanduser(value))
|
|
||||||
and resolved != data_dir
|
|
||||||
and os.path.dirname(resolved) != resolved
|
|
||||||
and not _path_within(data_dir, resolved)
|
|
||||||
and not _path_within(resolved, data_dir)
|
|
||||||
)
|
|
||||||
if not (inside_data or external_safe) or _is_sensitive_path(resolved):
|
|
||||||
continue
|
|
||||||
safe.append(resolved)
|
|
||||||
return tuple(safe)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_app_state_path(resolved: str) -> bool:
|
|
||||||
"""True for anything under DATA_DIR that is not agent-readable.
|
|
||||||
|
|
||||||
DATA_DIR holds the session store, the auth database, the app encryption key
|
|
||||||
and the settings file. A model-supplied path must not reach those through
|
|
||||||
any root, so this is checked in both resolvers rather than expressed as an
|
|
||||||
absence from the allowlist: a workspace bound at or above the data
|
|
||||||
directory, or an opt-in tool_path_extra_roots entry covering it, would
|
|
||||||
otherwise put them back in reach.
|
|
||||||
|
|
||||||
A containment rule rather than a filename deny list, so state files added
|
|
||||||
later are covered without anyone remembering to list them. The composed
|
|
||||||
sensitive-name policy also denies settings.json and app.db in workspaces.
|
|
||||||
"""
|
|
||||||
from src.constants import DATA_DIR
|
|
||||||
if not _path_within_conservative(resolved, os.path.realpath(DATA_DIR)):
|
|
||||||
return False
|
|
||||||
return not any(
|
|
||||||
_path_within(resolved, d)
|
|
||||||
for d in _agent_readable_data_subdirs()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_hardlinked_regular_file(resolved: str) -> bool:
|
|
||||||
"""Reject inode aliases that can smuggle DATA_DIR state into an allow root."""
|
|
||||||
try:
|
|
||||||
target = os.stat(resolved, follow_symlinks=False)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1
|
|
||||||
|
|
||||||
|
|
||||||
def _is_denied_tool_path(resolved: str, *, snapshot=None) -> bool:
|
|
||||||
"""Apply every path deny to a canonical traversal result.
|
|
||||||
|
|
||||||
Directory scans pass one ``_control_plane_snapshot()`` for the whole walk;
|
|
||||||
rebuilding it per entry made grep/ls/glob linear in protected-state stats.
|
|
||||||
"""
|
|
||||||
from src.agent_runtime.resources import _control_plane_path
|
|
||||||
return (
|
|
||||||
_is_sensitive_path(resolved)
|
|
||||||
or _is_app_state_path(resolved)
|
|
||||||
or _is_hardlinked_regular_file(resolved)
|
|
||||||
or _control_plane_path(resolved, snapshot=snapshot)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _can_traverse_tool_path(resolved: str, *, snapshot=None) -> bool:
|
|
||||||
"""Allow walking a denied state parent only to reach safe carve-outs."""
|
|
||||||
if _is_sensitive_path(resolved):
|
|
||||||
return False
|
|
||||||
if not _is_app_state_path(resolved):
|
|
||||||
from src.agent_runtime.resources import _control_plane_path
|
|
||||||
return not _control_plane_path(resolved, snapshot=snapshot)
|
|
||||||
return any(
|
|
||||||
_path_within(readable, resolved)
|
|
||||||
for readable in _agent_readable_data_subdirs()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_path_roots() -> list[str]:
|
def _tool_path_roots() -> list[str]:
|
||||||
"""Return the list of directory roots that read_file / write_file
|
"""Return the list of directory roots that read_file / write_file
|
||||||
may touch. Default: project data/ + system temp dirs. Extra roots
|
may touch. Default: project data/ + system temp dirs. Extra roots
|
||||||
@@ -999,9 +794,9 @@ def _tool_path_roots() -> list[str]:
|
|||||||
"""
|
"""
|
||||||
roots: list[str] = []
|
roots: list[str] = []
|
||||||
|
|
||||||
# The agent's workspace plus the user-content directories inside data/.
|
# Project data directory — the agent's primary workspace.
|
||||||
# The rest of DATA_DIR is denied by _is_app_state_path.
|
from src.constants import DATA_DIR
|
||||||
roots.extend(_agent_readable_data_subdirs())
|
roots.append(DATA_DIR)
|
||||||
|
|
||||||
# /tmp (and its macOS realpath /private/tmp).
|
# /tmp (and its macOS realpath /private/tmp).
|
||||||
roots.append("/tmp")
|
roots.append("/tmp")
|
||||||
@@ -1058,10 +853,7 @@ def _resolve_tool_path(raw_path: str) -> str:
|
|||||||
"""
|
"""
|
||||||
resource_operation = active_resource_operation()
|
resource_operation = active_resource_operation()
|
||||||
if resource_operation is not None:
|
if resource_operation is not None:
|
||||||
resolved = resource_operation.resolve_path(raw_path)
|
return resource_operation.resolve_path(raw_path)
|
||||||
if _is_denied_tool_path(resolved):
|
|
||||||
raise ValueError("bound path is sensitive, application state or a hard-linked file")
|
|
||||||
return resolved
|
|
||||||
ws = get_active_workspace()
|
ws = get_active_workspace()
|
||||||
if ws:
|
if ws:
|
||||||
return _resolve_tool_path_in_workspace(ws, raw_path)
|
return _resolve_tool_path_in_workspace(ws, raw_path)
|
||||||
@@ -1070,20 +862,11 @@ def _resolve_tool_path(raw_path: str) -> str:
|
|||||||
expanded = os.path.expanduser(str(raw_path).strip())
|
expanded = os.path.expanduser(str(raw_path).strip())
|
||||||
resolved = os.path.realpath(expanded)
|
resolved = os.path.realpath(expanded)
|
||||||
|
|
||||||
if _is_app_state_path(resolved):
|
|
||||||
raise ValueError(
|
|
||||||
f"path '{raw_path}' is inside the application state directory"
|
|
||||||
)
|
|
||||||
if _is_sensitive_path(resolved):
|
if _is_sensitive_path(resolved):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"path '{raw_path}' is inside a sensitive directory "
|
f"path '{raw_path}' is inside a sensitive directory "
|
||||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||||
)
|
)
|
||||||
if _is_hardlinked_regular_file(resolved):
|
|
||||||
raise ValueError(f"path '{raw_path}' is a hard-linked file")
|
|
||||||
|
|
||||||
if _is_denied_tool_path(resolved):
|
|
||||||
raise ValueError("path addresses sensitive execution control-plane state")
|
|
||||||
|
|
||||||
for root in _tool_path_roots():
|
for root in _tool_path_roots():
|
||||||
if is_inside(root, resolved):
|
if is_inside(root, resolved):
|
||||||
@@ -1120,19 +903,11 @@ def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
|
|||||||
# Confinement says "inside the root"; the deny list says "allowed". They
|
# Confinement says "inside the root"; the deny list says "allowed". They
|
||||||
# are separate questions and this one stays here, with the policy that
|
# are separate questions and this one stays here, with the policy that
|
||||||
# owns it.
|
# owns it.
|
||||||
if _is_app_state_path(resolved):
|
|
||||||
raise ValueError(
|
|
||||||
f"path '{raw_path}' is inside the application state directory"
|
|
||||||
)
|
|
||||||
if _is_sensitive_path(resolved):
|
if _is_sensitive_path(resolved):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"path '{raw_path}' is inside a sensitive directory "
|
f"path '{raw_path}' is inside a sensitive directory "
|
||||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||||
)
|
)
|
||||||
if _is_hardlinked_regular_file(resolved):
|
|
||||||
raise ValueError(f"path '{raw_path}' is a hard-linked file")
|
|
||||||
if _is_denied_tool_path(resolved):
|
|
||||||
raise ValueError("path addresses sensitive execution control-plane state")
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
@@ -1191,10 +966,6 @@ def vet_workspace(raw: str) -> Optional[str]:
|
|||||||
resolved = os.path.realpath(os.path.expanduser(raw))
|
resolved = os.path.realpath(os.path.expanduser(raw))
|
||||||
if not os.path.isdir(resolved) or _is_sensitive_path(resolved):
|
if not os.path.isdir(resolved) or _is_sensitive_path(resolved):
|
||||||
return None
|
return None
|
||||||
# Refuse the bind rather than binding a workspace where every subsequent
|
|
||||||
# tool call would fail on the same deny list.
|
|
||||||
if _is_denied_tool_path(resolved):
|
|
||||||
return None
|
|
||||||
# Reject filesystem roots: binding / (or a Windows drive/UNC root) as the
|
# Reject filesystem roots: binding / (or a Windows drive/UNC root) as the
|
||||||
# workspace would make every absolute path "inside" it, collapsing the
|
# workspace would make every absolute path "inside" it, collapsing the
|
||||||
# confinement into host-wide file access. A root is its own dirname, which
|
# confinement into host-wide file access. A root is its own dirname, which
|
||||||
@@ -1206,25 +977,11 @@ def vet_workspace(raw: str) -> Optional[str]:
|
|||||||
|
|
||||||
def agent_cwd() -> str:
|
def agent_cwd() -> str:
|
||||||
"""Working directory for agent subprocesses (bash/python/background jobs):
|
"""Working directory for agent subprocesses (bash/python/background jobs):
|
||||||
a validated sealed launch root, active workspace, or agent workspace."""
|
the active workspace when set, else the persistent data dir."""
|
||||||
from src.agent_runtime.process_resources import active_process_operation
|
from src.agent_runtime.process_resources import active_process_operation
|
||||||
bound = active_process_operation()
|
bound = active_process_operation()
|
||||||
if bound is not None and bound.launch is not None:
|
return (bound.launch.scope.root.path if bound is not None and bound.launch is not None
|
||||||
bound.launch.scope.validate()
|
else get_active_workspace() or _AGENT_WORKDIR)
|
||||||
root = bound.launch.scope.root.path
|
|
||||||
if vet_workspace(root) != root:
|
|
||||||
raise RuntimeError("sealed process root is not a safe real directory")
|
|
||||||
return root
|
|
||||||
workspace = get_active_workspace()
|
|
||||||
if workspace:
|
|
||||||
vetted = vet_workspace(workspace)
|
|
||||||
if vetted is None:
|
|
||||||
raise RuntimeError("active workspace is not a safe real directory")
|
|
||||||
return vetted
|
|
||||||
resolved = os.path.realpath(_AGENT_WORKDIR)
|
|
||||||
if resolved not in _agent_readable_data_subdirs() or vet_workspace(resolved) != resolved:
|
|
||||||
raise RuntimeError("agent workspace is not a safe real directory")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def get_mcp_manager():
|
def get_mcp_manager():
|
||||||
@@ -1239,28 +996,19 @@ def _resolve_search_root(raw_path: str) -> str:
|
|||||||
|
|
||||||
With a workspace active, the workspace folder is the root and a supplied
|
With a workspace active, the workspace folder is the root and a supplied
|
||||||
path is confined inside it. Otherwise an empty path defaults to the agent's
|
path is confined inside it. Otherwise an empty path defaults to the agent's
|
||||||
primary root (its workspace under the project data dir) and a supplied path
|
primary root (project data dir) and a supplied path is confined by the
|
||||||
is confined by the global allowlist + sensitive-file policy.
|
global allowlist + sensitive-file policy.
|
||||||
"""
|
"""
|
||||||
resource_operation = active_resource_operation()
|
resource_operation = active_resource_operation()
|
||||||
if resource_operation is not None:
|
if resource_operation is not None:
|
||||||
resolved = resource_operation.resolve_path(raw_path, search=True)
|
return resource_operation.resolve_path(raw_path, search=True)
|
||||||
if _is_denied_tool_path(resolved):
|
|
||||||
raise ValueError("bound search root is sensitive, application state or a hard-linked file")
|
|
||||||
return resolved
|
|
||||||
raw = (raw_path or "").strip()
|
raw = (raw_path or "").strip()
|
||||||
ws = get_active_workspace()
|
ws = get_active_workspace()
|
||||||
if ws:
|
if ws:
|
||||||
# Resolve the empty case as the workspace path rather than returning
|
return os.path.realpath(ws) if not raw else _resolve_tool_path_in_workspace(ws, raw)
|
||||||
# it directly: returned unchecked it skipped both deny lists, so a
|
|
||||||
# bare ls listed whatever the workspace was bound to.
|
|
||||||
return _resolve_tool_path_in_workspace(ws, raw or ws)
|
|
||||||
if not raw:
|
if not raw:
|
||||||
roots = _tool_path_roots()
|
roots = _tool_path_roots()
|
||||||
default_root = os.path.realpath(AGENT_WORKSPACE_DIR)
|
return roots[0] if roots else os.path.realpath(".")
|
||||||
if default_root in roots and not _is_denied_tool_path(default_root):
|
|
||||||
return default_root
|
|
||||||
raise ValueError("default agent workspace is not a safe readable data subdirectory")
|
|
||||||
return _resolve_tool_path(raw)
|
return _resolve_tool_path(raw)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1651,14 +1399,6 @@ async def execute_tool_block(
|
|||||||
"NO_TOOL_SECURITY_CONTEXT"
|
"NO_TOOL_SECURITY_CONTEXT"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Credential denial precedes authority, exact approval claim and bridges.
|
|
||||||
if (isinstance(security_context, ToolRunSecurityContext)
|
|
||||||
and security_context.delegated_credential):
|
|
||||||
from src.tool_capabilities import delegated_tool_is_blocked
|
|
||||||
if delegated_tool_is_blocked(getattr(block, "tool_type", None), getattr(block, "content", None)):
|
|
||||||
decision = security_context.decision_for(block.tool_type, block.content)
|
|
||||||
return blocked_tool_result(block.tool_type, decision.reason)
|
|
||||||
|
|
||||||
authority = active_request_authority() if request_authority is MISSING_AUTHORITY else request_authority
|
authority = active_request_authority() if request_authority is MISSING_AUTHORITY else request_authority
|
||||||
parent = active_request_authority()
|
parent = active_request_authority()
|
||||||
if isinstance(authority, RequestAuthority) and parent is not None and authority is not parent:
|
if isinstance(authority, RequestAuthority) and parent is not None and authority is not parent:
|
||||||
@@ -2082,17 +1822,6 @@ async def _execute_tool_block_impl(
|
|||||||
|
|
||||||
mark_authorized()
|
mark_authorized()
|
||||||
if bridge_owns_tool:
|
if bridge_owns_tool:
|
||||||
if tool == "write_file":
|
|
||||||
from src.agent_tools.filesystem_tools import _parse_write_intent, _unwrap_fenced_source_body
|
|
||||||
try:
|
|
||||||
path, body, section, explicit_clear = _parse_write_intent(content)
|
|
||||||
if not path or path.endswith(("/", "\\")) or "\n" in path or "\r" in path:
|
|
||||||
raise ValueError("write_file: valid file path required")
|
|
||||||
body = _unwrap_fenced_source_body(body, path)
|
|
||||||
if not section or (not body.strip() and not explicit_clear):
|
|
||||||
raise ValueError("write_file: bridge cannot safely enforce implicit empty content; use explicit JSON string content")
|
|
||||||
except ValueError as error:
|
|
||||||
return "write_file: invalid arguments", {"error": str(error), "exit_code": 1}
|
|
||||||
try:
|
try:
|
||||||
return await dispatched(execution_bridge.route_tool(
|
return await dispatched(execution_bridge.route_tool(
|
||||||
tool,
|
tool,
|
||||||
@@ -2164,7 +1893,8 @@ async def _execute_tool_block_impl(
|
|||||||
if tool == "generate_image":
|
if tool == "generate_image":
|
||||||
from src.ai_interaction import do_generate_image
|
from src.ai_interaction import do_generate_image
|
||||||
desc = "generate_image"
|
desc = "generate_image"
|
||||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
|
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||||
|
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||||
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
||||||
and (active_resource_operation() is not None or tool != "apply_patch"
|
and (active_resource_operation() is not None or tool != "apply_patch"
|
||||||
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
||||||
@@ -2324,7 +2054,8 @@ async def _execute_tool_block_impl(
|
|||||||
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
||||||
elif tool == "edit_image":
|
elif tool == "edit_image":
|
||||||
desc = "edit_image"
|
desc = "edit_image"
|
||||||
result = await dispatched(do_edit_image(content, owner=owner))
|
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||||
|
result = await dispatched(do_edit_image(content, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||||
elif tool == "edit_file":
|
elif tool == "edit_file":
|
||||||
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
||||||
desc = result.get("output") or result.get("error") or "edit_file"
|
desc = result.get("output") or result.get("error") or "edit_file"
|
||||||
|
|||||||
+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.",
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-163
@@ -194,33 +194,10 @@ _TOOL_CODE_OPEN_RE = re.compile(r"<tool_code>\s*\{", re.IGNORECASE)
|
|||||||
_TOOL_CODE_CLOSE_RE = re.compile(r"\}\s*</tool_code>", re.IGNORECASE)
|
_TOOL_CODE_CLOSE_RE = re.compile(r"\}\s*</tool_code>", re.IGNORECASE)
|
||||||
|
|
||||||
# Pattern 4b: Gemma-style <|tool_call|> call:tool_name{args} <tool_call|>
|
# Pattern 4b: Gemma-style <|tool_call|> call:tool_name{args} <tool_call|>
|
||||||
_GEMMA_TOOL_CALL_OPEN_RE = re.compile(
|
_GEMMA_TOOL_CALL_RE = re.compile(
|
||||||
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*\{",
|
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*(\{[\s\S]*?\})\s*<\|?tool_call\|?>",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_GEMMA_TOOL_CALL_CLOSE_RE = re.compile(
|
|
||||||
r"\}\s*<\|?tool_call\|?>",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Native Qwen markup shares the same non-nesting delimiter grammar as the
|
|
||||||
# XML helpers. Literal closers keep whitespace and opener floods linear;
|
|
||||||
# values are stripped by the caller, as in the original regex path.
|
|
||||||
_QWEN_FUNCTION_OPEN_RE = re.compile(r"<function=([A-Za-z_][\w:.-]*)>\s*")
|
|
||||||
_QWEN_FUNCTION_CLOSE_RE = re.compile(r"</function>")
|
|
||||||
_QWEN_PARAMETER_OPEN_RE = re.compile(r"<parameter=([A-Za-z_]\w*)>\s*")
|
|
||||||
_QWEN_PARAMETER_CLOSE_RE = re.compile(r"</parameter>")
|
|
||||||
_QWEN_PYTHON_ARG_KEY_RE = re.compile(r"[A-Za-z_]\w*")
|
|
||||||
_QWEN_PYTHON_ARG_VALUE_RE = re.compile(r"\s*=\s*(['\"].*?['\"]|[^,]+)")
|
|
||||||
_ANGLE_TAG_OPEN_RE = re.compile(r"<")
|
|
||||||
_NONEMPTY_ANGLE_TAG_OPEN_RE = re.compile(r"<(?=[^>])")
|
|
||||||
_ANGLE_TAG_CLOSE_RE = re.compile(r">")
|
|
||||||
_EMAIL_LOCAL_RE = re.compile(r"[\w.+-]+")
|
|
||||||
_EMAIL_ADDRESS_RE = re.compile(r"[\w.+-]+@[\w.-]+\.\w+")
|
|
||||||
_ASCII_EMAIL_LOCAL_RE = re.compile(r"[A-Za-z0-9.!#$%&\x27*+/=?^_`{|}~-]+")
|
|
||||||
_ASCII_EMAIL_ADDRESS_RE = re.compile(
|
|
||||||
r"[A-Za-z0-9.!#$%&\x27*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pattern 4c: Open-function wrapper emitted by some local MLX/Exo models.
|
# Pattern 4c: Open-function wrapper emitted by some local MLX/Exo models.
|
||||||
# Example:
|
# Example:
|
||||||
@@ -565,34 +542,6 @@ _PLAIN_UI_OPEN_PANEL_RE = re.compile(
|
|||||||
r"((?:\s+(?:day|week|month|year|agenda)(?:\s+view)?(?:\s+\d{4}-\d{2}(?:-\d{2})?)?)?)"
|
r"((?:\s+(?:day|week|month|year|agenda)(?:\s+view)?(?:\s+\d{4}-\d{2}(?:-\d{2})?)?)?)"
|
||||||
r"\s*(?:`{1,3})?\s*$"
|
r"\s*(?:`{1,3})?\s*$"
|
||||||
)
|
)
|
||||||
_PLAIN_UI_CANDIDATE_RE = re.compile(r"ui_control", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_plain_ui_open_panel(text: str):
|
|
||||||
"""Yield line-anchored UI commands without retrying every blank line."""
|
|
||||||
pos = 0
|
|
||||||
while candidate := _PLAIN_UI_CANDIDATE_RE.search(text, pos):
|
|
||||||
line_start = text.rfind("\n", 0, candidate.start()) + 1
|
|
||||||
match = _PLAIN_UI_OPEN_PANEL_RE.match(text, line_start)
|
|
||||||
if match is not None:
|
|
||||||
yield match
|
|
||||||
pos = match.end()
|
|
||||||
continue
|
|
||||||
newline = text.find("\n", candidate.end())
|
|
||||||
pos = len(text) if newline < 0 else newline + 1
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_plain_ui_open_panel(text: str) -> str:
|
|
||||||
matches = list(_iter_plain_ui_open_panel(text))
|
|
||||||
if not matches:
|
|
||||||
return text
|
|
||||||
out = []
|
|
||||||
pos = 0
|
|
||||||
for match in matches:
|
|
||||||
out.append(text[pos:match.start()])
|
|
||||||
pos = match.end()
|
|
||||||
out.append(text[pos:])
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -909,10 +858,7 @@ def _raw_openai_tool_call_to_block(value) -> Optional[ToolBlock]:
|
|||||||
elif tool_type in ("grep", "glob", "ls", "edit_file"):
|
elif tool_type in ("grep", "glob", "ls", "edit_file"):
|
||||||
content = json.dumps(args) if args else "{}"
|
content = json.dumps(args) if args else "{}"
|
||||||
elif tool_type == "write_file":
|
elif tool_type == "write_file":
|
||||||
# Keep raw OpenAI JSON on the canonical path so explicit empty content
|
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||||
# remains distinguishable from a missing body.
|
|
||||||
from src.tool_schemas import function_call_to_tool_block
|
|
||||||
return function_call_to_tool_block(name, json.dumps(args))
|
|
||||||
elif tool_type == "create_document":
|
elif tool_type == "create_document":
|
||||||
parts = [args.get("title", "Untitled")]
|
parts = [args.get("title", "Untitled")]
|
||||||
if args.get("language"):
|
if args.get("language"):
|
||||||
@@ -1043,43 +989,6 @@ def _strip_raw_openai_tool_call_json(text: str) -> str:
|
|||||||
return "".join(pieces)
|
return "".join(pieces)
|
||||||
|
|
||||||
|
|
||||||
def iter_email_addresses(text: str, *, ascii_only: bool = False):
|
|
||||||
"""Find legacy email-shaped strings without retrying every word suffix.
|
|
||||||
|
|
||||||
Match the address only at the start of each maximal local-part token.
|
|
||||||
When that attempt fails, every suffix has the same '@'/domain boundary
|
|
||||||
and must fail too. Local/domain character runs are each scanned a bounded
|
|
||||||
number of times, including malformed text with no '@' or domain dot.
|
|
||||||
"""
|
|
||||||
local_re = _ASCII_EMAIL_LOCAL_RE if ascii_only else _EMAIL_LOCAL_RE
|
|
||||||
address_re = _ASCII_EMAIL_ADDRESS_RE if ascii_only else _EMAIL_ADDRESS_RE
|
|
||||||
pos = 0
|
|
||||||
while token := local_re.search(text, pos):
|
|
||||||
address = address_re.match(text, token.start())
|
|
||||||
if address is not None:
|
|
||||||
yield address.group(0)
|
|
||||||
pos = address.end()
|
|
||||||
else:
|
|
||||||
pos = token.end()
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_qwen_python_args(raw_args: str):
|
|
||||||
"""Match each maximal key once, then its value at that fixed position.
|
|
||||||
|
|
||||||
If a word has no following equals sign, none of its suffixes can have one
|
|
||||||
either. Advancing past it avoids the old unanchored regex's O(n^2) retries
|
|
||||||
on a long malformed key, while preserving permissive quoted/bare values.
|
|
||||||
"""
|
|
||||||
pos = 0
|
|
||||||
while key := _QWEN_PYTHON_ARG_KEY_RE.search(raw_args, pos):
|
|
||||||
value = _QWEN_PYTHON_ARG_VALUE_RE.match(raw_args, key.end())
|
|
||||||
if value is not None:
|
|
||||||
yield key.group(0), value.group(1)
|
|
||||||
pos = value.end()
|
|
||||||
else:
|
|
||||||
pos = key.end()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_qwen3_native_text_call(
|
def _parse_qwen3_native_text_call(
|
||||||
text: str,
|
text: str,
|
||||||
additional_tool_names: Optional[Iterable[str]] = None,
|
additional_tool_names: Optional[Iterable[str]] = None,
|
||||||
@@ -1106,14 +1015,13 @@ def _parse_qwen3_native_text_call(
|
|||||||
|
|
||||||
# Qwen native text rendering:
|
# Qwen native text rendering:
|
||||||
# <tool_call><function=manage_notes><parameter=action>list</parameter>...
|
# <tool_call><function=manage_notes><parameter=action>list</parameter>...
|
||||||
fn_match = next(_iter_named_blocks(
|
fn_match = re.search(r"<function=([A-Za-z_][\w:.-]*)>\s*([\s\S]*?)\s*</function>", text)
|
||||||
text, _QWEN_FUNCTION_OPEN_RE, _QWEN_FUNCTION_CLOSE_RE
|
|
||||||
), None)
|
|
||||||
if fn_match:
|
if fn_match:
|
||||||
name, raw_body = fn_match
|
name, raw_body = fn_match.groups()
|
||||||
args = {}
|
args = {}
|
||||||
for key, raw_value in _iter_named_blocks(
|
for key, raw_value in re.findall(
|
||||||
raw_body.rstrip(), _QWEN_PARAMETER_OPEN_RE, _QWEN_PARAMETER_CLOSE_RE,
|
r"<parameter=([A-Za-z_]\w*)>\s*([\s\S]*?)\s*</parameter>",
|
||||||
|
raw_body,
|
||||||
):
|
):
|
||||||
value = raw_value.strip()
|
value = raw_value.strip()
|
||||||
if value and value[0] in "[{\"":
|
if value and value[0] in "[{\"":
|
||||||
@@ -1174,7 +1082,7 @@ def _parse_qwen3_native_text_call(
|
|||||||
or normalized_name in declared_names
|
or normalized_name in declared_names
|
||||||
):
|
):
|
||||||
args = {}
|
args = {}
|
||||||
for key, raw_value in _iter_qwen_python_args(raw_args):
|
for key, raw_value in re.findall(r"([A-Za-z_]\w*)\s*=\s*(['\"].*?['\"]|[^,]+)", raw_args):
|
||||||
try:
|
try:
|
||||||
args[key] = ast.literal_eval(raw_value.strip())
|
args[key] = ast.literal_eval(raw_value.strip())
|
||||||
except (ValueError, SyntaxError):
|
except (ValueError, SyntaxError):
|
||||||
@@ -1515,45 +1423,6 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
|
|||||||
return ToolBlock(tool_name, content.strip())
|
return ToolBlock(tool_name, content.strip())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_GEMMA_FALLBACK_KEY_RE = re.compile(r"\w++")
|
|
||||||
_GEMMA_FALLBACK_COLON_RE = re.compile(r"\s*+:")
|
|
||||||
_GEMMA_FALLBACK_BOUNDARY_RE = re.compile(r"(?<!\s)(\s*+)(?:,\s*+\w++\s*+:|})")
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_gemma_fallback_items(body):
|
|
||||||
"""Preserve permissive k:v recovery with one pass over keys/boundaries."""
|
|
||||||
boundaries = list(_GEMMA_FALLBACK_BOUNDARY_RE.finditer(body))
|
|
||||||
boundary_index = 0
|
|
||||||
pos = 0
|
|
||||||
newline = body.find("\n")
|
|
||||||
while key := _GEMMA_FALLBACK_KEY_RE.search(body, pos):
|
|
||||||
colon = _GEMMA_FALLBACK_COLON_RE.match(body, key.end())
|
|
||||||
if colon is None:
|
|
||||||
pos = key.end()
|
|
||||||
continue
|
|
||||||
start = colon.end()
|
|
||||||
while start < len(body) and body[start].isspace():
|
|
||||||
start += 1
|
|
||||||
if start < len(body) and body[start] in "\"'":
|
|
||||||
start += 1
|
|
||||||
while boundary_index < len(boundaries) and boundaries[boundary_index].end(1) < start:
|
|
||||||
boundary_index += 1
|
|
||||||
if boundary_index == len(boundaries):
|
|
||||||
return
|
|
||||||
boundary = boundaries[boundary_index]
|
|
||||||
end = max(start, boundary.start())
|
|
||||||
if 0 <= newline < start:
|
|
||||||
newline = body.find("\n", start)
|
|
||||||
if newline >= 0 and newline < end:
|
|
||||||
pos = key.end()
|
|
||||||
continue
|
|
||||||
capture_end = end
|
|
||||||
if capture_end > start and body[capture_end - 1] in "\"'":
|
|
||||||
capture_end -= 1
|
|
||||||
yield key.group(0), body[start:capture_end].strip()
|
|
||||||
pos = end
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
||||||
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
|
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
|
||||||
tool_name = tool_name.strip().lower().replace("-", "_")
|
tool_name = tool_name.strip().lower().replace("-", "_")
|
||||||
@@ -1578,7 +1447,12 @@ def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
|||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
params = {}
|
params = {}
|
||||||
except Exception:
|
except Exception:
|
||||||
params = dict(_iter_gemma_fallback_items(body))
|
# Simple regex key-value extraction fallback
|
||||||
|
params = {}
|
||||||
|
for m in re.finditer(r'(\w+)\s*:\s*["\']?(.*?)["\']?(?=\s*,\s*\w+\s*:|\s*\})', body):
|
||||||
|
k = m.group(1)
|
||||||
|
v = m.group(2).strip()
|
||||||
|
params[k] = v
|
||||||
|
|
||||||
from src.tool_schemas import function_call_to_tool_block
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
return function_call_to_tool_block(tool_name, json.dumps(params))
|
return function_call_to_tool_block(tool_name, json.dumps(params))
|
||||||
@@ -1632,9 +1506,9 @@ def _iter_delimited(text, open_re, close_re):
|
|||||||
pos = cm.end()
|
pos = cm.end()
|
||||||
|
|
||||||
|
|
||||||
def _strip_delimited(text: str, open_re, close_re, replacement: str = "") -> str:
|
def _strip_delimited(text: str, open_re, close_re) -> str:
|
||||||
"""Replace every ``open_re ... close_re`` span (forward-only; see
|
"""Remove every ``open_re ... close_re`` span (forward-only; see
|
||||||
_iter_delimited). Equivalent to ``open_re([\\s\\S]*?)close_re`` substitution
|
_iter_delimited). Equivalent to ``open_re([\\s\\S]*?)close_re`` ``re.sub('')``
|
||||||
for these delimiters, without the O(n^2) rescan on unclosed openers."""
|
for these delimiters, without the O(n^2) rescan on unclosed openers."""
|
||||||
spans = list(_iter_delimited(text, open_re, close_re))
|
spans = list(_iter_delimited(text, open_re, close_re))
|
||||||
if not spans:
|
if not spans:
|
||||||
@@ -1643,23 +1517,11 @@ def _strip_delimited(text: str, open_re, close_re, replacement: str = "") -> str
|
|||||||
last = 0
|
last = 0
|
||||||
for match_start, _inner_start, _inner_end, match_end in spans:
|
for match_start, _inner_start, _inner_end, match_end in spans:
|
||||||
out.append(text[last:match_start])
|
out.append(text[last:match_start])
|
||||||
out.append(replacement)
|
|
||||||
last = match_end
|
last = match_end
|
||||||
out.append(text[last:])
|
out.append(text[last:])
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def strip_angle_tags(text: str, replacement: str = "", *, allow_empty: bool = False) -> str:
|
|
||||||
"""Linear equivalent of replacing ``<[^>]+>`` (or ``<[^>]*>``).
|
|
||||||
|
|
||||||
This preserves the existing flat text cleanup, including nested '<' and
|
|
||||||
malformed tails; it does not interpret HTML. Stop once no '>' is reachable
|
|
||||||
instead of retrying a suffix scan at every '<' in untrusted text.
|
|
||||||
"""
|
|
||||||
opener = _ANGLE_TAG_OPEN_RE if allow_empty else _NONEMPTY_ANGLE_TAG_OPEN_RE
|
|
||||||
return _strip_delimited(text, opener, _ANGLE_TAG_CLOSE_RE, replacement)
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_named_blocks(text, open_re, close_re):
|
def _iter_named_blocks(text, open_re, close_re):
|
||||||
"""Forward-only equivalent of ``open_re([\\s\\S]*?)close_re`` finditer where
|
"""Forward-only equivalent of ``open_re([\\s\\S]*?)close_re`` finditer where
|
||||||
open_re captures a name in group 1: yield ``(name, body)``, pairing each
|
open_re captures a name in group 1: yield ``(name, body)``, pairing each
|
||||||
@@ -2093,10 +1955,10 @@ def parse_tool_blocks(
|
|||||||
|
|
||||||
# Pattern 4b: Gemma-style <|tool_call|> blocks
|
# Pattern 4b: Gemma-style <|tool_call|> blocks
|
||||||
if not blocks:
|
if not blocks:
|
||||||
for tool_name, body in _iter_named_blocks(
|
for m in _GEMMA_TOOL_CALL_RE.finditer(text):
|
||||||
text, _GEMMA_TOOL_CALL_OPEN_RE, _GEMMA_TOOL_CALL_CLOSE_RE
|
tool_name = m.group(1)
|
||||||
):
|
body = m.group(2)
|
||||||
block = _parse_gemma_tool_call(tool_name, "{" + body + "}")
|
block = _parse_gemma_tool_call(tool_name, body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
|
|
||||||
@@ -2133,7 +1995,7 @@ def parse_tool_blocks(
|
|||||||
# from weaker native-tool models after reading the tool docs but failing to
|
# from weaker native-tool models after reading the tool docs but failing to
|
||||||
# emit the actual structured call.
|
# emit the actual structured call.
|
||||||
if not blocks:
|
if not blocks:
|
||||||
m = next(_iter_plain_ui_open_panel(text), None)
|
m = _PLAIN_UI_OPEN_PANEL_RE.search(text)
|
||||||
if m:
|
if m:
|
||||||
blocks.append(ToolBlock("ui_control", f"open_panel {m.group(1).lower()}{m.group(2).lower()}".strip()))
|
blocks.append(ToolBlock("ui_control", f"open_panel {m.group(1).lower()}{m.group(2).lower()}".strip()))
|
||||||
|
|
||||||
@@ -2176,7 +2038,7 @@ def strip_tool_blocks(
|
|||||||
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
|
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
|
||||||
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
|
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
|
||||||
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
|
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
|
||||||
cleaned = _strip_delimited(cleaned, _GEMMA_TOOL_CALL_OPEN_RE, _GEMMA_TOOL_CALL_CLOSE_RE)
|
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
|
||||||
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
|
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
|
||||||
cleaned = _strip_raw_openai_tool_call_json(cleaned)
|
cleaned = _strip_raw_openai_tool_call_json(cleaned)
|
||||||
declared_xml_calls = _parse_declared_direct_xml_calls(
|
declared_xml_calls = _parse_declared_direct_xml_calls(
|
||||||
@@ -2195,7 +2057,7 @@ def strip_tool_blocks(
|
|||||||
if raw_web_json:
|
if raw_web_json:
|
||||||
_, (start, end) = raw_web_json
|
_, (start, end) = raw_web_json
|
||||||
cleaned = cleaned[:start] + cleaned[end:]
|
cleaned = cleaned[:start] + cleaned[end:]
|
||||||
cleaned = _strip_plain_ui_open_panel(cleaned)
|
cleaned = _PLAIN_UI_OPEN_PANEL_RE.sub("", cleaned)
|
||||||
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
||||||
cleaned = _strip_bare_invoke_markup(cleaned)
|
cleaned = _strip_bare_invoke_markup(cleaned)
|
||||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
||||||
|
|||||||
+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":
|
||||||
|
|||||||
+2
-13
@@ -127,6 +127,7 @@ PLAN_MODE_READONLY_TOOLS = {
|
|||||||
"resolve_contact",
|
"resolve_contact",
|
||||||
"chat_with_model",
|
"chat_with_model",
|
||||||
"ask_teacher",
|
"ask_teacher",
|
||||||
|
"get_editor_screenshot",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ _PLAN_MODE_KNOWN_MUTATORS = {
|
|||||||
"download_model", "serve_model",
|
"download_model", "serve_model",
|
||||||
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
||||||
"generate_image", "edit_image", "trigger_research", "manage_research",
|
"generate_image", "edit_image", "trigger_research", "manage_research",
|
||||||
|
"plan_edit", "run_editor_command", "ai_edit_image",
|
||||||
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
||||||
# mode even if the schema list fails to load.
|
# mode even if the schema list fails to load.
|
||||||
"bash", "python",
|
"bash", "python",
|
||||||
@@ -274,16 +276,3 @@ def blocked_tools_for_owner(owner: Optional[str]) -> Set[str]:
|
|||||||
if owner_is_admin_or_single_user(owner):
|
if owner_is_admin_or_single_user(owner):
|
||||||
return set()
|
return set()
|
||||||
return set(NON_ADMIN_BLOCKED_TOOLS)
|
return set(NON_ADMIN_BLOCKED_TOOLS)
|
||||||
|
|
||||||
|
|
||||||
def delegated_credential_blocked_tools() -> Set[str]:
|
|
||||||
"""Tools an agent run driven by a bearer API token must not reach.
|
|
||||||
|
|
||||||
Deliberately not owner-dependent. ``blocked_tools_for_owner`` asks whether
|
|
||||||
the OWNER is an admin, and for a token that question is always answered
|
|
||||||
yes: minting a token is an admin-only action, so the empty set comes back
|
|
||||||
for every token in existence. A token is a long-lived credential the owner
|
|
||||||
hands to a third party, so it is capped at the non-admin policy no matter
|
|
||||||
who minted it.
|
|
||||||
"""
|
|
||||||
return set(NON_ADMIN_BLOCKED_TOOLS)
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+146
-269
@@ -17,7 +17,6 @@ from typing import Iterable, Mapping
|
|||||||
|
|
||||||
from src.action_intents import classify_tool_intent
|
from src.action_intents import classify_tool_intent
|
||||||
from src.tool_policy import ToolPolicy
|
from src.tool_policy import ToolPolicy
|
||||||
from src.text_scanning import has_prefixed_token_match
|
|
||||||
|
|
||||||
|
|
||||||
FAMILY_TOOLS = {
|
FAMILY_TOOLS = {
|
||||||
@@ -37,6 +36,7 @@ FAMILY_TOOLS = {
|
|||||||
"sessions": frozenset({"list_sessions", "manage_session", "create_session", "send_to_session", "chat_with_model", "pipeline"}),
|
"sessions": frozenset({"list_sessions", "manage_session", "create_session", "send_to_session", "chat_with_model", "pipeline"}),
|
||||||
"image_generation": frozenset({"generate_image"}),
|
"image_generation": frozenset({"generate_image"}),
|
||||||
"image_editing": frozenset({"edit_image"}),
|
"image_editing": frozenset({"edit_image"}),
|
||||||
|
"photo_editor": frozenset({"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image", "generate_image"}),
|
||||||
"transcription": frozenset({"transcribe_media"}),
|
"transcription": frozenset({"transcribe_media"}),
|
||||||
"media_inspection": frozenset({"inspect_media"}),
|
"media_inspection": frozenset({"inspect_media"}),
|
||||||
"ocr": frozenset({"extract_text"}),
|
"ocr": frozenset({"extract_text"}),
|
||||||
@@ -48,62 +48,6 @@ FAMILY_TOOLS = {
|
|||||||
CONTRACT_CORE_TOOLS = frozenset({
|
CONTRACT_CORE_TOOLS = frozenset({
|
||||||
"bash", "python", "read_file", "web_search", "web_fetch", "ask_user",
|
"bash", "python", "read_file", "web_search", "web_fetch", "ask_user",
|
||||||
})
|
})
|
||||||
|
|
||||||
_WORKSPACE_PREFIX_RE = re.compile(r"/workspace/", re.I)
|
|
||||||
_WORKSPACE_ARTIFACT_RE = re.compile(
|
|
||||||
r"/workspace/[^\s`\"']+\.(?:csv|html?|json|md|svg|txt)\b", re.I
|
|
||||||
)
|
|
||||||
_WORKSPACE_OUTPUT_PREFIX_RE = re.compile(r"/workspace/(?!input/)", re.I)
|
|
||||||
_WORKSPACE_OUTPUT_RE = re.compile(
|
|
||||||
r"/workspace/(?!input/)[^\s`\"']+\."
|
|
||||||
r"(?:csv|html?|json|md|svg|txt|avif|bmp|gif|jpe?g|png|webp|pdf|mp4|webm)\b",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_WORKSPACE_TOKEN_TAIL_RE = re.compile(r"[^\s`\"']*")
|
|
||||||
|
|
||||||
|
|
||||||
def _mentions_workspace_artifact(text: str) -> bool:
|
|
||||||
return has_prefixed_token_match(
|
|
||||||
str(text or ""),
|
|
||||||
_WORKSPACE_PREFIX_RE,
|
|
||||||
_WORKSPACE_ARTIFACT_RE,
|
|
||||||
_WORKSPACE_TOKEN_TAIL_RE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _mentions_workspace_output(text: str) -> bool:
|
|
||||||
return has_prefixed_token_match(
|
|
||||||
str(text or ""),
|
|
||||||
_WORKSPACE_OUTPUT_PREFIX_RE,
|
|
||||||
_WORKSPACE_OUTPUT_RE,
|
|
||||||
_WORKSPACE_TOKEN_TAIL_RE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _mentions_under_budget(text: str) -> bool:
|
|
||||||
value = str(text or "")
|
|
||||||
for under in re.finditer(r"\bunder", value, re.I):
|
|
||||||
cursor = under.end()
|
|
||||||
if cursor >= len(value) or not value[cursor].isspace():
|
|
||||||
continue
|
|
||||||
while cursor < len(value) and value[cursor].isspace():
|
|
||||||
cursor += 1
|
|
||||||
if cursor < len(value) and value[cursor] in "¥$€£":
|
|
||||||
cursor += 1
|
|
||||||
while cursor < len(value) and value[cursor].isspace():
|
|
||||||
cursor += 1
|
|
||||||
digit_start = cursor
|
|
||||||
while cursor < len(value) and value[cursor].isdecimal():
|
|
||||||
cursor += 1
|
|
||||||
if cursor == digit_start:
|
|
||||||
continue
|
|
||||||
if cursor == len(value) or not (value[cursor].isalnum() or value[cursor] == "_"):
|
|
||||||
return True
|
|
||||||
if value[cursor:cursor + 3].casefold() == "yen":
|
|
||||||
cursor += 3
|
|
||||||
if cursor == len(value) or not (value[cursor].isalnum() or value[cursor] == "_"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
_FAMILY_WORDS = {
|
_FAMILY_WORDS = {
|
||||||
"calendar": r"\b(?:calendar|calender|events?|appointments?|meetings?|agenda)\b",
|
"calendar": r"\b(?:calendar|calender|events?|appointments?|meetings?|agenda)\b",
|
||||||
"notes": r"\b(?:notes?|checklists?|groceries|remind\s+me)\b",
|
"notes": r"\b(?:notes?|checklists?|groceries|remind\s+me)\b",
|
||||||
@@ -179,7 +123,13 @@ def _normalize_request_lead(value: str) -> str:
|
|||||||
text,
|
text,
|
||||||
flags=re.I,
|
flags=re.I,
|
||||||
)
|
)
|
||||||
text = _strip_cancelled_request_lead(text)
|
text = re.sub(
|
||||||
|
r"^(?:never\s*mind|scratch\s+that)\s*[,;:—–-]?\s*"
|
||||||
|
r"(?=(?:open|show|list|read|search|find|check|switch|go)\b)",
|
||||||
|
"",
|
||||||
|
text,
|
||||||
|
flags=re.I,
|
||||||
|
)
|
||||||
text = re.sub(r"^k(?:ay)?\s*[,!]?\s+(?=\S)", "", text, flags=re.I)
|
text = re.sub(r"^k(?:ay)?\s*[,!]?\s+(?=\S)", "", text, flags=re.I)
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"^(?:(?:great|nice|cool)\s*[,!.]|thanks?\s*[.!])\s+"
|
r"^(?:(?:great|nice|cool)\s*[,!.]|thanks?\s*[.!])\s+"
|
||||||
@@ -214,21 +164,6 @@ def _normalize_request_lead(value: str) -> str:
|
|||||||
text = re.sub(r"^((?:can|could|would|will)\s+)u\b", r"\1you", text, flags=re.I)
|
text = re.sub(r"^((?:can|could|would|will)\s+)u\b", r"\1you", text, flags=re.I)
|
||||||
text = re.sub(r"\boffical\b", "official", text, flags=re.I)
|
text = re.sub(r"\boffical\b", "official", text, flags=re.I)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _strip_cancelled_request_lead(text: str) -> str:
|
|
||||||
lead = re.match(r"^(?:never\s*mind|scratch\s+that)", text, re.I)
|
|
||||||
if lead is None:
|
|
||||||
return text
|
|
||||||
cursor = lead.end()
|
|
||||||
while cursor < len(text) and text[cursor].isspace():
|
|
||||||
cursor += 1
|
|
||||||
if cursor < len(text) and text[cursor] in ",;:—–-":
|
|
||||||
cursor += 1
|
|
||||||
while cursor < len(text) and text[cursor].isspace():
|
|
||||||
cursor += 1
|
|
||||||
action = re.match(r"(?:open|show|list|read|search|find|check|switch|go)\b", text[cursor:], re.I)
|
|
||||||
return text[cursor:] if action is not None else text
|
|
||||||
_MISSPELLED_RESEARCH_ACTION = re.compile(
|
_MISSPELLED_RESEARCH_ACTION = re.compile(
|
||||||
r"^\s*" + _REQUEST_PREFIX + r"(?:reserch|reasearch|reseach)\b",
|
r"^\s*" + _REQUEST_PREFIX + r"(?:reserch|reasearch|reseach)\b",
|
||||||
re.I,
|
re.I,
|
||||||
@@ -261,21 +196,6 @@ _PURE_ACTION_PROHIBITION = re.compile(
|
|||||||
r"[^.;\n]*[.!?]*\s*$",
|
r"[^.;\n]*[.!?]*\s*$",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_pure_action_prohibition(text: str) -> bool:
|
|
||||||
value = str(text or "").strip()
|
|
||||||
prefix = re.match(
|
|
||||||
r"(?:read[- ]only(?:\s+and)?\s+)?(?:do\s+not|don['’]?t|never)\s+"
|
|
||||||
r"(?:add|create|make|write|draft|edit|change|update|delete|remove|send|reply|"
|
|
||||||
r"run|execute|download|serve|open|save|schedule|transcribe|inspect)\b",
|
|
||||||
value,
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
if prefix is None:
|
|
||||||
return False
|
|
||||||
tail = value[prefix.end():].rstrip(".!?")
|
|
||||||
return not any(char in ".;\n" for char in tail)
|
|
||||||
_RETURN_TO_ACTION = re.compile(r"^\s*" + _REQUEST_PREFIX + r"return\s+to\b", re.I)
|
_RETURN_TO_ACTION = re.compile(r"^\s*" + _REQUEST_PREFIX + r"return\s+to\b", re.I)
|
||||||
_PANEL_NAVIGATION = re.compile(
|
_PANEL_NAVIGATION = re.compile(
|
||||||
r"^\s*" + _REQUEST_PREFIX
|
r"^\s*" + _REQUEST_PREFIX
|
||||||
@@ -487,7 +407,8 @@ _BOUND_EDITOR_WRITE = re.compile(
|
|||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
_BOUND_EDITOR_IMPLICIT_REVISION = re.compile(
|
_BOUND_EDITOR_IMPLICIT_REVISION = re.compile(
|
||||||
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|"
|
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|go\s+more\s+into|"
|
||||||
|
r"g[aå]\s+mer\s+(?:in|ut)\s+p[aå]|"
|
||||||
r"clean\s+(?:this|it|the\s+(?:text|draft|document|doc))\s+up|"
|
r"clean\s+(?:this|it|the\s+(?:text|draft|document|doc))\s+up|"
|
||||||
r"give\s+(?:me\s+)?(?:feedback|a\s+critique|suggestions?|comments?)|"
|
r"give\s+(?:me\s+)?(?:feedback|a\s+critique|suggestions?|comments?)|"
|
||||||
r"remove\b[^.;\n]{0,100}\b(?:mistakes?|errors?|inaccurac(?:y|ies)|misinformation)|"
|
r"remove\b[^.;\n]{0,100}\b(?:mistakes?|errors?|inaccurac(?:y|ies)|misinformation)|"
|
||||||
@@ -528,111 +449,11 @@ _WARM_RECALL_WITH_FOLLOWUP = re.compile(
|
|||||||
r"(?P<followup>(?:what(?:['’]?s|\s+is)?|which|who|where|when|how|show|open|read|list|find|search)\b[\s\S]{0,180})$",
|
r"(?P<followup>(?:what(?:['’]?s|\s+is)?|which|who|where|when|how|show|open|read|list|find|search)\b[\s\S]{0,180})$",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
|
||||||
_WARM_TARGET_RE = re.compile(
|
|
||||||
r"calendar|emails?|inbox|notes?|tasks?|skills?|memories|memory|"
|
|
||||||
r"documents?|docs?|web|browser|cookbook|files?|shell",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
_WARM_FOLLOWUP_RE = re.compile(
|
|
||||||
r"(?:what(?:['’]?s|\s+is)?|which|who|where|when|how|show|open|read|list|find|search)"
|
|
||||||
r"\b[\s\S]{0,180}\Z",
|
|
||||||
re.I,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_space(value: str, cursor: int, *, required: bool = False) -> int | None:
|
|
||||||
start = cursor
|
|
||||||
while cursor < len(value) and value[cursor].isspace():
|
|
||||||
cursor += 1
|
|
||||||
return None if required and cursor == start else cursor
|
|
||||||
|
|
||||||
|
|
||||||
def _phrase_end(value: str, cursor: int, phrase: str) -> int | None:
|
|
||||||
for index, word in enumerate(phrase.split(" ")):
|
|
||||||
if value[cursor:cursor + len(word)].casefold() != word:
|
|
||||||
return None
|
|
||||||
cursor += len(word)
|
|
||||||
if index + 1 < len(phrase.split(" ")):
|
|
||||||
cursor = _consume_space(value, cursor, required=True)
|
|
||||||
if cursor is None:
|
|
||||||
return None
|
|
||||||
return cursor
|
|
||||||
|
|
||||||
|
|
||||||
def _warm_recall_parts(value: str, *, with_followup: bool = False) -> tuple[str, str] | None:
|
|
||||||
value = str(value or "")
|
|
||||||
cursor = _consume_space(value, 0) or 0
|
|
||||||
states = [cursor]
|
|
||||||
discourse = re.match(r"(?:ok(?:ay)?|and|then)", value[cursor:], re.I)
|
|
||||||
if discourse is not None:
|
|
||||||
after = _consume_space(value, cursor + discourse.end(), required=True)
|
|
||||||
if after is not None:
|
|
||||||
states.insert(0, after)
|
|
||||||
|
|
||||||
action_phrases = ("back to", "return to", "what about", "check", "show", "open")
|
|
||||||
action_states: list[int] = []
|
|
||||||
for state in states:
|
|
||||||
if not with_followup:
|
|
||||||
action_states.append(state)
|
|
||||||
for phrase in action_phrases:
|
|
||||||
end = _phrase_end(value, state, phrase)
|
|
||||||
if end is None:
|
|
||||||
continue
|
|
||||||
if with_followup:
|
|
||||||
end = _consume_space(value, end, required=True)
|
|
||||||
if end is None:
|
|
||||||
continue
|
|
||||||
action_states.append(end)
|
|
||||||
|
|
||||||
for state in dict.fromkeys(action_states):
|
|
||||||
state = _consume_space(value, state) or 0
|
|
||||||
possessive_states = [state]
|
|
||||||
for possessive in ("my", "the"):
|
|
||||||
if value[state:state + len(possessive)].casefold() == possessive:
|
|
||||||
possessive_states.insert(0, state + len(possessive))
|
|
||||||
for target_state in possessive_states:
|
|
||||||
target_state = _consume_space(value, target_state) or 0
|
|
||||||
target = _WARM_TARGET_RE.match(value, target_state)
|
|
||||||
if target is None:
|
|
||||||
continue
|
|
||||||
target_text = target.group(0)
|
|
||||||
suffix = target.end()
|
|
||||||
if with_followup:
|
|
||||||
if suffix < len(value) and (value[suffix].isalnum() or value[suffix] == "_"):
|
|
||||||
continue
|
|
||||||
separator_states = []
|
|
||||||
spaced = _consume_space(value, suffix) or 0
|
|
||||||
if spaced > suffix:
|
|
||||||
separator_states.append(spaced)
|
|
||||||
if spaced < len(value) and value[spaced] in "-—,:;":
|
|
||||||
separator_states.append(_consume_space(value, spaced + 1) or 0)
|
|
||||||
for conjunction in ("and", "then"):
|
|
||||||
end = spaced + len(conjunction)
|
|
||||||
if (value[spaced:end].casefold() == conjunction
|
|
||||||
and (end == len(value) or not (value[end].isalnum() or value[end] == "_"))):
|
|
||||||
separator_states.append(_consume_space(value, end) or 0)
|
|
||||||
for followup_start in dict.fromkeys(separator_states):
|
|
||||||
followup = _WARM_FOLLOWUP_RE.match(value, followup_start)
|
|
||||||
if followup is not None:
|
|
||||||
return target_text, followup.group(0)
|
|
||||||
continue
|
|
||||||
suffix = _consume_space(value, suffix) or 0
|
|
||||||
suffix_states = [suffix]
|
|
||||||
for word in ("again", "now"):
|
|
||||||
if value[suffix:suffix + len(word)].casefold() == word:
|
|
||||||
suffix_states.insert(0, suffix + len(word))
|
|
||||||
for end in suffix_states:
|
|
||||||
while end < len(value) and value[end] in ".!?":
|
|
||||||
end += 1
|
|
||||||
end = _consume_space(value, end) or 0
|
|
||||||
if end == len(value):
|
|
||||||
return target_text, ""
|
|
||||||
return None
|
|
||||||
_REQUIRED_TOOLS = {
|
_REQUIRED_TOOLS = {
|
||||||
"calendar": "manage_calendar", "notes": "manage_notes",
|
"calendar": "manage_calendar", "notes": "manage_notes",
|
||||||
"tasks": "manage_tasks", "skills": "manage_skills",
|
"tasks": "manage_tasks", "skills": "manage_skills",
|
||||||
"image_generation": "generate_image", "image_editing": "edit_image",
|
"image_generation": "generate_image", "image_editing": "edit_image",
|
||||||
|
"photo_editor": "plan_edit",
|
||||||
"transcription": "transcribe_media", "media_inspection": "inspect_media", "ocr": "extract_text",
|
"transcription": "transcribe_media", "media_inspection": "inspect_media", "ocr": "extract_text",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,6 +716,60 @@ def scheduled_automation_request(message: str) -> bool:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
_TASK_OBJECT = (
|
||||||
|
r'(?:tasks?|to-?dos?|to-?do\s+lists?|task\s+lists?|reminders?|automations?|'
|
||||||
|
r'(?:schedul\w*|recurring|scheduled)\s+jobs?)'
|
||||||
|
)
|
||||||
|
_TASK_MANAGEMENT_VERB = (
|
||||||
|
r'(?:add|create|make|set\s+up|schedule|new|start|track|turn|convert|save|store|'
|
||||||
|
r'delete|remove|complete|finish|close|pause|resume|restart|re-?run|run|edit|'
|
||||||
|
r'update|rename|archive|cancel|list|show|view|see|check|manage|assign|mark|'
|
||||||
|
r'look|peek|pull\s+up|open|find|keep)'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def explicit_task_management(message: str) -> bool:
|
||||||
|
"""True only when the user explicitly asks to manage background tasks.
|
||||||
|
|
||||||
|
A bare project noun ("30-day task + protocol doc") describes work to do, not
|
||||||
|
a request to create or read a scheduled task, so it must not select the
|
||||||
|
tasks family or offer manage_tasks.
|
||||||
|
"""
|
||||||
|
text = _normalize_request_lead(message)
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if scheduled_automation_request(text):
|
||||||
|
return True
|
||||||
|
# Possessive / near-noun references: "my tasks", "our reminders", "my jobs".
|
||||||
|
if re.search(
|
||||||
|
r'\b(?:my|our|the|any|all)\b[^.!?\n]{0,24}?\b' + _TASK_OBJECT + r'\b',
|
||||||
|
text, re.I,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
# An explicit management verb near a task noun: "add a task", "delete my todo".
|
||||||
|
if re.search(
|
||||||
|
r'\b' + _TASK_MANAGEMENT_VERB + r'\b[^.!?\n]{0,40}?\b' + _TASK_OBJECT + r'\b',
|
||||||
|
text, re.I,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
# A task noun with an interrogative/state tail: "tasks do I have", "tasks titled".
|
||||||
|
if re.search(
|
||||||
|
r'\b' + _TASK_OBJECT + r'\b[^.!?\n]{0,32}?'
|
||||||
|
r'\b(?:do\s+i\s+have|have\s+i|are\s+(?:set\s+up|scheduled|active)|'
|
||||||
|
r'titled|named|called|set\s+up|scheduled|active|due|overdue)\b',
|
||||||
|
text, re.I,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
# A task noun tied to a list/tracker surface.
|
||||||
|
if re.search(
|
||||||
|
r'\b' + _TASK_OBJECT + r'\b[^.!?\n]{0,24}?\b'
|
||||||
|
r'(?:list|tracker|manager|dashboard|schedule|inbox|library|board)\b',
|
||||||
|
text, re.I,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def creation_container_tool(message: str) -> str | None:
|
def creation_container_tool(message: str) -> str | None:
|
||||||
"""The explicitly created container owns its content, not vice versa."""
|
"""The explicitly created container owns its content, not vice versa."""
|
||||||
text = _normalize_request_lead(message)
|
text = _normalize_request_lead(message)
|
||||||
@@ -1033,7 +908,11 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
|
|||||||
tools = {"inspect_media"}
|
tools = {"inspect_media"}
|
||||||
if (
|
if (
|
||||||
re.search(r"\b(?:create|write|save|build|produce)\b", raw_text, re.I)
|
re.search(r"\b(?:create|write|save|build|produce)\b", raw_text, re.I)
|
||||||
and _mentions_workspace_artifact(raw_text)
|
and re.search(
|
||||||
|
r"(?:file://)?/workspace/[^\s`\"']+\.(?:csv|html?|json|md|svg|txt)\b",
|
||||||
|
raw_text,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
tools.update({"write_file", "read_file"})
|
tools.update({"write_file", "read_file"})
|
||||||
if re.search(r"\b(?:preview|render|open)\b[^.\n]{0,100}\b(?:page|html|browser)\b", raw_text, re.I):
|
if re.search(r"\b(?:preview|render|open)\b[^.\n]{0,100}\b(?:page|html|browser)\b", raw_text, re.I):
|
||||||
@@ -2296,34 +2175,6 @@ _READ_PRESENTATION_SUFFIX = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _terminal_clause_match(text: str, core_pattern: str) -> re.Match[str] | None:
|
|
||||||
"""Match a terminal clause after removing its ambiguous punctuation tail."""
|
|
||||||
end = len(text)
|
|
||||||
while end and text[end - 1].isspace():
|
|
||||||
end -= 1
|
|
||||||
while end and text[end - 1] in ".!?":
|
|
||||||
end -= 1
|
|
||||||
possessive = core_pattern.replace(r"\s+", r"\s++").replace(r"\s*", r"\s*+")
|
|
||||||
# An optional leading " and" branch must start at a whitespace boundary;
|
|
||||||
# otherwise search retries the whole possessive run at every suffix.
|
|
||||||
possessive = possessive.replace(r"|\s++and", r"|(?<!\s)\s++and")
|
|
||||||
return re.search(possessive + r"$", text[:end], re.I)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_terminal_but(text: str) -> str:
|
|
||||||
end = len(text)
|
|
||||||
while end and text[end - 1].isspace():
|
|
||||||
end -= 1
|
|
||||||
if end < 3 or text[end - 3:end].casefold() != "but":
|
|
||||||
return text
|
|
||||||
start = end - 3
|
|
||||||
if start == 0 or not text[start - 1].isspace():
|
|
||||||
return text
|
|
||||||
while start and text[start - 1].isspace():
|
|
||||||
start -= 1
|
|
||||||
return text[:start]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||||
"""Strip only whole, known presentation/safety suffixes, never actions."""
|
"""Strip only whole, known presentation/safety suffixes, never actions."""
|
||||||
text = _normalize_request_lead(message)
|
text = _normalize_request_lead(message)
|
||||||
@@ -2373,11 +2224,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
if keep_few_suffix:
|
if keep_few_suffix:
|
||||||
maximum = 3
|
maximum = 3
|
||||||
text = text[:keep_few_suffix.start()].strip()
|
text = text[:keep_few_suffix.start()].strip()
|
||||||
few_suffix = _terminal_clause_match(
|
few_suffix = re.search(
|
||||||
text,
|
|
||||||
r"[,.;?]\s*(?:(?:only|just)\s+)?(?:(?:list|show)\s+(?:me\s+)?)?a\s+few"
|
r"[,.;?]\s*(?:(?:only|just)\s+)?(?:(?:list|show)\s+(?:me\s+)?)?a\s+few"
|
||||||
r"(?:\s+(?:task\s+)?(?:names?|items?|results?|entries?))?"
|
r"(?:\s+(?:task\s+)?(?:names?|items?|results?|entries?))?"
|
||||||
r"(?:\s+and\s+(?:whether|if)\s+[^.;\n]+)?",
|
r"(?:\s+and\s+(?:whether|if)\s+[^.;\n]+)?[.!?]*\s*$",
|
||||||
|
text, re.I,
|
||||||
)
|
)
|
||||||
if few_suffix:
|
if few_suffix:
|
||||||
maximum = 3
|
maximum = 3
|
||||||
@@ -2389,43 +2240,42 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
text,
|
text,
|
||||||
flags=re.I,
|
flags=re.I,
|
||||||
).strip()
|
).strip()
|
||||||
terminal_read_only = _terminal_clause_match(
|
text = re.sub(
|
||||||
text,
|
|
||||||
r"[.;]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
r"[.;]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
||||||
r"(?:(?:and\s+)?(?:do\s+not|don['’]?t|dont)\s+"
|
r"(?:(?:and\s+)?(?:do\s+not|don['’]?t|dont)\s+"
|
||||||
r"(?:change|edit|modify)(?:\s+or\s+send)?\s+(?:anything|data))?",
|
r"(?:change|edit|modify)(?:\s+or\s+send)?\s+(?:anything|data))?"
|
||||||
)
|
r"[.!?]*\s*$",
|
||||||
if terminal_read_only:
|
"",
|
||||||
text = text[:terminal_read_only.start()].strip()
|
|
||||||
# Explanatory/safety tails do not alter a preceding exact read request.
|
|
||||||
safety_tail = _terminal_clause_match(
|
|
||||||
text,
|
text,
|
||||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?(?:do\s+not|don['’]?t|dont)\s+"
|
flags=re.I,
|
||||||
r"(?:touch|change|edit|modify)(?:\s+(?:anything|data|them))?(?:\s+yet)?",
|
).strip()
|
||||||
)
|
# Explanatory/safety tails do not alter a preceding exact read request.
|
||||||
if safety_tail:
|
|
||||||
text = text[:safety_tail.start()].strip()
|
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"[.!?]\s*+read[- ]only(?:\s++(?:please|pls|plz))?\s*+(?:,\s*+)?"
|
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?(?:do\s+not|don['’]?t|dont)\s+"
|
||||||
|
r"(?:touch|change|edit|modify)(?:\s+(?:anything|data|them))?(?:\s+yet)?[.!?]*\s*$",
|
||||||
|
"", text, flags=re.I,
|
||||||
|
).strip()
|
||||||
|
text = re.sub(
|
||||||
|
r"[.!?]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
||||||
r"(?:do\s+not|don['’]?t|dont)\s+(?:change|edit|modify)\s+"
|
r"(?:do\s+not|don['’]?t|dont)\s+(?:change|edit|modify)\s+"
|
||||||
r"(?:or\s+send\s+)?anything[.!?]*\s*$",
|
r"(?:or\s+send\s+)?anything[.!?]*\s*$",
|
||||||
"", text, flags=re.I,
|
"", text, flags=re.I,
|
||||||
).strip()
|
).strip()
|
||||||
for terminal_core in (
|
text = re.sub(
|
||||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+changes?",
|
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+changes?[.!?]*\s*$",
|
||||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+edits?",
|
"", text, flags=re.I,
|
||||||
):
|
).strip()
|
||||||
terminal_match = _terminal_clause_match(text, terminal_core)
|
text = re.sub(
|
||||||
if terminal_match:
|
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+edits?[.!?]*\s*$",
|
||||||
text = text[:terminal_match.start()].strip()
|
"", text, flags=re.I,
|
||||||
|
).strip()
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"[,;]\s*no\s+edits?[.!?]*\s*$", "", text, flags=re.I,
|
r"[,;]\s*no\s+edits?[.!?]*\s*$", "", text, flags=re.I,
|
||||||
).strip()
|
).strip()
|
||||||
terminal_no_write = _terminal_clause_match(
|
text = re.sub(
|
||||||
text, r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+writes?"
|
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+writes?[.!?]*\s*$",
|
||||||
)
|
"", text, flags=re.I,
|
||||||
if terminal_no_write:
|
).strip()
|
||||||
text = text[:terminal_no_write.start()].strip()
|
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"[.!?]\s*(?:i['’]?m|i\s+am)\s+(?:just\s+)?checking\b[^\n]*$",
|
r"[.!?]\s*(?:i['’]?m|i\s+am)\s+(?:just\s+)?checking\b[^\n]*$",
|
||||||
"", text, flags=re.I,
|
"", text, flags=re.I,
|
||||||
@@ -2443,11 +2293,12 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
text,
|
text,
|
||||||
flags=re.I,
|
flags=re.I,
|
||||||
).strip()
|
).strip()
|
||||||
short_lines = _terminal_clause_match(
|
text = re.sub(
|
||||||
text, r"[,;]\s*(?:keep\s+(?:them|it)\s+)?short\s+lines?\s*,?"
|
r"[,;]\s*(?:keep\s+(?:them|it)\s+)?short\s+lines?\s*,?[.!?]*\s*$",
|
||||||
)
|
"",
|
||||||
if short_lines:
|
text,
|
||||||
text = text[:short_lines.start()].strip()
|
flags=re.I,
|
||||||
|
).strip()
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"[,.;]\s*keep\s+(?:the\s+answer|it|them)\s+short[.!?]*\s*$",
|
r"[,.;]\s*keep\s+(?:the\s+answer|it|them)\s+short[.!?]*\s*$",
|
||||||
"",
|
"",
|
||||||
@@ -2472,11 +2323,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||||
text = text[:approximate_limit.start()].strip()
|
text = text[:approximate_limit.start()].strip()
|
||||||
natural_limit = re.search(
|
natural_limit = re.search(
|
||||||
r"(?:[,.;?]|[—–-]|(?<!\s)\s++but\s++|(?<!\s)\s++)\s*+(?:"
|
r"(?:[,.;?]|[—–-]|\s+but\s+|\s+)\s*(?:"
|
||||||
r"(?:i\s++)?only\s++(?:need|want|show(?:\s++me)?)?\s*+(?:(?:the\s++)?first\s++)?"
|
r"(?:i\s+)?only\s+(?:need|want|show(?:\s+me)?)?\s*(?:(?:the\s+)?first\s+)?"
|
||||||
r"|just\s++(?:(?:the\s++)?first\s++)?|(?:show\s++me\s++)?like\s++|no\s++more\s++than\s++"
|
r"|just\s+(?:(?:the\s+)?first\s+)?|(?:show\s+me\s+)?like\s+|no\s+more\s+than\s+"
|
||||||
r"|cap(?:\s++(?:it|them|the\s++(?:answer|list)))?\s++at\s++"
|
r"|cap(?:\s+(?:it|them|the\s+(?:answer|list)))?\s+at\s+"
|
||||||
r"|(?:maybe\s++)?(?:first|same)\s++)"
|
r"|(?:maybe\s+)?(?:first|same)\s+)"
|
||||||
r"(" + _READ_COUNT + r")"
|
r"(" + _READ_COUNT + r")"
|
||||||
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?|bits?|things?))?"
|
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?|bits?|things?))?"
|
||||||
r"(?:\s*(?:and|\+)\s+(?:their\s+)?(?:status(?:es)?|states?))?"
|
r"(?:\s*(?:and|\+)\s+(?:their\s+)?(?:status(?:es)?|states?))?"
|
||||||
@@ -2514,7 +2365,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||||
text = text[:compact_limit.start()].strip()
|
text = text[:compact_limit.start()].strip()
|
||||||
conversational_limit = re.search(
|
conversational_limit = re.search(
|
||||||
r"(?:[,.;?]\s*|(?<!\s)\s++)(?:maybe\s+)?(?:keep\s+it\s+to\s+|stick\s+to\s+|(?:first|top)\s+)"
|
r"(?:[,.;?]\s*|\s+)(?:maybe\s+)?(?:keep\s+it\s+to\s+|stick\s+to\s+|(?:first|top)\s+)"
|
||||||
r"(" + _READ_COUNT + r")(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
r"(" + _READ_COUNT + r")(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
||||||
r"[.!?]*\s*$",
|
r"[.!?]*\s*$",
|
||||||
text, re.I,
|
text, re.I,
|
||||||
@@ -2524,7 +2375,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
value = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
value = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||||
maximum = value if maximum is None else min(maximum, value)
|
maximum = value if maximum is None else min(maximum, value)
|
||||||
text = text[:conversational_limit.start()].rstrip(' ,.;?')
|
text = text[:conversational_limit.start()].rstrip(' ,.;?')
|
||||||
text = _strip_terminal_but(text)
|
text = re.sub(r"\s+but\s*$", "", text, flags=re.I)
|
||||||
need_limit = re.search(
|
need_limit = re.search(
|
||||||
r"[.!?]\s*(?:i\s+)?only\s+need\s+(" + _READ_COUNT + r")"
|
r"[.!?]\s*(?:i\s+)?only\s+need\s+(" + _READ_COUNT + r")"
|
||||||
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
||||||
@@ -4149,13 +4000,21 @@ def _families_for_tool(tool: str) -> frozenset[str]:
|
|||||||
if bare in {"list_sessions", "manage_session", "create_session", "send_to_session",
|
if bare in {"list_sessions", "manage_session", "create_session", "send_to_session",
|
||||||
"chat_with_model", "pipeline"}:
|
"chat_with_model", "pipeline"}:
|
||||||
return frozenset({"sessions"})
|
return frozenset({"sessions"})
|
||||||
return frozenset(family for family, tools in FAMILY_TOOLS.items() if bare in tools)
|
# The photo_editor family is context-only: it is only ever selected by an
|
||||||
|
# explicit editor request at the route, never inferred from message text.
|
||||||
|
# Overlapping tools such as generate_image must still resolve to their real
|
||||||
|
# product family, so exclude photo_editor rather than short-circuiting.
|
||||||
|
return frozenset(
|
||||||
|
family
|
||||||
|
for family, tools in FAMILY_TOOLS.items()
|
||||||
|
if bare in tools and family != "photo_editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clause_capabilities(text: str) -> set[str]:
|
def _clause_capabilities(text: str) -> set[str]:
|
||||||
# A prohibition constrains authority; it must never grant the family named
|
# A prohibition constrains authority; it must never grant the family named
|
||||||
# only as the forbidden side effect (for example, "do not create a file").
|
# only as the forbidden side effect (for example, "do not create a file").
|
||||||
if _is_pure_action_prohibition(text):
|
if _PURE_ACTION_PROHIBITION.fullmatch(text):
|
||||||
return set()
|
return set()
|
||||||
container_tool = creation_container_tool(text)
|
container_tool = creation_container_tool(text)
|
||||||
if container_tool:
|
if container_tool:
|
||||||
@@ -4299,6 +4158,11 @@ def _clause_capabilities(text: str) -> set[str]:
|
|||||||
if intent.reason == "terse calendar follow-up action":
|
if intent.reason == "terse calendar follow-up action":
|
||||||
return set()
|
return set()
|
||||||
words = {f for f, pattern in _FAMILY_WORDS.items() if re.search(pattern, text, re.I)}
|
words = {f for f, pattern in _FAMILY_WORDS.items() if re.search(pattern, text, re.I)}
|
||||||
|
if "tasks" in words and not explicit_task_management(text):
|
||||||
|
# A bare mention of "task"/"todo"/"automation" in a project description
|
||||||
|
# is content, not a request to create or read a scheduled task. Only an
|
||||||
|
# explicit task-management request may select the tasks family.
|
||||||
|
words.discard("tasks")
|
||||||
personal_stores = words & {
|
personal_stores = words & {
|
||||||
"calendar", "notes", "tasks", "skills", "memory", "documents", "email",
|
"calendar", "notes", "tasks", "skills", "memory", "documents", "email",
|
||||||
}
|
}
|
||||||
@@ -4406,6 +4270,14 @@ def _clause_capabilities(text: str) -> set[str]:
|
|||||||
mapped = "tasks"
|
mapped = "tasks"
|
||||||
if mapped in FAMILY_TOOLS:
|
if mapped in FAMILY_TOOLS:
|
||||||
return {mapped}
|
return {mapped}
|
||||||
|
if "shell_files" in words and re.search(
|
||||||
|
r"\b(?:bash|shell|terminal|zsh|command\s+line|cmd)\b", text, re.I,
|
||||||
|
):
|
||||||
|
# An explicitly named shell is a request to use the shell even when the
|
||||||
|
# rest of the message is a bare or typo'd command ("Bash commabd Ls la").
|
||||||
|
# Grant the shell family so the always-offered bash tool is not denied
|
||||||
|
# by request authority (offer/admission alignment).
|
||||||
|
return {"shell_files"}
|
||||||
# Families absent from action_intents still need a generic action gate;
|
# Families absent from action_intents still need a generic action gate;
|
||||||
# merely discussing a domain must not offer its mutation tools.
|
# merely discussing a domain must not offer its mutation tools.
|
||||||
return words if _has_action_signal(text) or personal_lookup else set()
|
return words if _has_action_signal(text) or personal_lookup else set()
|
||||||
@@ -4816,7 +4688,12 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
|||||||
raw_text,
|
raw_text,
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
and _mentions_workspace_output(raw_text)
|
and re.search(
|
||||||
|
r"(?:file://)?/workspace/(?!input/)[^\s`\"']+\."
|
||||||
|
r"(?:csv|html?|json|md|svg|txt|avif|bmp|gif|jpe?g|png|webp|pdf|mp4|webm)\b",
|
||||||
|
raw_text,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
families.add("shell_files")
|
families.add("shell_files")
|
||||||
if re.search(
|
if re.search(
|
||||||
@@ -5275,7 +5152,7 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
|||||||
and re.search(r"\b(?:ones?|top|apps?|services?|providers?)\b", text, re.I)
|
and re.search(r"\b(?:ones?|top|apps?|services?|providers?)\b", text, re.I)
|
||||||
and re.search(r"\b(?:price|cheap|under|dimensions?|quote|trustworthy|app)\b", text, re.I)
|
and re.search(r"\b(?:price|cheap|under|dimensions?|quote|trustworthy|app)\b", text, re.I)
|
||||||
)
|
)
|
||||||
or _mentions_under_budget(text)
|
or re.search(r"\bunder\s+[¥$€£]?\s*\d+(?:[.,]\d+)?(?:\s*yen)?\b", text, re.I)
|
||||||
):
|
):
|
||||||
return frozenset({"search_browser"})
|
return frozenset({"search_browser"})
|
||||||
if recent_family == ("search_browser",) and re.fullmatch(
|
if recent_family == ("search_browser",) and re.fullmatch(
|
||||||
@@ -6212,8 +6089,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
|||||||
elif (recent == ("search_browser",) and families == {"cookbook_admin"}
|
elif (recent == ("search_browser",) and families == {"cookbook_admin"}
|
||||||
and re.match(r"^\s*" + _REQUEST_PREFIX + r"(?:search|find)\s+(?:those|them|these)\b", text, re.I)):
|
and re.match(r"^\s*" + _REQUEST_PREFIX + r"(?:search|find)\s+(?:those|them|these)\b", text, re.I)):
|
||||||
families = {"search_browser"}
|
families = {"search_browser"}
|
||||||
if not families and (recall := _warm_recall_parts(text)):
|
if not families and (recall := _WARM_RECALL.fullmatch(text)):
|
||||||
target = recall[0].lower()
|
target = recall["target"].lower()
|
||||||
family = {
|
family = {
|
||||||
"email": "email", "emails": "email", "inbox": "email",
|
"email": "email", "emails": "email", "inbox": "email",
|
||||||
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
||||||
@@ -6225,8 +6102,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
|||||||
}[target]
|
}[target]
|
||||||
if family in recently_executed_families(history):
|
if family in recently_executed_families(history):
|
||||||
families.add(family)
|
families.add(family)
|
||||||
if not families and (recall := _warm_recall_parts(text, with_followup=True)):
|
if not families and (recall := _WARM_RECALL_WITH_FOLLOWUP.fullmatch(text)):
|
||||||
target = recall[0].lower()
|
target = recall["target"].lower()
|
||||||
family = {
|
family = {
|
||||||
"email": "email", "emails": "email", "inbox": "email",
|
"email": "email", "emails": "email", "inbox": "email",
|
||||||
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
||||||
@@ -6325,7 +6202,7 @@ def resolve_full_inventory_contract(*, schemas: Iterable[dict], policy: ToolPoli
|
|||||||
"""Experimental trained inventory: permissions filter offers; model chooses actions."""
|
"""Experimental trained inventory: permissions filter offers; model chooses actions."""
|
||||||
families = frozenset({"calendar", "notes", "tasks", "skills", "memory", "documents",
|
families = frozenset({"calendar", "notes", "tasks", "skills", "memory", "documents",
|
||||||
"email", "search_browser", "shell_files", "cookbook_admin",
|
"email", "search_browser", "shell_files", "cookbook_admin",
|
||||||
"image_editing", "image_generation"})
|
"image_editing", "image_generation", "photo_editor"})
|
||||||
# ``ui_control`` is the executable bridge for explicit client-interface
|
# ``ui_control`` is the executable bridge for explicit client-interface
|
||||||
# requests (for example, opening the gallery). It is not one of the ten
|
# requests (for example, opening the gallery). It is not one of the ten
|
||||||
# persisted-data families, but omitting it here makes the full-inventory
|
# persisted-data families, but omitting it here makes the full-inventory
|
||||||
|
|||||||
+2
-55
@@ -12,7 +12,7 @@ break the primary use case. What it *always* rejects:
|
|||||||
- a non-HTTP(S) scheme (``file://``, ``gopher://``, ``ftp://`` …), and
|
- a non-HTTP(S) scheme (``file://``, ``gopher://``, ``ftp://`` …), and
|
||||||
- the link-local range (``169.254.0.0/16`` / ``fe80::/10``), i.e. the cloud
|
- the link-local range (``169.254.0.0/16`` / ``fe80::/10``), i.e. the cloud
|
||||||
instance-metadata SSRF credential-exfil vector — nobody serves embeddings
|
instance-metadata SSRF credential-exfil vector — nobody serves embeddings
|
||||||
there, plus multicast / non-loopback reserved / unspecified addresses.
|
there — plus multicast / reserved / unspecified addresses.
|
||||||
|
|
||||||
For exposed multi-tenant deployments, set ``EMBEDDING_BLOCK_PRIVATE_IPS=true`` to
|
For exposed multi-tenant deployments, set ``EMBEDDING_BLOCK_PRIVATE_IPS=true`` to
|
||||||
additionally reject all private and loopback targets (full SSRF lockdown).
|
additionally reject all private and loopback targets (full SSRF lockdown).
|
||||||
@@ -94,15 +94,11 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st
|
|||||||
return None
|
return None
|
||||||
if ip.is_link_local:
|
if ip.is_link_local:
|
||||||
return f"link-local address blocked (SSRF metadata risk): {ip}"
|
return f"link-local address blocked (SSRF metadata risk): {ip}"
|
||||||
# IPv6 loopback is also reserved; its policy must match IPv4 loopback.
|
|
||||||
if ip.is_loopback:
|
|
||||||
if block_private:
|
|
||||||
return f"private/shared/loopback address blocked: {ip}"
|
|
||||||
return None
|
|
||||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||||
return f"disallowed address: {ip}"
|
return f"disallowed address: {ip}"
|
||||||
if block_private and (
|
if block_private and (
|
||||||
ip.is_private
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
or (isinstance(ip, ipaddress.IPv4Address) and ip in _SHARED_ADDRESS_SPACE_V4)
|
or (isinstance(ip, ipaddress.IPv4Address) and ip in _SHARED_ADDRESS_SPACE_V4)
|
||||||
):
|
):
|
||||||
return f"private/shared/loopback address blocked: {ip}"
|
return f"private/shared/loopback address blocked: {ip}"
|
||||||
@@ -158,52 +154,3 @@ def check_outbound_url(
|
|||||||
if not saw_ip:
|
if not saw_ip:
|
||||||
return False, "host does not resolve to an IP"
|
return False, "host does not resolve to an IP"
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class OutboundAddressBlocked(PermissionError):
|
|
||||||
"""A non-HTTP outbound host resolves into a disallowed address range."""
|
|
||||||
|
|
||||||
|
|
||||||
def connect_outbound_tcp(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
*,
|
|
||||||
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
|
|
||||||
block_private: bool = False,
|
|
||||||
source_address=None,
|
|
||||||
resolver: Optional[Callable[..., list]] = None,
|
|
||||||
) -> socket.socket:
|
|
||||||
"""Open a TCP connection to *host* under the outbound address policy.
|
|
||||||
|
|
||||||
For raw TCP clients (IMAP/SMTP). The host is resolved exactly once, every
|
|
||||||
resolved address is judged with the same policy as check_outbound_url, and
|
|
||||||
the socket connects only to those already-judged addresses. A second
|
|
||||||
lookup at connect time (what socket.create_connection(host) does) would let
|
|
||||||
a rebinding name pass the check with a public answer and then connect to
|
|
||||||
metadata/private space. TLS callers keep wrapping the returned socket with
|
|
||||||
``server_hostname=host``, so SNI and certificate checks are unchanged.
|
|
||||||
"""
|
|
||||||
resolve = resolver or socket.getaddrinfo
|
|
||||||
infos = resolve(host, port, 0, socket.SOCK_STREAM)
|
|
||||||
for _family, _type, _proto, _canon, sockaddr in infos:
|
|
||||||
ip = ipaddress.ip_address(str(sockaddr[0]).split("%")[0])
|
|
||||||
reason = _classify(ip, block_private=block_private)
|
|
||||||
if reason:
|
|
||||||
raise OutboundAddressBlocked(reason)
|
|
||||||
last_error: Optional[OSError] = None
|
|
||||||
for family, socktype, proto, _canon, sockaddr in infos:
|
|
||||||
sock = socket.socket(family, socktype, proto)
|
|
||||||
try:
|
|
||||||
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: # same contract as create_connection
|
|
||||||
sock.settimeout(timeout)
|
|
||||||
if source_address:
|
|
||||||
sock.bind(source_address)
|
|
||||||
sock.connect(sockaddr)
|
|
||||||
return sock
|
|
||||||
except OSError as exc:
|
|
||||||
last_error = exc
|
|
||||||
sock.close()
|
|
||||||
if last_error is not None:
|
|
||||||
raise last_error
|
|
||||||
raise socket.gaierror(socket.EAI_NONAME, f"{host} did not resolve to an address")
|
|
||||||
|
|||||||
@@ -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 == []
|
||||||
|
|||||||
@@ -1873,8 +1873,7 @@ def test_tui_local_skill_request_reconciles_normal_tool_policy(monkeypatch):
|
|||||||
},
|
},
|
||||||
)))
|
)))
|
||||||
|
|
||||||
# Caller hard denials are absolute and cannot be re-enabled by later TUI turn contracts.
|
assert calls == ["manage_skills"]
|
||||||
assert calls == []
|
|
||||||
assert not any(e.get("type") == "loop_breaker_triggered" for e in events)
|
assert not any(e.get("type") == "loop_breaker_triggered" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
||||||
@@ -49,9 +49,8 @@ def test_scheduler_fallbacks_and_research_headers_are_owner_scoped():
|
|||||||
assert "owner=task.owner or None" in src
|
assert "owner=task.owner or None" in src
|
||||||
assert "headers_from_resolver = False" in src
|
assert "headers_from_resolver = False" in src
|
||||||
assert "headers_from_resolver = True" in src
|
assert "headers_from_resolver = True" in src
|
||||||
assert "resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)" in src
|
assert "from src.auth_helpers import owner_filter" in src
|
||||||
assert "resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)" in src
|
assert "owner_filter(ep_q, ModelEndpoint, task.owner or None)" in src
|
||||||
assert src.count("resolve_endpoint_runtime(ep, owner=task.owner or None)") == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_research_routes_fallbacks_are_owner_scoped():
|
def test_research_routes_fallbacks_are_owner_scoped():
|
||||||
|
|||||||
@@ -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.'}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ async def _create_with_reminder(reminder, owner):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("reminder,expected", [
|
@pytest.mark.parametrize("reminder,expected", [
|
||||||
|
(0, 0), # At-event reminder must not compare equal to False.
|
||||||
|
(0.0, 0),
|
||||||
|
("0", 0),
|
||||||
("5 mins", 5),
|
("5 mins", 5),
|
||||||
("10 mins", 10),
|
("10 mins", 10),
|
||||||
("2 hrs", 120),
|
("2 hrs", 120),
|
||||||
@@ -91,6 +94,15 @@ async def test_no_reminder_when_offset_absent():
|
|||||||
assert "reminder set" not in res.get("response", ""), res
|
assert "reminder set" not in res.get("response", ""), res
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reminder", [False, "off", "false", None])
|
||||||
|
async def test_disabled_reminder_is_distinct_from_zero(reminder):
|
||||||
|
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||||
|
res = await _create_with_reminder(reminder, owner)
|
||||||
|
assert res.get("exit_code") == 0, res
|
||||||
|
assert not res.get("has_reminder"), res
|
||||||
|
assert res.get("reminder_note_id") is None, res
|
||||||
|
|
||||||
|
|
||||||
async def test_update_event_can_add_reminder_after_creation():
|
async def test_update_event_can_add_reminder_after_creation():
|
||||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||||
from src.tool_implementations import do_manage_calendar
|
from src.tool_implementations import do_manage_calendar
|
||||||
@@ -123,37 +135,3 @@ async def test_update_event_can_add_reminder_after_creation():
|
|||||||
assert note is not None, "update_event should create the reminder note"
|
assert note is not None, "update_event should create the reminder note"
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_reminder_and_duration_parsers_stay_linear_on_digit_and_space_floods():
|
|
||||||
"""CodeQL py/polynomial-redos: `(\\d+)\\s*unit` rescanned a digit run from
|
|
||||||
every offset and `alarm\\s*:?\\s*\\d+` split one whitespace run two ways.
|
|
||||||
Tool arguments come from model output, so keep them O(n)."""
|
|
||||||
import time
|
|
||||||
from src.tool_implementations import do_manage_calendar
|
|
||||||
|
|
||||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
|
||||||
started = time.perf_counter()
|
|
||||||
res = await do_manage_calendar(json.dumps({
|
|
||||||
"action": "create_event",
|
|
||||||
"summary": "Flood",
|
|
||||||
"dtstart": "2030-04-01T10:00:00",
|
|
||||||
"reminder_minutes": "0" * 40_000 + "x",
|
|
||||||
"duration": "0" * 40_000 + "x",
|
|
||||||
}), owner=owner)
|
|
||||||
assert time.perf_counter() - started < 2.0
|
|
||||||
assert res.get("exit_code") == 0, res
|
|
||||||
assert "reminder set" not in res.get("response", ""), res
|
|
||||||
|
|
||||||
# A reminder is set, so the "is the description only a reminder?" check runs.
|
|
||||||
started = time.perf_counter()
|
|
||||||
res = await do_manage_calendar(json.dumps({
|
|
||||||
"action": "create_event",
|
|
||||||
"summary": "Flood 2",
|
|
||||||
"dtstart": "2030-04-02T10:00:00",
|
|
||||||
"description": "alarm" + "\t" * 40_000,
|
|
||||||
"reminder_minutes": 5,
|
|
||||||
}), owner=owner)
|
|
||||||
assert time.perf_counter() - started < 2.0
|
|
||||||
assert res.get("exit_code") == 0, res
|
|
||||||
assert "reminder 5 min before" in res.get("response", ""), res
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -624,8 +624,7 @@ def test_bridge_post_targets_endpoint_paths():
|
|||||||
posted = {}
|
posted = {}
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, timeout=None, *, trust_env=True):
|
def __init__(self, timeout=None):
|
||||||
assert trust_env is False
|
|
||||||
posted["timeout"] = timeout
|
posted["timeout"] = timeout
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
@@ -696,8 +695,8 @@ def test_bridge_post_treats_empty_or_error_payload_as_failure():
|
|||||||
payloads = iter(({}, {"error": "token rejected"}))
|
payloads = iter(({}, {"error": "token rejected"}))
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, timeout=None, *, trust_env=True):
|
def __init__(self, timeout=None):
|
||||||
assert trust_env is False
|
pass
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -735,8 +734,8 @@ def test_bridge_post_http_or_payload_error_overrides_false_success_code():
|
|||||||
))
|
))
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, timeout=None, *, trust_env=True):
|
def __init__(self, timeout=None):
|
||||||
assert trust_env is False
|
pass
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -770,8 +769,8 @@ def test_bridge_post_rejects_malformed_exit_code():
|
|||||||
return {"output": "looks successful", "exit_code": "zero"}
|
return {"output": "looks successful", "exit_code": "zero"}
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, timeout=None, *, trust_env=True):
|
def __init__(self, timeout=None):
|
||||||
assert trust_env is False
|
pass
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -805,8 +804,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
|
|||||||
raise ValueError("not JSON")
|
raise ValueError("not JSON")
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, timeout=None, *, trust_env=True):
|
def __init__(self, timeout=None):
|
||||||
assert trust_env is False
|
pass
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -834,8 +833,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
|
|||||||
def test_bridge_post_cancellation_schedules_host_operation_cancel():
|
def test_bridge_post_cancellation_schedules_host_operation_cancel():
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, timeout=None, *, trust_env=True):
|
def __init__(self, timeout=None):
|
||||||
assert trust_env is False
|
pass
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -61,20 +61,11 @@ def test_grep_ignore_case(repo):
|
|||||||
assert "b.txt:2:" in r["output"]
|
assert "b.txt:2:" in r["output"]
|
||||||
|
|
||||||
|
|
||||||
def test_grep_glob_filter(repo, monkeypatch):
|
def test_grep_glob_filter(repo):
|
||||||
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
||||||
assert r["exit_code"] == 0
|
|
||||||
assert "a.py" in r["output"]
|
assert "a.py" in r["output"]
|
||||||
assert "b.txt" not in r["output"]
|
assert "b.txt" not in r["output"]
|
||||||
|
|
||||||
# Positive control: verify that fallback path honors the exact same glob contract
|
|
||||||
real_which = shutil.which
|
|
||||||
monkeypatch.setattr(shutil, "which", lambda name, *a, **kw: None if name == "rg" else real_which(name, *a, **kw))
|
|
||||||
r_fallback = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
|
||||||
assert r_fallback["exit_code"] == 0
|
|
||||||
assert "a.py" in r_fallback["output"]
|
|
||||||
assert "b.txt" not in r_fallback["output"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grep_no_match(repo):
|
def test_grep_no_match(repo):
|
||||||
r = _run("grep", f'{{"pattern": "zzzznotfound", "path": "{repo}"}}')
|
r = _run("grep", f'{{"pattern": "zzzznotfound", "path": "{repo}"}}')
|
||||||
@@ -103,17 +94,6 @@ def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
|
|||||||
assert ".git/config" not in r["output"]
|
assert ".git/config" not in r["output"]
|
||||||
|
|
||||||
|
|
||||||
def test_grep_python_fallback_uses_relative_glob_paths(repo, monkeypatch):
|
|
||||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
||||||
r = _run(
|
|
||||||
"grep",
|
|
||||||
f'{{"pattern": "needle|python", "glob": "**/*.py", "path": "{repo}"}}',
|
|
||||||
)
|
|
||||||
assert r["exit_code"] == 0
|
|
||||||
assert "a.py" in r["output"]
|
|
||||||
assert "sub/deep/c.py" in r["output"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
|
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
|
||||||
def test_grep_skips_case_variant_sensitive_files_rg(repo):
|
def test_grep_skips_case_variant_sensitive_files_rg(repo):
|
||||||
"""The rg fast-path must exclude deny-listed key files case-insensitively.
|
"""The rg fast-path must exclude deny-listed key files case-insensitively.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -179,16 +179,8 @@ def test_cancelled_write_unsettles_an_earlier_success(run, ws, monkeypatch):
|
|||||||
|
|
||||||
def test_mid_write_failure_unsettles_but_refusal_preserves(run, ws, monkeypatch):
|
def test_mid_write_failure_unsettles_but_refusal_preserves(run, ws, monkeypatch):
|
||||||
run("write_file", {"path": "a.txt", "content": "hello\n"})
|
run("write_file", {"path": "a.txt", "content": "hello\n"})
|
||||||
# Explicit empty JSON clear/write is authoritative and succeeds.
|
|
||||||
run("write_file", {"path": "a.txt", "content": ""})
|
|
||||||
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.REPORTED_SUCCESS
|
|
||||||
|
|
||||||
# Re-seed the artifact content
|
|
||||||
run("write_file", {"path": "a.txt", "content": "hello\n"})
|
|
||||||
assert ledger(run.journal, ws).evaluate().can_complete
|
|
||||||
|
|
||||||
# A deterministic refusal before the mutation stage keeps the artifact.
|
# A deterministic refusal before the mutation stage keeps the artifact.
|
||||||
run("write_file", "a.txt\n")
|
run("write_file", {"path": "a.txt", "content": ""})
|
||||||
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.FAILED
|
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.FAILED
|
||||||
assert ledger(run.journal, ws).evaluate().can_complete
|
assert ledger(run.journal, ws).evaluate().can_complete
|
||||||
|
|
||||||
|
|||||||
@@ -167,56 +167,10 @@ def test_renders_one_table_row_per_variable(generator, fake_tree, monkeypatch):
|
|||||||
page = generator.render(found, generator.naive_line_scan(fake_tree))
|
page = generator.render(found, generator.naive_line_scan(fake_tree))
|
||||||
|
|
||||||
assert page.startswith("---\nlayout: default\n---\n")
|
assert page.startswith("---\nlayout: default\n---\n")
|
||||||
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py` | A synthetic limit. |" in page
|
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py:16` | A synthetic limit. |" in page
|
||||||
assert "### Search" in page
|
assert "### Search" in page
|
||||||
|
|
||||||
|
|
||||||
def _render_one(generator, root, name, monkeypatch):
|
|
||||||
monkeypatch.setitem(
|
|
||||||
generator.VARIABLE_NOTES, name, ("Search", generator.USER, "A synthetic flag.")
|
|
||||||
)
|
|
||||||
return generator.render(
|
|
||||||
{name: generator.collect(root)[name]}, generator.naive_line_scan(root)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_page_does_not_change_when_an_unrelated_edit_shifts_lines(
|
|
||||||
generator, fake_tree, monkeypatch
|
|
||||||
):
|
|
||||||
"""An edit that moves a read down a file must not make the page stale.
|
|
||||||
|
|
||||||
The page used to cite `path:line`, so any PR adding a line above a read had
|
|
||||||
to regenerate it and then conflicted with every other PR that had.
|
|
||||||
"""
|
|
||||||
before = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
|
|
||||||
source = fake_tree / "src" / "indirect.py"
|
|
||||||
source.write_text("import sys\n\n\n" + source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
||||||
|
|
||||||
after = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
|
|
||||||
|
|
||||||
assert generator.collect(fake_tree)["ODYSSEUS_VIA_HELPER"].primary.lineno == 19
|
|
||||||
assert after == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_extra_reads_are_counted_per_file(generator, tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setattr(generator, "SOURCE_ROOTS", ("app.py", "src"))
|
|
||||||
_write(tmp_path, "app.py", """
|
|
||||||
import os
|
|
||||||
|
|
||||||
FIRST = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
|
|
||||||
SECOND = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
|
|
||||||
""")
|
|
||||||
_write(tmp_path, "src/elsewhere.py", """
|
|
||||||
import os
|
|
||||||
|
|
||||||
AGAIN = os.getenv("ODYSSEUS_READ_OFTEN")
|
|
||||||
""")
|
|
||||||
|
|
||||||
page = _render_one(generator, tmp_path, "ODYSSEUS_READ_OFTEN", monkeypatch)
|
|
||||||
|
|
||||||
assert "| `app.py` (+1 more) |" in page
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_variable_read_in_the_repository_is_documented(built):
|
def test_every_variable_read_in_the_repository_is_documented(built):
|
||||||
_, variables, problems = built
|
_, variables, problems = built
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import pytest
|
|
||||||
import logging
|
import logging
|
||||||
import src.tool_execution as tool_execution
|
import src.tool_execution as tool_execution
|
||||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
@@ -17,60 +15,6 @@ from src.tool_execution import (
|
|||||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("content", ['{"path":"/workspace/x"}', '{"path":"/workspace/x","content":null}',
|
|
||||||
'/workspace/x', '/workspace/x\n', '/workspace/x\n \t',
|
|
||||||
json.dumps({'path': '/workspace/x.py', 'content': '```python\n\n```'})])
|
|
||||||
def test_bridge_refuses_missing_or_ambiguous_empty_content(monkeypatch, tmp_path, content):
|
|
||||||
async def forbidden(*args, **kwargs):
|
|
||||||
raise AssertionError("ambiguous write reached remote producer")
|
|
||||||
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
|
|
||||||
monkeypatch.setattr(tool_execution, "_bridge_post", forbidden)
|
|
||||||
async def invoke():
|
|
||||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
|
||||||
try:
|
|
||||||
return await tool_execution._route_tool_via_bridge("write_file", content, "s", {})
|
|
||||||
finally:
|
|
||||||
tool_execution._active_workspace.reset(token)
|
|
||||||
_, result = asyncio.run(invoke())
|
|
||||||
assert result["exit_code"] == 1
|
|
||||||
assert "content" in result["error"] or "JSON" in result["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("body", ["", " \t", "normal source"])
|
|
||||||
def test_bridge_honors_explicit_json_string_content(monkeypatch, tmp_path, body):
|
|
||||||
import base64
|
|
||||||
seen = []
|
|
||||||
async def post(bridge, endpoint, payload, **kwargs):
|
|
||||||
seen.append((endpoint, base64.b64decode(payload["content_b64"])))
|
|
||||||
return {"output": "written", "exit_code": 0}
|
|
||||||
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
|
|
||||||
monkeypatch.setattr(tool_execution, "_bridge_post", post)
|
|
||||||
async def invoke():
|
|
||||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
|
||||||
try:
|
|
||||||
return await tool_execution._route_tool_via_bridge("write_file", json.dumps({"path": "/workspace/x", "content": body}), "s", {})
|
|
||||||
finally:
|
|
||||||
tool_execution._active_workspace.reset(token)
|
|
||||||
_, result = asyncio.run(invoke())
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
assert seen == [("/write", body.encode())]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("content", ['/workspace/x\n', '{"path":"/workspace/x"}', '/workspace/x\n \t'])
|
|
||||||
def test_scoped_bridge_cannot_blindly_write_implicit_empty(content):
|
|
||||||
async def forbidden(*args):
|
|
||||||
raise AssertionError("scoped bridge received an ambiguous destructive write")
|
|
||||||
bridge = AgentExecutionBridge(forbidden, frozenset({'write_file'}), 'empty-intent-test')
|
|
||||||
async def invoke():
|
|
||||||
with bind_execution_bridge(bridge):
|
|
||||||
block = Block(content)
|
|
||||||
block.tool_type = 'write_file'
|
|
||||||
return await execute_tool_block(block, security_context=NO_TOOL_SECURITY_CONTEXT)
|
|
||||||
_, result = asyncio.run(invoke())
|
|
||||||
assert result['exit_code'] == 1
|
|
||||||
assert 'content' in result['error']
|
|
||||||
|
|
||||||
|
|
||||||
class Block:
|
class Block:
|
||||||
tool_type = "host_shell"
|
tool_type = "host_shell"
|
||||||
|
|
||||||
@@ -215,7 +159,7 @@ def test_scoped_execution_bridge_failure_logs_compact_warning(caplog) -> None:
|
|||||||
assert "Scoped execution bridge test-environment failed for tool=host_shell" in caplog.text
|
assert "Scoped execution bridge test-environment failed for tool=host_shell" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch, tmp_path) -> None:
|
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch) -> None:
|
||||||
"""The bridge path must preserve rendered media just like local writes."""
|
"""The bridge path must preserve rendered media just like local writes."""
|
||||||
called = False
|
called = False
|
||||||
|
|
||||||
@@ -228,17 +172,12 @@ def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch, tmp
|
|||||||
monkeypatch.setattr(tool_execution, "_bridge_post", fake_post)
|
monkeypatch.setattr(tool_execution, "_bridge_post", fake_post)
|
||||||
|
|
||||||
async def invoke():
|
async def invoke():
|
||||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
return await tool_execution._route_tool_via_bridge(
|
||||||
try:
|
"write_file",
|
||||||
return await tool_execution._route_tool_via_bridge(
|
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
|
||||||
"write_file",
|
"run-1",
|
||||||
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
|
{"surface": "odysseus-tui"},
|
||||||
"run-1",
|
)
|
||||||
{"surface": "odysseus-tui"},
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
tool_execution._active_workspace.reset(token)
|
|
||||||
|
|
||||||
description, result = asyncio.run(invoke())
|
description, result = asyncio.run(invoke())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -984,7 +984,7 @@ def test_attachment_extract_dir_stays_contained(folder, uid):
|
|||||||
"""User-controlled folder/uid must never escape ATTACHMENTS_DIR — pins the
|
"""User-controlled folder/uid must never escape ATTACHMENTS_DIR — pins the
|
||||||
fix for the attachment-extraction path traversal."""
|
fix for the attachment-extraction path traversal."""
|
||||||
aed, base = _import_attachment_extract_dir()
|
aed, base = _import_attachment_extract_dir()
|
||||||
target = aed(folder, uid, owner="../../owner", account_id="../acct")
|
target = aed(folder, uid)
|
||||||
base_r = base.resolve()
|
base_r = base.resolve()
|
||||||
assert target == base_r or base_r in target.parents
|
assert target == base_r or base_r in target.parents
|
||||||
# exactly one extra path segment, and no `..` component survived
|
# exactly one extra path segment, and no `..` component survived
|
||||||
@@ -992,11 +992,9 @@ def test_attachment_extract_dir_stays_contained(folder, uid):
|
|||||||
assert ".." not in rel.parts
|
assert ".." not in rel.parts
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_extract_dir_is_stable_single_segment_per_scope():
|
def test_attachment_extract_dir_normal_inputs_unchanged():
|
||||||
aed, base = _import_attachment_extract_dir()
|
aed, base = _import_attachment_extract_dir()
|
||||||
target = aed("INBOX", "123", owner="alice", account_id="acct-1")
|
assert aed("INBOX", "123") == base.resolve() / "INBOX_123"
|
||||||
assert target == aed("INBOX", "123", owner="alice", account_id="acct-1")
|
|
||||||
assert target.parent == base.resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def test_diagnostics_routes_are_admin_gated():
|
def test_diagnostics_routes_are_admin_gated():
|
||||||
|
|||||||
@@ -6,21 +6,13 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
# Import the route helper during collection so sibling session tests that use
|
# Import the route helper during collection so sibling session tests that use
|
||||||
# partial import stubs do not become the first loader of core.session_manager.
|
# partial import stubs do not become the first loader of core.session_manager.
|
||||||
from routes.session_routes import (
|
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||||
_reject_delegated_session_options,
|
|
||||||
_reject_raw_endpoint_url_for_non_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _request(user, *, admin=False, api_token=False, scopes=None):
|
def _request(user, *, admin=False):
|
||||||
auth_manager = SimpleNamespace(is_admin=lambda username: bool(admin))
|
auth_manager = SimpleNamespace(is_admin=lambda username: bool(admin))
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
state=SimpleNamespace(
|
state=SimpleNamespace(current_user=user),
|
||||||
current_user="api" if api_token else user,
|
|
||||||
api_token=api_token,
|
|
||||||
api_token_owner=user if api_token else None,
|
|
||||||
api_token_scopes=scopes or [],
|
|
||||||
),
|
|
||||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,47 +44,6 @@ def test_admin_and_registered_endpoint_can_use_endpoint_url():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_bearer_token_does_not_inherit_owner_admin_raw_endpoint_authority():
|
|
||||||
request = _request("admin", admin=True, api_token=True, scopes=["chat"])
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
_reject_raw_endpoint_url_for_non_admin(
|
|
||||||
request,
|
|
||||||
"admin",
|
|
||||||
"",
|
|
||||||
"http://127.0.0.1:8000/v1/chat/completions",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert exc.value.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_chat_scoped_bearer_can_still_choose_an_owner_registered_endpoint():
|
|
||||||
_reject_raw_endpoint_url_for_non_admin(
|
|
||||||
_request("admin", admin=True, api_token=True, scopes=["chat"]),
|
|
||||||
"admin",
|
|
||||||
"owner-endpoint-id",
|
|
||||||
"http://127.0.0.1:8000/v1/chat/completions",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("skip_validation", "api_key"),
|
|
||||||
[(True, ""), (False, "caller-secret")],
|
|
||||||
)
|
|
||||||
def test_bearer_token_cannot_use_interactive_session_options(
|
|
||||||
skip_validation,
|
|
||||||
api_key,
|
|
||||||
):
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
_reject_delegated_session_options(
|
|
||||||
_request("admin", admin=True, api_token=True, scopes=["chat"]),
|
|
||||||
skip_validation=skip_validation,
|
|
||||||
api_key=api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert exc.value.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_chat_endpoint_recovery_paths_are_owner_scoped():
|
def test_chat_endpoint_recovery_paths_are_owner_scoped():
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
chat_routes = (root / "routes" / "chat_routes.py").read_text(encoding="utf-8")
|
chat_routes = (root / "routes" / "chat_routes.py").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -169,14 +169,12 @@ def test_blocks_netrc():
|
|||||||
_resolve_tool_path("~/.netrc")
|
_resolve_tool_path("~/.netrc")
|
||||||
|
|
||||||
|
|
||||||
def test_allows_agent_workspace(tmp_path):
|
def test_allows_project_data(tmp_path):
|
||||||
"""Paths under the agent's workspace in project data/ must resolve
|
"""Paths under project data/ must resolve cleanly."""
|
||||||
cleanly. The rest of data/ is application state and is rejected;
|
|
||||||
tests/test_agent_state_dir_confinement.py covers that side."""
|
|
||||||
from src.tool_execution import _resolve_tool_path
|
from src.tool_execution import _resolve_tool_path
|
||||||
from src.constants import AGENT_WORKSPACE_DIR
|
from src.constants import DATA_DIR
|
||||||
target = os.path.join(AGENT_WORKSPACE_DIR, "test-confinement-ok.txt")
|
target = os.path.join(DATA_DIR, "test-confinement-ok.txt")
|
||||||
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
with open(target, "w") as f:
|
with open(target, "w") as f:
|
||||||
f.write("ok")
|
f.write("ok")
|
||||||
try:
|
try:
|
||||||
@@ -315,8 +313,8 @@ async def test_write_file_dispatch_rejects_empty_directory_like_workspace_path(m
|
|||||||
workspace=str(tmp_path),
|
workspace=str(tmp_path),
|
||||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||||
)
|
)
|
||||||
assert "BLOCKED" in desc
|
assert desc == "write_file: /workspace/papers"
|
||||||
assert "content required; missing content section" in (
|
assert "content required; refusing to create an empty file" in (
|
||||||
result.get("error") or ""
|
result.get("error") or ""
|
||||||
)
|
)
|
||||||
assert result.get("exit_code") == 1
|
assert result.get("exit_code") == 1
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
+11
-35
@@ -3,8 +3,6 @@
|
|||||||
A stub resolver is injected so the tests never touch real DNS.
|
A stub resolver is injected so the tests never touch real DNS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
|
|
||||||
|
|
||||||
@@ -66,39 +64,6 @@ def test_strict_mode_blocks_private_and_loopback():
|
|||||||
assert ok is False and "private" in reason
|
assert ok is False and "private" in reason
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("block_private", [False, True])
|
|
||||||
@pytest.mark.parametrize("host,ips", [
|
|
||||||
("127.0.0.1", ["127.0.0.1"]),
|
|
||||||
("[::1]", ["::1"]),
|
|
||||||
("[::ffff:127.0.0.1]", ["::ffff:127.0.0.1"]),
|
|
||||||
("localhost", ["127.0.0.1", "::1"]),
|
|
||||||
("localhost", ["::1", "127.0.0.1"]),
|
|
||||||
])
|
|
||||||
def test_loopback_urls_follow_private_policy(host, ips, block_private):
|
|
||||||
ok, reason = check_outbound_url(
|
|
||||||
f"http://{host}:8080", block_private=block_private,
|
|
||||||
resolver=_resolver({host.strip("[]"): ips}),
|
|
||||||
)
|
|
||||||
assert ok is (not block_private), reason
|
|
||||||
if block_private:
|
|
||||||
assert "loopback address blocked" in reason
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("block_private", [False, True])
|
|
||||||
@pytest.mark.parametrize("ip", [
|
|
||||||
"169.254.169.254", "fe80::1", "0.0.0.0", "::", "224.0.0.1", "ff02::1",
|
|
||||||
"240.0.0.1", "::2", "100::1", "::ffff:169.254.169.254",
|
|
||||||
"::ffff:0.0.0.0", "::ffff:224.0.0.1", "::ffff:240.0.0.1",
|
|
||||||
])
|
|
||||||
def test_special_ranges_stay_blocked_alongside_loopback(ip, block_private):
|
|
||||||
ok, reason = check_outbound_url(
|
|
||||||
"http://localhost:8080", block_private=block_private,
|
|
||||||
resolver=_resolver({"localhost": [ip, "127.0.0.1", "::1"]}),
|
|
||||||
)
|
|
||||||
assert ok is False
|
|
||||||
assert "link-local" in reason or "disallowed address" in reason
|
|
||||||
|
|
||||||
|
|
||||||
def test_strict_mode_blocks_cgnat_shared_space():
|
def test_strict_mode_blocks_cgnat_shared_space():
|
||||||
# RFC 6598 shared/CGNAT space (100.64.0.0/10) is not globally routable.
|
# RFC 6598 shared/CGNAT space (100.64.0.0/10) is not globally routable.
|
||||||
# A public redirect into it must be rejected under full SSRF lockdown,
|
# A public redirect into it must be rejected under full SSRF lockdown,
|
||||||
@@ -282,3 +247,14 @@ def test_ordinary_ipv6_behaviour_is_unchanged():
|
|||||||
LL6 = _resolver({"ll.example": ["fe80::1"]})
|
LL6 = _resolver({"ll.example": ["fe80::1"]})
|
||||||
ok, reason = check_outbound_url("http://ll.example/", resolver=LL6)
|
ok, reason = check_outbound_url("http://ll.example/", resolver=LL6)
|
||||||
assert ok is False and "link-local" in reason
|
assert ok is False and "link-local" in reason
|
||||||
|
|
||||||
|
# Pre-existing behaviour, unchanged here: CPython reports ::1 as
|
||||||
|
# is_reserved, so IPv6 loopback is rejected in both modes (unlike 127.0.0.1,
|
||||||
|
# which the local-first default allows).
|
||||||
|
LOOP6 = _resolver({"loop6.example": ["::1"]})
|
||||||
|
for strict in (False, True):
|
||||||
|
ok, reason = check_outbound_url(
|
||||||
|
"http://loop6.example/", block_private=strict, resolver=LOOP6
|
||||||
|
)
|
||||||
|
assert ok is False, strict
|
||||||
|
assert "NAT64" not in reason
|
||||||
|
|||||||
Reference in New Issue
Block a user