mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 00:02:21 +01:00
Merge current dev and refresh PR #6738 from the 7011 integration
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""In-flight activity counters (heavy image ops) for safe restarts/observability.
|
||||
|
||||
A deploy restart kills any in-flight long image op (a diffusion edit is 60-120 s),
|
||||
so the operator needs to know whether the process is busy before issuing
|
||||
``systemctl --user restart``. These counters are process-local and cheap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict
|
||||
|
||||
_lock = threading.Lock()
|
||||
_image_ops = 0
|
||||
_image_ops_by_name: Dict[str, int] = {}
|
||||
_started_at = time.time()
|
||||
|
||||
|
||||
def begin_image_op(name: str = "image") -> None:
|
||||
global _image_ops
|
||||
with _lock:
|
||||
_image_ops += 1
|
||||
_image_ops_by_name[name] = _image_ops_by_name.get(name, 0) + 1
|
||||
|
||||
|
||||
def end_image_op(name: str = "image") -> None:
|
||||
global _image_ops
|
||||
with _lock:
|
||||
_image_ops = max(0, _image_ops - 1)
|
||||
if name in _image_ops_by_name:
|
||||
_image_ops_by_name[name] -= 1
|
||||
if _image_ops_by_name[name] <= 0:
|
||||
_image_ops_by_name.pop(name, None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def image_op(name: str = "image"):
|
||||
begin_image_op(name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
end_image_op(name)
|
||||
|
||||
|
||||
def activity() -> Dict[str, object]:
|
||||
with _lock:
|
||||
return {
|
||||
"image_ops": _image_ops,
|
||||
"image_ops_by_name": dict(_image_ops_by_name),
|
||||
"uptime_s": round(time.time() - _started_at, 1),
|
||||
}
|
||||
|
||||
|
||||
def heavy_image_ops_active() -> int:
|
||||
with _lock:
|
||||
return _image_ops
|
||||
|
||||
|
||||
def busy() -> bool:
|
||||
return heavy_image_ops_active() > 0
|
||||
@@ -54,7 +54,6 @@ def workspace_artifact_is_usable(path: Path) -> bool:
|
||||
class EvidenceKind(str, Enum):
|
||||
TOOL_RESULT = "tool_result"
|
||||
ARTIFACT_MUTATION = "artifact_mutation"
|
||||
DOCUMENT_MUTATION = "document_mutation"
|
||||
ARTIFACT_VALIDATION = "artifact_validation"
|
||||
VERIFIER_RESULT = "verifier_result"
|
||||
MEDIA_INGRESS = "media_ingress"
|
||||
@@ -132,20 +131,6 @@ class CompletionDecision:
|
||||
|
||||
|
||||
_ARTIFACT_PATH = r"(?:/|\./|\.\./)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12}"
|
||||
# Document-editor tools mutate a server-side document rather than a filesystem
|
||||
# path, so their successful results are recorded as a distinct evidence kind.
|
||||
_DOCUMENT_MUTATION_TOOLS = frozenset({"create_document", "update_document", "edit_document"})
|
||||
# Owned private-action writes (calendar, tasks, notes, memory, contacts) mutate
|
||||
# server-side state that carries no filesystem path, so a successful
|
||||
# authoritative result is their mutation evidence. Without this, a verified
|
||||
# calendar/task create is discarded as an "unsupported execution claim".
|
||||
_PRIVATE_WRITE_ACTIONS: Mapping[str, frozenset[str]] = {
|
||||
"manage_calendar": frozenset({"create_event", "update_event", "delete_event"}),
|
||||
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
|
||||
"manage_memory": frozenset({"add", "edit", "delete"}),
|
||||
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
|
||||
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
|
||||
}
|
||||
_ARTIFACT_REQUEST_RE = re.compile(
|
||||
rf"\b(?:writ(?:e|ten)|creat(?:e|ed)|make|made|sav(?:e|ed)|produc(?:e|ed)|"
|
||||
rf"generat(?:e|ed)|export(?:ed)?|edit(?:ed)?|modif(?:y|ied)|updat(?:e|ed)|"
|
||||
@@ -656,9 +641,6 @@ class EvidenceLedger:
|
||||
# order of actions so receipt evidence and effects share one ordering.
|
||||
self.effects: list[dict[str, Any]] = []
|
||||
self._action_order: dict[str, int] = {}
|
||||
# Successful authoritative owned-state writes (calendar/tasks/notes/
|
||||
# memory/contacts) that an unnamed mutation claim may cite.
|
||||
self._private_action_mutations: list[str] = []
|
||||
|
||||
def record_effects(self, entries: Iterable[Mapping[str, Any]], action_order: Mapping[str, int],
|
||||
partial_reads: Iterable[str] = ()) -> None:
|
||||
@@ -918,29 +900,6 @@ class EvidenceLedger:
|
||||
detail="post-write artifact inspection",
|
||||
)
|
||||
|
||||
if tool in _DOCUMENT_MUTATION_TOOLS:
|
||||
# A successful, authoritative document write is evidence of a
|
||||
# document mutation even though it has no filesystem path.
|
||||
self._append(
|
||||
kind=EvidenceKind.DOCUMENT_MUTATION,
|
||||
success=success,
|
||||
authoritative=authoritative,
|
||||
source=event,
|
||||
detail=tool,
|
||||
)
|
||||
|
||||
if tool in _PRIVATE_WRITE_ACTIONS:
|
||||
try:
|
||||
write_args = json.loads(command or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
write_args = None
|
||||
action = (
|
||||
str(write_args.get("action") or "").strip().lower()
|
||||
if isinstance(write_args, Mapping) else ""
|
||||
)
|
||||
if action in _PRIVATE_WRITE_ACTIONS[tool] and authoritative and success:
|
||||
self._private_action_mutations.append(f"{tool}:{action}")
|
||||
|
||||
if tool in {"bash", "host_shell"} and (command_is_test(_command_text(command)) or _matches_declared_verifier(
|
||||
command,
|
||||
self.requirements.verifier_commands,
|
||||
@@ -1012,22 +971,6 @@ class EvidenceLedger:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _supports_document_mutation(self) -> bool:
|
||||
"""A successful authoritative document write supports an unnamed
|
||||
document-mutation claim, which carries no filesystem path to match."""
|
||||
return any(event.kind == EvidenceKind.DOCUMENT_MUTATION
|
||||
and event.authoritative and event.success
|
||||
for event in self.events)
|
||||
|
||||
def _supports_mutation_claim(self, paths: Sequence[str]) -> bool:
|
||||
"""Match a mutation claim to a file edit, a document write, or a
|
||||
successful owned-state action when the claim names no file path."""
|
||||
if self._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
return True
|
||||
if paths:
|
||||
return False
|
||||
return self._supports_document_mutation() or bool(self._private_action_mutations)
|
||||
|
||||
def record_media_ingress(self, metadata: Mapping[str, Any]) -> None:
|
||||
for artifact in metadata.get("artifacts") or []:
|
||||
if not isinstance(artifact, Mapping):
|
||||
|
||||
+1276
-300
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ from dataclasses import dataclass, replace
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import uuid4
|
||||
@@ -140,11 +141,20 @@ class RequestAuthority:
|
||||
raise ValueError("Malformed request authority")
|
||||
if self.resource_roots is None:
|
||||
roots = ()
|
||||
if self.workspace:
|
||||
try:
|
||||
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass # An unresolved workspace grants no filesystem root.
|
||||
# With no workspace, bash/python run in the agent workspace
|
||||
# (agent_cwd), so the file tools get that same root (#6679).
|
||||
# Imported here: authority -> execution is a new edge, and
|
||||
# tool_execution imports this module at load time.
|
||||
from src import tool_execution
|
||||
try:
|
||||
root = self.workspace
|
||||
if not root:
|
||||
# Vetted like agent_cwd(): a symlinked agent workspace
|
||||
# raises and so grants no root.
|
||||
root = tool_execution.default_agent_workspace()
|
||||
roots = (FilesystemRoot.seal(root, owner=self.owner),)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass # An unresolved workspace grants no filesystem root.
|
||||
object.__setattr__(self, "resource_roots", roots)
|
||||
if (not isinstance(self.resource_roots, tuple)
|
||||
or any(not isinstance(r, FilesystemRoot) or (r.owner and r.owner != self.owner)
|
||||
@@ -316,12 +326,7 @@ class RequestAuthority:
|
||||
|
||||
|
||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||
"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"})
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -368,8 +373,7 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None,
|
||||
spec_surface=False):
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Authority requires trusted request text")
|
||||
@@ -378,25 +382,10 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
families = intent.capabilities
|
||||
if capabilities is not None:
|
||||
families |= frozenset(capabilities)
|
||||
if spec_surface:
|
||||
# Strict router (spec) surface: agent_loop builds the final tool set in
|
||||
# src/spec_router.py AFTER this runs, so turn_contract.offered is still
|
||||
# the compact inventory here. The router already limits schemas to its
|
||||
# picks + sticky prior tools + ask_user/update_plan, and
|
||||
# _resolve_tool_blocks drops calls to unoffered tools, so admit every
|
||||
# family and let the spec surface be the boundary. Selected-tool
|
||||
# narrowing is skipped for the same reason.
|
||||
families |= frozenset(FAMILY_TOOLS)
|
||||
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
|
||||
selected = None if spec_surface else intent.selected_tools
|
||||
selected = intent.selected_tools
|
||||
if selected is not None:
|
||||
tools = tools & set(selected) if families else set(selected)
|
||||
if "web_search" in tools:
|
||||
# Search selection narrows to the query tool, but reading a source
|
||||
# it surfaces is the same bounded read operation. Without the
|
||||
# sibling reader every discovered result is a dead end and the
|
||||
# turn fails closed on the first web_fetch of a found URL.
|
||||
tools.add("web_fetch")
|
||||
if tools & {"web_search", "web_fetch"}:
|
||||
tools.add("private_browser")
|
||||
operation = intent.required_read
|
||||
@@ -410,28 +399,13 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
actions = inputs = None
|
||||
if name == "private_browser":
|
||||
actions = _BROWSER_READ_ACTIONS
|
||||
if spec_surface:
|
||||
# The strict router itself offered a working browser: admit its
|
||||
# interaction classes too, not only when the verb is in the text.
|
||||
actions |= _BROWSER_INTERACTION_ACTIONS
|
||||
elif re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
|
||||
if re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||
if operation is not None and name == canonical_tool(operation.tool):
|
||||
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})
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input})
|
||||
grants.append(OperationGrant(name, actions, inputs))
|
||||
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||
str(workspace or ""), tuple(grants))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""One presentation gate between agent execution and externally visible prose.
|
||||
|
||||
Tool, progress and interaction events stay live. Evidence-safe complete
|
||||
sentences from ordinary answers may stream before the terminal check; execution
|
||||
answers remain gated so a later replacement cannot conceal an earlier false
|
||||
Tool, progress and interaction events stay live. Answer deltas are held until
|
||||
the generator unwinds so a later replacement cannot conceal an earlier false
|
||||
claim. This consumes no provider calls. Cancellation closes the inner generator
|
||||
under the same journal/turn authority; it never emits a successful terminal event.
|
||||
"""
|
||||
@@ -124,7 +123,7 @@ def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDeci
|
||||
or not ledger._supports_verifier_claim(identities, paths)):
|
||||
why = 'no current passing executable verification supports the claim'
|
||||
elif claim == 'mutation':
|
||||
if not ledger._supports_mutation_claim(paths):
|
||||
if not ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
why = 'no matching artifact mutation supports the execution claim'
|
||||
elif claim == 'execution':
|
||||
# A generic assertion cannot be tied confidently to a receipt.
|
||||
@@ -168,16 +167,6 @@ def _disclosure(answer: str, ledger: EvidenceLedger) -> str:
|
||||
return ('\n\n' + summary) if answer.strip() else summary
|
||||
|
||||
|
||||
def _complete_prose_prefix(text: str) -> str:
|
||||
"""Return complete statement segments, keeping the current sentence pending."""
|
||||
statements = list(_unquoted_statements(text))
|
||||
if not statements:
|
||||
return ''
|
||||
if statements[-1][0].strip():
|
||||
statements.pop()
|
||||
return ''.join(statement for statement, _ in statements)
|
||||
|
||||
|
||||
def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
|
||||
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
|
||||
execution_required = _execution_obligation(ledger.requirements)
|
||||
@@ -243,10 +232,6 @@ def with_completion_gate(func):
|
||||
journal.effects = (parent.effects if parent is not None and parent.effects is not None
|
||||
else EffectLog(journal.run_id))
|
||||
answer_events: list[dict] = []
|
||||
streamed_answer_events: set[int] = set()
|
||||
streamed_safe_text = ''
|
||||
stream_conflict = False
|
||||
first_visible_answer_at = None
|
||||
metrics_events: list[dict] = []
|
||||
answer = ''
|
||||
has_final = False
|
||||
@@ -312,36 +297,23 @@ def with_completion_gate(func):
|
||||
first_answer_at = perf_counter()
|
||||
# Boolean thinking=True marks a reasoning-only delta;
|
||||
# a textual thinking companion must not hide an answer
|
||||
# delta. Thinking remains buffered until terminal review.
|
||||
# delta. Both shapes remain buffered until the gate.
|
||||
if isinstance(data.get('thinking'), str):
|
||||
thinking_event = {'delta': data['thinking'], 'thinking': True}
|
||||
answer_events.append(thinking_event)
|
||||
answer_events.append({'delta': data['thinking'], 'thinking': True})
|
||||
data = {key: value for key, value in data.items() if key != 'thinking'}
|
||||
if 'delta' not in data:
|
||||
continue
|
||||
if data.get('thinking') is not True and 'delta' in data:
|
||||
if has_final:
|
||||
stream_conflict |= bool(streamed_safe_text)
|
||||
streamed_safe_text = ''
|
||||
answer = ''
|
||||
has_final = False
|
||||
answer += str(data.get('delta') or '')
|
||||
answer_events.append(data)
|
||||
if data.get('thinking') is not True and 'delta' in data \
|
||||
and not _execution_obligation(requirements):
|
||||
prefix = _complete_prose_prefix(answer)
|
||||
if prefix:
|
||||
ledger_now = _ledger(journal, requirements)
|
||||
decision_now = ledger_now.evaluate(exhausted=exhausted, awaiting_user=awaiting)
|
||||
safe_prefix, _ = _supported_prose(prefix, ledger_now, decision_now)
|
||||
if safe_prefix.startswith(streamed_safe_text):
|
||||
delta = safe_prefix[len(streamed_safe_text):]
|
||||
if delta:
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event({'delta': delta})
|
||||
streamed_safe_text = safe_prefix
|
||||
elif streamed_safe_text:
|
||||
stream_conflict = True
|
||||
if data.get('thinking') is True and 'delta' in data:
|
||||
# Stream reasoning deltas live; only the answer
|
||||
# prose is held for the terminal evidence review.
|
||||
streamed_answer_events.add(id(data))
|
||||
yield _event(data)
|
||||
continue
|
||||
yield chunk
|
||||
if provider_error and not answer_events and not metrics_events:
|
||||
@@ -394,10 +366,7 @@ def with_completion_gate(func):
|
||||
disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft
|
||||
or filtered_answer != answer)
|
||||
replaced_answer = not disclosure_only and bool(
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer or stream_conflict
|
||||
or (provider_error and streamed_safe_text))
|
||||
if streamed_safe_text and not answer.startswith(streamed_safe_text):
|
||||
replaced_answer = True
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer)
|
||||
if replaced_answer:
|
||||
reasoning = [event for event in answer_events if event.get('thinking') is True]
|
||||
_, unsafe_reasoning = completion_answer(
|
||||
@@ -405,31 +374,11 @@ def with_completion_gate(func):
|
||||
replace(presentation_decision, can_complete=True))
|
||||
if not unsafe_reasoning:
|
||||
for event in reasoning:
|
||||
if id(event) not in streamed_answer_events:
|
||||
yield _event(event)
|
||||
yield _event({
|
||||
'type': 'final_response',
|
||||
'content': safe_answer,
|
||||
'render_owner': 'streamed',
|
||||
'replacement_scope': 'turn',
|
||||
})
|
||||
yield _event(event)
|
||||
yield _event({'type': 'final_response', 'content': safe_answer})
|
||||
else:
|
||||
for event in answer_events:
|
||||
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})
|
||||
yield _event(event)
|
||||
if disclosure_only:
|
||||
yield _event({'delta': disclosure})
|
||||
if provider_error:
|
||||
@@ -442,11 +391,8 @@ def with_completion_gate(func):
|
||||
if ledger.effects:
|
||||
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
|
||||
metadata['completion_gate'] = {
|
||||
'buffer_seconds': max(0, (first_visible_answer_at or released_at) - first_answer_at)
|
||||
if first_answer_at is not None else 0,
|
||||
'evaluation_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': first_visible_answer_at - started
|
||||
if first_visible_answer_at is not None else 0,
|
||||
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': released_at - started,
|
||||
'additional_provider_calls': 0,
|
||||
'answer_replaced': replaced_answer,
|
||||
}
|
||||
|
||||
@@ -230,6 +230,18 @@ def classify(capture: DispatchCapture) -> dict[str, Any] | None:
|
||||
if capture.backend is not None:
|
||||
from src.agent_runtime.resources import ExternalResource
|
||||
if isinstance(capture.backend.resource, ExternalResource):
|
||||
# A read through an external backend is still a read. The tool
|
||||
# capability registry is sealed by the dispatcher, and unknown or
|
||||
# mixed-effect calls continue to receive an external effect claim.
|
||||
if all(bound is None for bound in (capture.browser, capture.filesystem,
|
||||
capture.process, capture.owned)):
|
||||
from src.tool_capabilities import ToolEffect, capabilities_for_action
|
||||
capabilities = capabilities_for_action(capture.backend.transport_tool,
|
||||
capture.backend.exact_input)
|
||||
reads = frozenset({ToolEffect.READ_PUBLIC, ToolEffect.READ_WORKSPACE,
|
||||
ToolEffect.READ_PRIVATE, ToolEffect.BROKERED_NETWORK_READ})
|
||||
if capabilities.known and capabilities.effects and capabilities.effects <= reads:
|
||||
return None
|
||||
external = True
|
||||
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
|
||||
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
|
||||
|
||||
@@ -114,11 +114,9 @@ def _control_plane_snapshot():
|
||||
uploader = get_upload_handler()
|
||||
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"))
|
||||
for directory in job_dirs:
|
||||
jobs = Path(directory)
|
||||
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())
|
||||
# Prefix protection covers the complete runtime trees. Public policy denies
|
||||
# every regular hardlink alias, so collecting all child inodes here would
|
||||
# add an unbounded recursive state inventory without widening protection.
|
||||
protected.update(canonical_root(getattr(constants, name) + suffix)
|
||||
for name in ("APP_DB", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB")
|
||||
for suffix in ("-wal", "-shm", "-journal"))
|
||||
@@ -131,7 +129,7 @@ def _control_plane_snapshot():
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
identities.add((observed.st_dev, observed.st_ino))
|
||||
# Effect directories join the prefix set only after the recursive inventory.
|
||||
# Effect directories are protected by prefix without inventorying children.
|
||||
return frozenset(job_dirs | effect_dirs), frozenset(protected), frozenset(identities)
|
||||
|
||||
|
||||
@@ -139,6 +137,11 @@ def _control_plane_path(path, *, snapshot=None):
|
||||
# A scan-local snapshot bounds repeated hardlink checks. Ordinary resource
|
||||
# resolution always observes fresh state. Neither form is an atomic kernel
|
||||
# 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
|
||||
if any(Path(path).is_relative_to(directory) for directory in directories) or path in protected:
|
||||
return True
|
||||
|
||||
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
from .bg_job_tools import ManageBgJobsTool
|
||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
|
||||
from .admin_tools import (
|
||||
ADMIN_TOOL_HANDLERS,
|
||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
|
||||
"list_sessions": ListSessionsTool().execute,
|
||||
"send_to_session": SendToSessionTool().execute,
|
||||
"manage_session": ManageSessionTool().execute,
|
||||
"plan_edit": PlanEditTool().execute,
|
||||
"run_editor_command": RunEditorCommandTool().execute,
|
||||
"get_editor_screenshot": GetEditorScreenshotTool().execute,
|
||||
"ai_edit_image": AiEditImageTool().execute,
|
||||
}
|
||||
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||
|
||||
@@ -38,11 +38,25 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
|
||||
|
||||
elif action == "add":
|
||||
import uuid as _uuid
|
||||
from src.url_safety import check_outbound_url
|
||||
name = args.get("name", "")
|
||||
base_url = args.get("base_url", "")
|
||||
api_key = args.get("api_key", "")
|
||||
if not base_url:
|
||||
return {"error": "base_url is required", "exit_code": 1}
|
||||
# SSRF hardening: validate the URL before storing it. Every entry
|
||||
# point that accepts an external base_url must run this guard.
|
||||
# The HTTP route (POST /api/model-endpoints) gets the same guard
|
||||
# in #6316; this tool bypasses that route entirely, so it needs
|
||||
# its own validation. A prompt-injection payload smuggled into a
|
||||
# skill, note, fetched page, or email could instruct the model to
|
||||
# register a cloud-metadata or internal-network endpoint.
|
||||
ok, reason = check_outbound_url(
|
||||
base_url,
|
||||
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
return {"error": f"Rejected endpoint URL: {reason}", "exit_code": 1}
|
||||
eid = str(_uuid.uuid4())[:8]
|
||||
from datetime import datetime
|
||||
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
|
||||
@@ -75,7 +89,7 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_endpoints error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
return {"error": "An internal error occurred while managing endpoints", "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -496,7 +496,6 @@ def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional
|
||||
"content": clean,
|
||||
"version": 1,
|
||||
"source_doc_id": getattr(source_doc, "id", None),
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -619,7 +618,6 @@ class CreateDocumentTool:
|
||||
"language": language,
|
||||
"content": content,
|
||||
"version": 1,
|
||||
"exit_code": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -710,7 +708,6 @@ class UpdateDocumentTool:
|
||||
"language": doc.language,
|
||||
"content": new_content,
|
||||
"version": new_ver,
|
||||
"exit_code": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -866,7 +863,6 @@ class EditDocumentTool:
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {"error": "No edits applied — FIND text cannot be blank"}
|
||||
|
||||
@@ -972,7 +968,6 @@ class EditDocumentTool:
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"exit_code": 0,
|
||||
}
|
||||
if partial_edits:
|
||||
result.update({
|
||||
@@ -1064,7 +1059,6 @@ class SuggestDocumentTool:
|
||||
"partial": bool(invalid),
|
||||
"rejected": len(invalid),
|
||||
"invalid_suggestions": invalid,
|
||||
"exit_code": 0,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
"""In-process agent tools for the scoped ``photo_editor`` capability family.
|
||||
|
||||
These handlers run as the logged-in owner and drive the image editor over the
|
||||
owner-scoped in-process bridge (``/api/editor/bridge/*``) plus the app's
|
||||
existing image transforms (``/api/image/*``). They are only offered to the
|
||||
model when the ``photo_editor`` family is active (the Quick Edit popup or an
|
||||
explicit photo-edit chat turn), never in the general tool list.
|
||||
|
||||
Loopback calls carry the internal token and impersonate ``owner`` (middleware
|
||||
app.py), so the editor bridge resolves the same ``owner:session`` scope the
|
||||
browser subscriber uses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from src.tools._common import _INTERNAL_BASE, _internal_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _provider_message() -> str:
|
||||
try:
|
||||
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||
|
||||
return IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||
except Exception:
|
||||
return (
|
||||
"Removing/adding objects needs an image generation provider; enable one "
|
||||
"in Settings > Models."
|
||||
)
|
||||
|
||||
|
||||
def _provider_available(owner: Optional[str]) -> bool:
|
||||
try:
|
||||
from src.ai_interaction import image_editing_provider_available
|
||||
|
||||
return bool(image_editing_provider_available(owner))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
# Local diffusion/image servers can take 90-110s per image (8 steps) and
|
||||
# longer on a cold start; the previous 120s default timed out mid-generation.
|
||||
_EDITOR_TIMEOUT = 300.0
|
||||
# A mask covering less than this fraction of the image cannot drive a useful
|
||||
# regional inpaint (tiny figures, empty SAM output): fall back to grounding or
|
||||
# whole-image instruction mode instead.
|
||||
_MIN_MASK_FRACTION = 0.002
|
||||
_AI_OPS = {
|
||||
"remove-bg": "/api/image/remove-bg",
|
||||
"upscale": "/api/image/upscale-local",
|
||||
"denoise": "/api/image/denoise",
|
||||
"enhance-face": "/api/image/enhance-face",
|
||||
"inpaint": "/api/image/inpaint",
|
||||
"generative": "/api/image/inpaint",
|
||||
}
|
||||
_PLACE_OP = "layers.placeImage"
|
||||
|
||||
|
||||
def _parse(content: Any) -> Dict[str, Any]:
|
||||
raw = content if isinstance(content, str) else json.dumps(content or {})
|
||||
try:
|
||||
parsed = json.loads(raw) if raw.strip() else {}
|
||||
except (ValueError, TypeError):
|
||||
parsed = {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
||||
|
||||
|
||||
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||
headers = {**_internal_headers(owner), "Content-Type": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{_INTERNAL_BASE}{path}", json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return {"raw": resp.text}
|
||||
|
||||
|
||||
def _as_data_url(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if value.startswith("data:"):
|
||||
return value
|
||||
if value.startswith("/"):
|
||||
return None
|
||||
return f"data:image/png;base64,{value}"
|
||||
|
||||
|
||||
def _data_url_bytes(data_url: str) -> bytes:
|
||||
_, _, b64 = data_url.partition(",")
|
||||
return base64.b64decode(b64)
|
||||
|
||||
|
||||
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
||||
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||
from core.database import GalleryImage, SessionLocal
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
(root / filename).write_bytes(image_bytes)
|
||||
new_id = str(uuid.uuid4())
|
||||
width = height = 0
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
pass
|
||||
size = f"{width}x{height}" if width and height else ""
|
||||
# The editor bridge uses an owner-scoped session id (e.g. "pewds:3")
|
||||
# that is not a chat-sessions row. GalleryImage.session_id is a FK to
|
||||
# sessions.id, so drop an unknown id instead of raising IntegrityError.
|
||||
if session_id:
|
||||
from core.database import Session as _ChatSession
|
||||
|
||||
try:
|
||||
known = db.query(_ChatSession.id).filter(_ChatSession.id == session_id).first()
|
||||
except Exception:
|
||||
known = None
|
||||
if known is None:
|
||||
session_id = None
|
||||
derived = GalleryImage(
|
||||
id=new_id,
|
||||
filename=filename,
|
||||
prompt=prompt or None,
|
||||
caption=None,
|
||||
model=model,
|
||||
size=size or None,
|
||||
quality=None,
|
||||
tags=None,
|
||||
ai_tags=None,
|
||||
session_id=session_id,
|
||||
album_id=None,
|
||||
owner=owner,
|
||||
file_hash=hashlib.sha256(image_bytes).hexdigest(),
|
||||
file_size=len(image_bytes),
|
||||
width=width or None,
|
||||
height=height or None,
|
||||
)
|
||||
db.add(derived)
|
||||
db.commit()
|
||||
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
|
||||
params: Dict[str, Any] = {
|
||||
"dataUrl": data_url,
|
||||
"name": name,
|
||||
"masked": bool(masked),
|
||||
"blend": blend or "normal",
|
||||
"opacity": opacity,
|
||||
}
|
||||
# Forward the edit mode so the client can build a difference mask for
|
||||
# instruction-mode results: the whole image was regenerated under an
|
||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||
# client keeps only the changed region) instead of the new layer overwriting
|
||||
# the entire canvas. Omit the keys entirely when no mode is given so the
|
||||
# client keeps its current behavior (e.g. generate_image must not diff-mask).
|
||||
if mode:
|
||||
params["mode"] = mode
|
||||
params["differenceMask"] = mode == "instruction"
|
||||
if mode == "instruction":
|
||||
# Exclude a thin edge band from the client difference mask: full-frame
|
||||
# regenerations can shift pixels along the border.
|
||||
params["edgeExcludePx"] = 3
|
||||
payload = {
|
||||
"session": session,
|
||||
"op": _PLACE_OP,
|
||||
"params": params,
|
||||
"wait": True,
|
||||
"timeout": 60,
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||
return data.get("ok") is not False
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("editor layer placement failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def _fetch_selection_mask(owner: Optional[str], session: str) -> Optional[str]:
|
||||
"""Return the editor's current selection as a data-URL mask, or None.
|
||||
|
||||
Generative ops (inpaint/generative) need a mask; the planner/judge may not
|
||||
have supplied one, so fall back to whatever is selected in the editor.
|
||||
"""
|
||||
payload = {
|
||||
"session": session,
|
||||
"op": "selection.exportMask",
|
||||
"params": {},
|
||||
"wait": True,
|
||||
"timeout": 30,
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=45)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("editor selection mask fetch failed: %s", exc)
|
||||
return None
|
||||
result = data.get("result") if isinstance(data, dict) else None
|
||||
if isinstance(result, dict):
|
||||
return _as_data_url(result.get("mask"))
|
||||
return None
|
||||
|
||||
|
||||
def _mask_white_fraction(data_url: Optional[str]) -> float:
|
||||
"""Fraction of a grayscale mask that is selected (white, >=128)."""
|
||||
if not data_url:
|
||||
return 0.0
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))).convert("L") as im:
|
||||
total = im.width * im.height
|
||||
if not total:
|
||||
return 0.0
|
||||
hist = im.histogram()
|
||||
return sum(hist[128:]) / total
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _image_is_blank(data_url: str) -> bool:
|
||||
"""True when a result has an alpha channel that is essentially transparent."""
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))) as im:
|
||||
if im.mode not in ("RGBA", "LA") and "transparency" not in im.info:
|
||||
return False
|
||||
alpha = im.convert("RGBA").getchannel("A")
|
||||
hist = alpha.histogram()
|
||||
total = sum(hist)
|
||||
return total > 0 and (sum(hist[1:]) / total) < 0.001
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _rect_mask_data_url(image_data_url: str, box: List[float], *, pad_frac: float = 0.15) -> Optional[str]:
|
||||
"""Build a rectangular mask data URL from a [x1,y1,x2,y2] box, dilated.
|
||||
|
||||
Used when the grounding detector finds an object but SAM returns an empty
|
||||
mask (tiny figures): a dilated box guarantees a usable region.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image as _PILImage, ImageDraw
|
||||
|
||||
with _PILImage.open(io.BytesIO(_data_url_bytes(image_data_url))) as im:
|
||||
w, h = im.size
|
||||
x1, y1, x2, y2 = (float(v) for v in box)
|
||||
bw, bh = x2 - x1, y2 - y1
|
||||
x1, y1 = max(0.0, x1 - bw * pad_frac), max(0.0, y1 - bh * pad_frac)
|
||||
x2, y2 = min(float(w), x2 + bw * pad_frac), min(float(h), y2 + bh * pad_frac)
|
||||
mask = _PILImage.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
|
||||
buf = io.BytesIO()
|
||||
mask.save(buf, "PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _ground_box(owner: Optional[str], image_data_url: str, label: str) -> Optional[List[float]]:
|
||||
"""Use the in-app grounding detector (OWL-ViT via /api/image/mask) for a box."""
|
||||
try:
|
||||
data = await _post("/api/image/mask", {"image": image_data_url, "text": label}, owner, timeout=90)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("editor grounding box failed: %s", exc)
|
||||
return None
|
||||
box = data.get("bbox") if isinstance(data, dict) else None
|
||||
if isinstance(box, (list, tuple)) and len(box) == 4:
|
||||
try:
|
||||
return [float(v) for v in box]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_keep_clause(text: str) -> str:
|
||||
"""Append a keep-the-subject clause when the prompt lacks one."""
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return body
|
||||
if re.search(r"\b(keep|preserve|leave|unchanged|identical|same)\b", body, re.I):
|
||||
return body
|
||||
return body.rstrip(". ") + ". Keep the main subject and everything else in the image exactly the same."
|
||||
|
||||
|
||||
def _mask_label(args: Dict[str, Any], prompt: str) -> str:
|
||||
"""Best-effort object noun for grounding a regional removal."""
|
||||
label = str(args.get("label") or args.get("object") or "").strip()
|
||||
if label:
|
||||
return label
|
||||
m = re.search(
|
||||
r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out)\s+(?:the\s+|a\s+|an\s+)?"
|
||||
r"([a-z][a-z \-]{1,30}?)(?:\s+(?:on|in|at|from|to|near|behind|beside|by)\b|[.,;!?]|$)",
|
||||
prompt or "",
|
||||
re.I,
|
||||
)
|
||||
if m:
|
||||
noun = m.group(1).strip().lower()
|
||||
if noun and noun not in {"background", "bg"}:
|
||||
return noun
|
||||
for noun in ("person", "people", "man", "woman", "car", "dog", "sign", "logo"):
|
||||
if re.search(rf"\b{re.escape(noun)}\b", prompt or "", re.I):
|
||||
return "person" if noun in {"person", "people", "man", "woman"} else noun
|
||||
return "object"
|
||||
|
||||
|
||||
class PlanEditTool:
|
||||
"""Plan an image edit as ordered editor operations (no side effects)."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
prompt = str(args.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return "plan_edit: invalid", {"error": "plan_edit needs a non-empty `prompt`.", "exit_code": 1}
|
||||
state = args.get("state") if isinstance(args.get("state"), dict) else {}
|
||||
try:
|
||||
data = await _post(
|
||||
"/api/editor/plan",
|
||||
{"prompt": prompt, "state": state, "mode": "plan", "session": _session(args, ctx)},
|
||||
ctx.get("owner"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return "plan_edit: error", {"error": f"plan_edit failed: {exc}", "exit_code": 1}
|
||||
steps = data.get("steps") or []
|
||||
reply = str(data.get("reply") or "")
|
||||
return f"plan_edit: {len(steps)} step(s)", {
|
||||
"output": reply or f"Planned {len(steps)} editor step(s).",
|
||||
"reply": reply,
|
||||
"planner": data.get("planner"),
|
||||
"steps": steps,
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
class RunEditorCommandTool:
|
||||
"""Run a single validated editor op in the browser via the owner bridge."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
op = str(args.get("op") or "").strip()
|
||||
if not op:
|
||||
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
||||
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||
payload = {
|
||||
"session": _session(args, ctx),
|
||||
"op": op,
|
||||
"params": params,
|
||||
"wait": bool(args.get("wait", True)),
|
||||
"timeout": float(args.get("timeout") or 60),
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||
except Exception as exc:
|
||||
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
||||
ok = data.get("ok")
|
||||
result = data.get("result")
|
||||
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||
return f"run_editor_command: {op}", {
|
||||
"output": summary[:4000],
|
||||
"result": result,
|
||||
"screenshot": data.get("screenshot"),
|
||||
"ok": ok,
|
||||
"exit_code": 0 if ok is not False else 1,
|
||||
}
|
||||
|
||||
|
||||
class GetEditorScreenshotTool:
|
||||
"""Capture the current editor canvas as a data URL."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
payload = {
|
||||
"session": _session(args, ctx),
|
||||
"op": "screenshot",
|
||||
"params": {},
|
||||
"wait": True,
|
||||
"timeout": float(args.get("timeout") or 30),
|
||||
}
|
||||
try:
|
||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"), timeout=45)
|
||||
except Exception as exc:
|
||||
return "get_editor_screenshot: error", {"error": f"get_editor_screenshot failed: {exc}", "exit_code": 1}
|
||||
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||
shot = data.get("screenshot") or result.get("dataUrl")
|
||||
if not shot:
|
||||
return "get_editor_screenshot: unavailable", {
|
||||
"error": "No editor screenshot was returned (is the editor open?).",
|
||||
"exit_code": 1,
|
||||
}
|
||||
return "get_editor_screenshot", {"output": "Captured the editor canvas.", "screenshot": shot, "exit_code": 0}
|
||||
|
||||
|
||||
class AiEditImageTool:
|
||||
"""Apply an AI image transform (remove-bg/upscale/denoise/enhance/inpaint)."""
|
||||
|
||||
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||
args = _parse(content)
|
||||
_t = {"start": time.perf_counter()}
|
||||
op = str(args.get("op") or "").strip().lower()
|
||||
if op not in _AI_OPS:
|
||||
return "ai_edit_image: invalid", {
|
||||
"error": f"ai_edit_image `op` must be one of {sorted(_AI_OPS)}.",
|
||||
"exit_code": 1,
|
||||
}
|
||||
owner = ctx.get("owner")
|
||||
if op in ("inpaint", "generative") and not _provider_available(owner):
|
||||
return "ai_edit_image: provider required", {
|
||||
"error": _provider_message(),
|
||||
"failure_kind": "image_provider_unavailable",
|
||||
"needs_provider": True,
|
||||
"exit_code": 1,
|
||||
}
|
||||
session = _session(args, ctx)
|
||||
image = _as_data_url(args.get("image"))
|
||||
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
||||
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||
resp.raise_for_status()
|
||||
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||
except Exception:
|
||||
image = None
|
||||
used_canvas = False
|
||||
if not image:
|
||||
shot = await GetEditorScreenshotTool().execute(json.dumps({"session": session}), ctx)
|
||||
image = (shot[1] or {}).get("screenshot") if isinstance(shot, tuple) else None
|
||||
used_canvas = bool(image)
|
||||
if not image:
|
||||
return "ai_edit_image: no image", {
|
||||
"error": "ai_edit_image needs an `image` (data URL or same-origin URL) or an open editor.",
|
||||
"exit_code": 1,
|
||||
}
|
||||
_t["image"] = time.perf_counter()
|
||||
payload: Dict[str, Any] = {"image": image}
|
||||
model_choice = str(args.get("model") or "").strip()
|
||||
mask_used = False
|
||||
place_mode: Optional[str] = None
|
||||
if op == "upscale":
|
||||
payload["scale"] = int(args.get("scale") or 2)
|
||||
elif op == "denoise":
|
||||
payload["strength"] = float(args.get("strength") or 0.5)
|
||||
elif op in ("inpaint", "generative"):
|
||||
payload["prompt"] = str(args.get("prompt") or "").strip()
|
||||
if model_choice:
|
||||
# inpaint_proxy reads `_model` to route to a specific model.
|
||||
payload["_model"] = model_choice
|
||||
mode = str(args.get("mode") or "instruction").strip().lower()
|
||||
_steps = int(args.get("steps") or 0)
|
||||
if _steps > 0:
|
||||
payload["steps"] = _steps
|
||||
mask_used = False
|
||||
mask = None
|
||||
if mode == "masked":
|
||||
mask = _as_data_url(args.get("mask"))
|
||||
if not mask:
|
||||
mask = await _fetch_selection_mask(owner, session)
|
||||
# An empty/tiny SAM mask on small figures is useless: drop it.
|
||||
if mask and _mask_white_fraction(mask) < _MIN_MASK_FRACTION:
|
||||
mask = None
|
||||
if not mask:
|
||||
# Fall back to the grounding detector (OWL-ViT): a dilated
|
||||
# box mask around the matching object.
|
||||
box = await _ground_box(owner, image, _mask_label(args, payload["prompt"]))
|
||||
if box:
|
||||
candidate = _rect_mask_data_url(image, box, pad_frac=0.15)
|
||||
if candidate and _mask_white_fraction(candidate) >= _MIN_MASK_FRACTION:
|
||||
mask = candidate
|
||||
if mask:
|
||||
payload["mask"] = mask
|
||||
payload["mode"] = "masked"
|
||||
mask_used = True
|
||||
else:
|
||||
# Nothing usable to mask -> whole-image instruction fallback
|
||||
# rather than placing a transparent/empty layer.
|
||||
mode = "instruction"
|
||||
if mode != "masked":
|
||||
# Instruction mode: whole-image edit with no tight mask. Send a
|
||||
# full-white mask so the edit endpoint accepts the request and
|
||||
# regenerates the whole frame under the instruction prompt, which
|
||||
# names what must stay unchanged. This avoids the mask-compositing
|
||||
# halos that loose selections caused.
|
||||
payload["mode"] = "instruction"
|
||||
payload.pop("mask", None)
|
||||
payload["prompt"] = _ensure_keep_clause(payload["prompt"])
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
_raw = _data_url_bytes(image)
|
||||
_w, _h = _PILImage.open(io.BytesIO(_raw)).size
|
||||
_mbuf = io.BytesIO()
|
||||
_PILImage.new("L", (_w, _h), 255).save(_mbuf, "PNG")
|
||||
payload["mask"] = "data:image/png;base64," + base64.b64encode(_mbuf.getvalue()).decode()
|
||||
except Exception:
|
||||
pass
|
||||
place_mode = "masked" if mask_used else "instruction"
|
||||
_t["prep"] = time.perf_counter()
|
||||
try:
|
||||
data = await _post(_AI_OPS[op], payload, owner)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"image-op ai_edit_image op=%s stage=provider FAILED image_ms=%.1f prep_ms=%.1f total_ms=%.1f err=%s",
|
||||
op, (_t["image"] - _t["start"]) * 1000.0, (_t["prep"] - _t["image"]) * 1000.0,
|
||||
(time.perf_counter() - _t["start"]) * 1000.0, exc,
|
||||
)
|
||||
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) failed: {exc}", "exit_code": 1}
|
||||
_t["provider"] = time.perf_counter()
|
||||
result = data.get("image") or data.get("data")
|
||||
data_url = _as_data_url(result)
|
||||
if not data_url:
|
||||
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) returned no image.", "exit_code": 1}
|
||||
if _image_is_blank(data_url):
|
||||
return f"ai_edit_image: {op}", {
|
||||
"error": f"ai_edit_image ({op}) produced an empty layer.",
|
||||
"failure_kind": "empty_result",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"output": f"Applied AI edit '{op}'" + (" to the current canvas." if used_canvas else "."),
|
||||
"op": op,
|
||||
"model": data.get("model") or model_choice or "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||
except Exception as exc:
|
||||
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||
_t["persist"] = time.perf_counter()
|
||||
if bool(args.get("place", True)):
|
||||
placed = await _place_layer(
|
||||
data_url,
|
||||
f"AI {op}",
|
||||
owner,
|
||||
session,
|
||||
masked=mask_used,
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
mode=place_mode,
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
info["output"] += " Placed the result as a new layer."
|
||||
_t["place"] = time.perf_counter()
|
||||
logger.info(
|
||||
"image-op ai_edit_image op=%s mode=%s canvas=%s image_ms=%.1f mask_ms=%.1f "
|
||||
"model_ms=%.1f persist_ms=%.1f place_ms=%.1f total_ms=%.1f",
|
||||
op, place_mode or "instruction", used_canvas,
|
||||
(_t["image"] - _t["start"]) * 1000.0,
|
||||
(_t["prep"] - _t["image"]) * 1000.0,
|
||||
(_t["provider"] - _t["prep"]) * 1000.0,
|
||||
(_t["persist"] - _t["provider"]) * 1000.0,
|
||||
(_t["place"] - _t["persist"]) * 1000.0,
|
||||
(_t["place"] - _t["start"]) * 1000.0,
|
||||
)
|
||||
return f"ai_edit_image: {op}", info
|
||||
|
||||
|
||||
class GenerateImageLayerTool:
|
||||
"""Generate an image with the configured image model and place it as a layer."""
|
||||
|
||||
async def execute(self, content: str, ctx: dict):
|
||||
args = _parse(content)
|
||||
prompt = str(args.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return "generate_image: invalid", {"error": "generate_image needs a non-empty `prompt`.", "exit_code": 1}
|
||||
owner = ctx.get("owner")
|
||||
if not _provider_available(owner):
|
||||
return "generate_image: provider required", {
|
||||
"error": _provider_message(),
|
||||
"failure_kind": "image_provider_unavailable",
|
||||
"needs_provider": True,
|
||||
"exit_code": 1,
|
||||
}
|
||||
session = _session(args, ctx)
|
||||
payload: Dict[str, Any] = {"prompt": prompt}
|
||||
for key in ("model", "size", "quality"):
|
||||
if args.get(key):
|
||||
payload[key] = args[key]
|
||||
try:
|
||||
from src.ai_interaction import do_generate_image
|
||||
|
||||
data = await do_generate_image(
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
session_id=session,
|
||||
owner=owner,
|
||||
incognito=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return "generate_image: error", {"error": f"generate_image failed: {exc}", "exit_code": 1}
|
||||
result = data.get("image_url") or data.get("image") or data.get("data")
|
||||
data_url = _as_data_url(result)
|
||||
if not data_url:
|
||||
return "generate_image", {"error": "generate_image returned no image.", "exit_code": 1}
|
||||
info: Dict[str, Any] = {
|
||||
"output": f"Generated an image for: {prompt[:120]}",
|
||||
"image_size": data.get("image_size"),
|
||||
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||
"exit_code": 0,
|
||||
}
|
||||
try:
|
||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||
except Exception as exc:
|
||||
logger.warning("generate_image persist failed: %s", exc)
|
||||
if bool(args.get("place", True)):
|
||||
placed = await _place_layer(
|
||||
data_url,
|
||||
str(args.get("name") or "Generated image"),
|
||||
owner,
|
||||
session,
|
||||
masked=bool(args.get("mask")),
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
info["output"] += " Placed it as a new layer."
|
||||
return "generate_image", info
|
||||
+573
-115
@@ -3,8 +3,9 @@ import json
|
||||
import os
|
||||
import re
|
||||
import difflib
|
||||
import fnmatch
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
import tempfile
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
|
||||
@@ -18,6 +19,8 @@ _CODENAV_SKIP_DIRS = frozenset({
|
||||
})
|
||||
_CODENAV_MAX_HITS = 200
|
||||
_CODENAV_MAX_LINE = 400
|
||||
_GREP_TIMEOUT_SECONDS = 20
|
||||
_GREP_STDERR_PREFIX = 20_000
|
||||
_STRUCTURED_DOCUMENT_SUFFIXES = frozenset({
|
||||
".doc", ".docx", ".epub", ".pdf", ".pptx", ".xls", ".xlsx",
|
||||
})
|
||||
@@ -88,6 +91,79 @@ def _glob_to_regex(pat: str) -> "re.Pattern":
|
||||
i += 1
|
||||
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]]:
|
||||
if old == new:
|
||||
return None
|
||||
@@ -268,32 +344,82 @@ class ReadFileTool:
|
||||
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
|
||||
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:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _display_tool_path, _resolve_tool_path, _resolve_search_root, _truncate
|
||||
lines = content.split("\n", 1)
|
||||
raw_path = lines[0].strip()
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
# Decode JSON-object args (the fenced inline-args shape
|
||||
# ```write_file {"path": "...", "content": "..."}```), matching
|
||||
# ReadFileTool above. Without this the whole JSON string becomes the
|
||||
# path and the file is written under a garbage name. This is the live
|
||||
# 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}
|
||||
from src.tool_execution import _display_tool_path, _resolve_tool_path
|
||||
from src.agent_runtime.resource_binding import active_resource_operation
|
||||
bound = active_resource_operation()
|
||||
original = bound.operation.input if bound is not None else content
|
||||
try:
|
||||
_, _, has_section, declared_clear = _parse_write_intent(original)
|
||||
raw_path, body, _, _ = _parse_write_intent(content)
|
||||
except ValueError as error:
|
||||
return {"error": str(error), "exit_code": 1}
|
||||
if not raw_path:
|
||||
return {"error": "write_file: path required", "exit_code": 1}
|
||||
try:
|
||||
@@ -323,16 +449,6 @@ class WriteFileTool:
|
||||
"exit_code": 1,
|
||||
"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
|
||||
# existing PDF, image, archive, or media artifact produced by a
|
||||
# format-aware tool, especially after the agent has verified it.
|
||||
@@ -347,6 +463,10 @@ class WriteFileTool:
|
||||
"exit_code": 1,
|
||||
"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
|
||||
# may leave a partial file; report it so effect evidence stays honest.
|
||||
attempted = []
|
||||
@@ -361,21 +481,67 @@ class WriteFileTool:
|
||||
d = os.path.dirname(path)
|
||||
if d:
|
||||
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)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
return old, len(body)
|
||||
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:
|
||||
return {"error": f"write_file: {path}: permission denied", "exit_code": 1,
|
||||
**({"mutation_attempted": True} if attempted else {})}
|
||||
except OSError as e:
|
||||
return {"error": f"write_file: {path}: {e}", "exit_code": 1,
|
||||
**({"mutation_attempted": True} if attempted else {})}
|
||||
diff = _unified_diff(old_content, body, path)
|
||||
committed_body = old_content if size == 0 and not declared_clear and not body.strip() else body
|
||||
diff = _unified_diff(old_content, committed_body, path)
|
||||
result = {
|
||||
"output": f"Wrote {size} bytes to {_display_tool_path(path)}",
|
||||
"output": (f"Wrote {size} bytes to {_display_tool_path(path)}" if attempted else
|
||||
f"No write performed for {_display_tool_path(path)} (implicit empty body)"),
|
||||
"exit_code": 0,
|
||||
**({"write_noop": True} if not attempted else {}),
|
||||
}
|
||||
if diff:
|
||||
result["diff"] = diff
|
||||
@@ -619,7 +785,7 @@ def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str
|
||||
|
||||
class LsTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _display_tool_path, _resolve_search_root, _truncate
|
||||
from src.tool_execution import _display_tool_path, _is_denied_tool_path, _resolve_search_root, _truncate
|
||||
raw_path = ""
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
@@ -635,15 +801,18 @@ class LsTool:
|
||||
return {"error": f"ls: {e}", "exit_code": 1}
|
||||
|
||||
def _ls():
|
||||
from src.agent_runtime.resources import _control_plane_snapshot
|
||||
if not os.path.isdir(root):
|
||||
return None, f"ls: {root}: not a directory"
|
||||
rows = []
|
||||
snapshot = _control_plane_snapshot()
|
||||
try:
|
||||
with os.scandir(root) as it:
|
||||
for entry in it:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
if not _visible_bound_resource(entry.path):
|
||||
if (_is_denied_tool_path(os.path.realpath(entry.path), snapshot=snapshot)
|
||||
or not _visible_bound_resource(entry.path)):
|
||||
continue
|
||||
try:
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
@@ -672,6 +841,8 @@ class GlobTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_SENSITIVE_BASENAMES,
|
||||
_can_traverse_tool_path,
|
||||
_is_denied_tool_path,
|
||||
_display_tool_path,
|
||||
_is_sensitive_path,
|
||||
_resolve_tool_path,
|
||||
@@ -696,10 +867,12 @@ class GlobTool:
|
||||
return {"error": f"glob: {e}", "exit_code": 1}
|
||||
|
||||
def _glob():
|
||||
from src.agent_runtime.resources import _control_plane_snapshot
|
||||
base = os.path.abspath(root)
|
||||
if not os.path.isdir(base):
|
||||
return None, f"glob: {root}: not a directory"
|
||||
rbase = os.path.realpath(base)
|
||||
snapshot = _control_plane_snapshot()
|
||||
norm_pat = pattern.replace("\\", "/")
|
||||
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
||||
if not any(c in norm_pat for c in "*?["):
|
||||
@@ -716,7 +889,8 @@ class GlobTool:
|
||||
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
||||
# otherwise glob would surface secret paths that read_file /
|
||||
# grep already refuse to touch.
|
||||
if inside and os.path.exists(cand) and not _is_sensitive_path(cand) and _visible_bound_resource(cand):
|
||||
if (inside and os.path.exists(cand) and not _is_denied_tool_path(cand, snapshot=snapshot)
|
||||
and _visible_bound_resource(cand)):
|
||||
return [cand], None
|
||||
# Literal not at exact path — fall through to walk so
|
||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||
@@ -726,13 +900,18 @@ class GlobTool:
|
||||
cap = _CODENAV_MAX_HITS * 5
|
||||
try:
|
||||
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
|
||||
# descends first then filters — fatal on large node_modules).
|
||||
# Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob
|
||||
# never enumerates the keys/tokens inside them.
|
||||
dns[:] = [
|
||||
d for d in dns
|
||||
if d not in _CODENAV_SKIP_DIRS and d not in _SENSITIVE_BASENAMES
|
||||
if d not in _CODENAV_SKIP_DIRS
|
||||
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:
|
||||
full = os.path.join(dp, name)
|
||||
@@ -740,7 +919,8 @@ class GlobTool:
|
||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||
# known_hosts, …) the same way grep does.
|
||||
if _is_sensitive_path(os.path.realpath(full)) or not _visible_bound_resource(full):
|
||||
if (_is_denied_tool_path(os.path.realpath(full), snapshot=snapshot)
|
||||
or not _visible_bound_resource(full)):
|
||||
continue
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
@@ -767,10 +947,14 @@ class GlobTool:
|
||||
class GrepTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_SENSITIVE_BASENAMES,
|
||||
_SENSITIVE_FILE_PATTERNS,
|
||||
_agent_readable_data_subdirs,
|
||||
_is_denied_tool_path,
|
||||
_display_tool_path,
|
||||
_can_traverse_tool_path,
|
||||
_is_sensitive_path,
|
||||
_resolve_tool_path,
|
||||
_path_within,
|
||||
_resolve_search_root,
|
||||
_truncate,
|
||||
)
|
||||
@@ -799,83 +983,357 @@ class GrepTool:
|
||||
return {"error": f"grep: {e}", "exit_code": 1}
|
||||
|
||||
def _grep():
|
||||
import re as _re
|
||||
import shutil
|
||||
import multiprocessing
|
||||
import queue
|
||||
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
|
||||
if not os.path.exists(root):
|
||||
return None, f"grep: search target not found: {_display_tool_path(root)}"
|
||||
# The pathname-only fast path scans before individual resources can
|
||||
# be checked. Bound searches must validate every file before read.
|
||||
rg = None if active_resource_operation() is not None else shutil.which("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:
|
||||
cmd += ["--glob", glob_pat]
|
||||
# --iglob (not --glob) so the exclusion is case-insensitive:
|
||||
# on a case-insensitive filesystem "ID_RSA"/"Known_Hosts"
|
||||
# resolve to the same secret as their lowercase forms, and the
|
||||
# Python fallback below already folds case via _is_sensitive_path.
|
||||
for _pat in _SENSITIVE_FILE_PATTERNS:
|
||||
cmd += ["--iglob", f"!*{_pat}*"]
|
||||
for _d in _CODENAV_SKIP_DIRS:
|
||||
cmd += ["--glob", f"!**/{_d}/**"]
|
||||
cmd += ["--regexp", pattern, root]
|
||||
try:
|
||||
import subprocess
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||
# ripgrep: 0 = matches, 1 = no matches, 2 = failed scan.
|
||||
# Do not present invalid patterns or IO failures as absence.
|
||||
if p.returncode not in (0, 1):
|
||||
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}"
|
||||
bound = active_resource_operation()
|
||||
if bound is not None:
|
||||
bound.validate()
|
||||
files = []
|
||||
snapshot = _control_plane_snapshot()
|
||||
|
||||
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:
|
||||
rx = _re.compile(pattern, _re.IGNORECASE if ignore_case else 0)
|
||||
except _re.error as _e:
|
||||
return None, f"grep: bad pattern: {_e}"
|
||||
hits = []
|
||||
scan_errors = []
|
||||
if os.path.isfile(root):
|
||||
file_iter = [root]
|
||||
else:
|
||||
file_iter = []
|
||||
for dp, dns, fns in os.walk(root, onerror=scan_errors.append):
|
||||
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
||||
for fn in fns:
|
||||
if glob_pat and not fnmatch.fnmatch(fn, glob_pat):
|
||||
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
|
||||
file_iter.append(os.path.join(dp, fn))
|
||||
for fp in file_iter:
|
||||
if len(hits) >= max_hits:
|
||||
break
|
||||
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:
|
||||
resolved_file = _resolve_tool_path(fp)
|
||||
except ValueError:
|
||||
# Apply the same workspace/sensitive-path checks to each
|
||||
# discovered file, not just the initial search directory.
|
||||
continue
|
||||
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:
|
||||
with open(resolved_file, "r", encoding="utf-8", errors="strict") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
if rx.search(line):
|
||||
hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}")
|
||||
if len(hits) >= max_hits:
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except OSError as error:
|
||||
scan_errors.append(error)
|
||||
if scan_errors:
|
||||
return None, "grep: search incomplete; one or more files or directories could not be read"
|
||||
return hits, None
|
||||
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 glob_pat:
|
||||
try:
|
||||
glob_regex = _glob_to_regex(glob_pat.replace("\\", "/"))
|
||||
targets = [
|
||||
target for target in targets
|
||||
if glob_regex.fullmatch(os.path.relpath(target, base).replace(os.sep, "/"))
|
||||
or glob_regex.fullmatch(os.path.basename(target))
|
||||
]
|
||||
except re.error:
|
||||
pass
|
||||
# Validate even when policy filtering leaves no search targets.
|
||||
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:
|
||||
output_queue.close()
|
||||
except (NameError, OSError, ValueError):
|
||||
pass
|
||||
return None, f"grep: could not start fallback worker: {exc}"
|
||||
error = None
|
||||
completed = False
|
||||
try:
|
||||
while len(lines) < max_hits:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
error = "grep: timed out"
|
||||
break
|
||||
try:
|
||||
# Keep queue waits short enough to observe a spawn
|
||||
# worker that dies during bootstrap/import before it
|
||||
# can enqueue either an error or the done sentinel.
|
||||
record = output_queue.get(timeout=min(0.05, remaining))
|
||||
except queue.Empty:
|
||||
if worker.is_alive():
|
||||
continue
|
||||
worker.join(timeout=0)
|
||||
try:
|
||||
# A multiprocessing queue's feeder can make the
|
||||
# final record visible at process-exit time. Give
|
||||
# that record precedence over the exit status.
|
||||
remaining = deadline - time.monotonic()
|
||||
record = output_queue.get(
|
||||
timeout=min(0.05, max(0, remaining))
|
||||
)
|
||||
except queue.Empty:
|
||||
error = f"grep: fallback worker exited {worker.exitcode}"
|
||||
break
|
||||
if record[0] == "done":
|
||||
completed = True
|
||||
break
|
||||
if record[0] == "error":
|
||||
error = record[1]
|
||||
break
|
||||
_, path, number, text_value = record
|
||||
canonical = os.path.realpath(path)
|
||||
if canonical not in descriptors:
|
||||
error = "grep: fallback returned an undeclared resource"
|
||||
break
|
||||
try:
|
||||
_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)
|
||||
if err:
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
import time
|
||||
import json
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -76,12 +76,14 @@ def _resolve_fontfile_for_text(text: str) -> str:
|
||||
async def _cancel_host_shell_bridge_request(
|
||||
url: str, token: str, request_id: str,
|
||||
) -> None:
|
||||
base = url.rsplit("/", 1)[0]
|
||||
if not token or not is_host_shell_bridge_url_allowed(url):
|
||||
return
|
||||
target = host_shell_bridge_endpoint_url(url, "/cancel")
|
||||
try:
|
||||
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
await client.post(
|
||||
f"{base}/cancel",
|
||||
target,
|
||||
json={"request_id": request_id},
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
@@ -216,6 +218,12 @@ def is_host_shell_bridge_url_allowed(url: str) -> bool:
|
||||
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:
|
||||
"""Map virtual /workspace paths without corrupting absolute host paths."""
|
||||
return re.sub(
|
||||
@@ -682,6 +690,7 @@ class HostShellTool:
|
||||
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
|
||||
if not token:
|
||||
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()
|
||||
if not command and not job_id:
|
||||
@@ -716,9 +725,9 @@ class HostShellTool:
|
||||
request_body["request_id"] = request_id
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout + 5) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout + 5, trust_env=False) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
run_url,
|
||||
json=request_body,
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
@@ -746,7 +755,7 @@ class HostShellTool:
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.25)
|
||||
poll = await client.post(
|
||||
url,
|
||||
run_url,
|
||||
json={"job_id": auto_job_id},
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import tempfile
|
||||
import time
|
||||
import html
|
||||
import hashlib
|
||||
import pwd
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
@@ -32,10 +31,7 @@ _ACTIVE_BROWSER_SESSIONS: set[str] = set()
|
||||
def _service_home() -> Path:
|
||||
"""Return the account home even when a task overrides ``HOME``."""
|
||||
|
||||
try:
|
||||
return Path(pwd.getpwuid(os.getuid()).pw_dir)
|
||||
except (KeyError, OSError):
|
||||
return Path.home()
|
||||
return platform_compat.service_home()
|
||||
|
||||
|
||||
def _host_npm_roots() -> list[Path]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+22
-2
@@ -464,6 +464,8 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
return {"error": "Edit needs line 2: memory_id, line 3: new text"}
|
||||
memory_id = lines[1].strip()
|
||||
new_text = lines[2].strip()
|
||||
if not memory_id:
|
||||
return {"error": "Edit needs line 2: memory_id"}
|
||||
if not new_text:
|
||||
return {"error": "New text cannot be empty"}
|
||||
|
||||
@@ -497,6 +499,8 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
if len(lines) < 2:
|
||||
return {"error": "Delete needs line 2: memory_id"}
|
||||
memory_id = lines[1].strip()
|
||||
if not memory_id:
|
||||
return {"error": "Delete needs line 2: memory_id"}
|
||||
|
||||
memories = _memory_manager.load_all()
|
||||
original_len = len(memories)
|
||||
@@ -961,7 +965,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
|
||||
elif action == "open_panel":
|
||||
# Open a top-level panel/modal: documents/library, gallery,
|
||||
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
|
||||
# calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook.
|
||||
panel = parts[1].lower() if len(parts) > 1 else ""
|
||||
view = ""
|
||||
view_label = ""
|
||||
@@ -989,6 +993,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
"note": "notes",
|
||||
"todo": "notes",
|
||||
"todos": "notes",
|
||||
"tasks": "tasks",
|
||||
"task": "tasks",
|
||||
"memories": "memories",
|
||||
"memory": "memories",
|
||||
"brain": "memories",
|
||||
@@ -1006,7 +1012,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
target = _panel_aliases.get(panel)
|
||||
if not target:
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook."}
|
||||
if target == "cookbook":
|
||||
cookbook_views = {
|
||||
"models": ("Search", "models"), "model": ("Search", "models"),
|
||||
@@ -1294,6 +1300,14 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
|
||||
async def _resolve_image_model(model_name: str):
|
||||
def _call():
|
||||
# Try a plain by-name lookup first. Gateway endpoints (OpenRouter)
|
||||
# register as `llm` yet serve image models, and the image-typed
|
||||
# query's last-resort fallback would otherwise hijack a cloud model
|
||||
# onto the local image endpoint (gpt-5-image -> qwen-image-edit).
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1412,6 +1426,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(image_bytes)
|
||||
if owner:
|
||||
from src.generated_images import register_generated_image
|
||||
register_generated_image(filename, owner)
|
||||
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
|
||||
|
||||
# Private results stay in the response, never in the gallery or on disk.
|
||||
@@ -1578,6 +1595,9 @@ async def do_edit_image(
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
(img_dir / filename).write_bytes(image_bytes)
|
||||
if owner:
|
||||
from src.generated_images import register_generated_image
|
||||
register_generated_image(filename, owner)
|
||||
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
|
||||
|
||||
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+31
-2
@@ -2,10 +2,11 @@
|
||||
"""Initialize all application components and dependencies."""
|
||||
import os
|
||||
import logging
|
||||
import stat
|
||||
from typing import Dict, Any
|
||||
|
||||
from src.constants import (
|
||||
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR,
|
||||
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR, AGENT_WORKSPACE_DIR,
|
||||
SESSIONS_FILE, DEFAULT_HOST, OPENAI_API_KEY
|
||||
)
|
||||
from src.memory import MemoryManager
|
||||
@@ -30,7 +31,35 @@ def create_directories():
|
||||
"""Create necessary directories if they don't exist."""
|
||||
for directory in (DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR):
|
||||
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]:
|
||||
"""
|
||||
Initialize all manager and handler instances.
|
||||
|
||||
@@ -17,13 +17,10 @@ def compact_browser_observation(value, budget=8000):
|
||||
notices.append('Exit code: ' + str(item['exit_code']))
|
||||
if item.get('success') is False:
|
||||
notices.append('Browser command failed.')
|
||||
snapshot = item.get('snapshot')
|
||||
if not isinstance(snapshot, str) or not snapshot.strip():
|
||||
snapshot = item.get('text')
|
||||
snapshot = item.get('snapshot') or item.get('text')
|
||||
if item.get('title'):
|
||||
notices.append('Title: ' + str(item['title']))
|
||||
url = item.get('url') or item.get('origin')
|
||||
title = item.get('title')
|
||||
if isinstance(title, str) and title.strip():
|
||||
notices.append('Title: ' + title.strip())
|
||||
if isinstance(snapshot, str) and snapshot.strip():
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
|
||||
+136
-9
@@ -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]:
|
||||
"""Background note-due scanner. Fires a reminder for any note whose
|
||||
`due_date` falls in the current ±5-minute window and hasn't been pinged
|
||||
within the last 25 minutes. Mirrors `action_ping_events` for calendar.
|
||||
`due_date` falls in the current ±90-second window and hasn't been delivered
|
||||
through its configured channel in the last 25 minutes.
|
||||
|
||||
State (`data/note_pings.json`): {note_id: iso_ts_of_last_ping}. Pruned
|
||||
Per-owner state: {note_id: {at, channel}}, with legacy timestamp support. Pruned
|
||||
on each run by dropping entries for notes that are gone/archived/replied.
|
||||
"""
|
||||
try:
|
||||
@@ -2309,6 +2309,11 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||||
from pathlib import Path as _P
|
||||
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
|
||||
# users' entries (review C4). Legacy path kept as fallback so a
|
||||
@@ -2373,20 +2378,27 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
due = _parse_due(n.due_date)
|
||||
if not due:
|
||||
continue
|
||||
# Inside the ±5min window?
|
||||
# Inside the due window?
|
||||
if abs((due - now).total_seconds()) > window.total_seconds():
|
||||
continue
|
||||
# Recently pinged? Skip.
|
||||
last = cache.get(n.id)
|
||||
browser_recent = False
|
||||
if last:
|
||||
try:
|
||||
last_channel = None
|
||||
if isinstance(last, dict):
|
||||
last_channel = last.get("channel")
|
||||
last = last.get("at")
|
||||
last_dt = _dt.fromisoformat(str(last))
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
||||
if last_dt >= reping_cutoff:
|
||||
continue
|
||||
if not external_channel or last_channel == channel:
|
||||
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:
|
||||
pass
|
||||
# Compose + dispatch.
|
||||
@@ -2410,15 +2422,26 @@ 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
|
||||
try:
|
||||
from routes.note_routes import dispatch_reminder
|
||||
await dispatch_reminder(
|
||||
result = await dispatch_reminder(
|
||||
title=title, note_body=body, note_id=n.id,
|
||||
owner=n.owner or owner or "",
|
||||
queue_browser=not browser_recent,
|
||||
)
|
||||
cache[n.id] = now.isoformat()
|
||||
sent.append(title)
|
||||
if result.get("skipped"):
|
||||
continue
|
||||
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:
|
||||
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.
|
||||
for stale in [k for k in cache if k not in seen_ids]:
|
||||
cache.pop(stale, None)
|
||||
@@ -2429,7 +2452,7 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
logger.warning(f"ping_notes: cache write failed: {e}")
|
||||
|
||||
if not sent:
|
||||
raise TaskNoop(f"scanned {len(notes)} note(s), none due in ±{WINDOW_SEC}s")
|
||||
raise TaskNoop(f"scanned {len(notes)} note(s), no reminders delivered")
|
||||
preview = "; ".join(sent[:3])
|
||||
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
|
||||
return f"Pinged {len(sent)} note(s): {preview}{extra}", True
|
||||
@@ -2442,6 +2465,100 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def _spec_triage_email_urgency(item: dict, *, triage_version: int) -> dict | None:
|
||||
"""Classify one email's urgency with the trained jeff ``triage`` adapter.
|
||||
|
||||
Returns an email-urgency verdict (same shape as the LLM path) or None when
|
||||
the per-job flag is off or the adapter is unavailable, so the caller falls
|
||||
through to the LLM candidate chain. Only the ``urgency`` question is sent:
|
||||
jeff scores each question in its own forward pass, and that is the question
|
||||
this adapter was trained on.
|
||||
"""
|
||||
from src.jeff_decision import jeff_job_enabled, jeff_systemone, score_value
|
||||
|
||||
if not jeff_job_enabled("EMAIL_URGENCY"):
|
||||
return None
|
||||
message = f"Subject: {item.get('subject','')}\n\n{item.get('body','')}".strip()
|
||||
if not message:
|
||||
return None
|
||||
answers = await jeff_systemone(
|
||||
"triage",
|
||||
{"channel": "email", "message": message[:8000]},
|
||||
{"urgency": {
|
||||
"type": "score",
|
||||
"instructions": "How urgently does this need a response?",
|
||||
"criteria": [
|
||||
"No action needed",
|
||||
"Can wait a few days",
|
||||
"Handle within a day",
|
||||
"Handle within hours",
|
||||
"Handle immediately",
|
||||
],
|
||||
}},
|
||||
)
|
||||
if not answers:
|
||||
return None
|
||||
exp = score_value(answers.get("urgency"))
|
||||
if exp is None:
|
||||
return None
|
||||
|
||||
# Adapter's 0-4 urgency scale -> the app's 0-3 score.
|
||||
score = max(0, min(3, int(round(exp))))
|
||||
reason = "triage adapter"
|
||||
|
||||
# Same high-precision correction the prompt path applies: a person waiting
|
||||
# in person is always urgent, whatever the model thinks.
|
||||
blob = f"{item.get('headers','')}\n{item.get('subject','')}\n{item.get('body','')}".lower()
|
||||
if re.search(r"\b(i'?m|i am|im|we'?re|we are)\s+outside\b", blob) or re.search(
|
||||
r"\b(waiting outside|at the door|locked out|can'?t get in|cannot get in)\b", blob
|
||||
):
|
||||
if score < 3:
|
||||
reason = "person is waiting outside"
|
||||
score = 3
|
||||
|
||||
# Same bulk/marketing guard the prompt path applies: a newsletter or
|
||||
# promotion is not urgent no matter how time-limited it sounds.
|
||||
bulkish = bool(re.search(
|
||||
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
|
||||
blob,
|
||||
))
|
||||
# The adapter was trained on support tickets, so it over-rates promotions.
|
||||
# Require a clear bulk header or several marketing terms before zeroing.
|
||||
marketing_hits = sum(1 for t in (
|
||||
"advertisement", "sponsored", "promo", "promotion", "sale", "discount",
|
||||
"offer", "limited time", "deal", "coupon", "shop now", "buy now",
|
||||
"sold out", "low tickets",
|
||||
) if t in blob)
|
||||
if bulkish or marketing_hits >= 2:
|
||||
score = 0
|
||||
reason = "bulk mail; no personal reply needed"
|
||||
|
||||
tags = []
|
||||
if score >= 3:
|
||||
tags.append("urgent")
|
||||
elif score >= 2:
|
||||
tags.append("reply-soon")
|
||||
|
||||
_from_raw = item.get("from", "") or ""
|
||||
if "<" in _from_raw:
|
||||
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
|
||||
else:
|
||||
_from_short = _from_raw
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"tags": tags[:4],
|
||||
"spam": False,
|
||||
"reason": reason,
|
||||
"subject": (item.get("subject") or "")[:200],
|
||||
"from": _from_short[:120],
|
||||
"triage_version": triage_version,
|
||||
"message_id": (item.get("message_id") or "").strip(),
|
||||
"unread": bool(item.get("unread")),
|
||||
"ts": time.time(),
|
||||
}
|
||||
|
||||
|
||||
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Scan unread emails across all accounts, LLM-triage new ones, cache
|
||||
per-UID verdicts, tag the inbox, and fire a reminder when a previously
|
||||
@@ -2829,6 +2946,16 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
per_uid_scores[key] = verdict
|
||||
saved_classifications += 1
|
||||
continue
|
||||
# ── Spec decision adapter (triage): a calibrated urgency score
|
||||
# with no generated-text prompt. Enabled per-job via
|
||||
# ODYSSEUS_JEFF_EMAIL_URGENCY; any failure returns None and the
|
||||
# LLM candidate chain below still runs.
|
||||
_tri = await _spec_triage_email_urgency(item, triage_version=TRIAGE_VERSION)
|
||||
if _tri is not None:
|
||||
cache.setdefault("uids", {})[item["uid"]] = _tri
|
||||
per_uid_scores[key] = _tri
|
||||
saved_classifications += 1
|
||||
continue
|
||||
# ── LLM-classify. JSON-only response; bullet-proof parse.
|
||||
llm_attempts += 1
|
||||
prompt = (
|
||||
|
||||
+90
-2
@@ -81,6 +81,16 @@ def is_vision_model(model_name: str) -> bool:
|
||||
drops the image entirely. See issue #124.
|
||||
"""
|
||||
m = (model_name or "").lower()
|
||||
if not m:
|
||||
return False
|
||||
# Per-model override (comma-separated substrings), e.g. a local model whose
|
||||
# name carries no vision/VL hint but answers images natively.
|
||||
override = os.getenv("ODYSSEUS_VISION_MODELS", "")
|
||||
if override:
|
||||
for token in override.split(","):
|
||||
token = token.strip().lower()
|
||||
if token and token in m:
|
||||
return True
|
||||
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
|
||||
return True
|
||||
return bool(_VISION_VL_RE.search(m))
|
||||
@@ -164,8 +174,10 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
|
||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||
"""Whether a model accepts images, using the endpoint's reported
|
||||
capability when available (LM Studio) and falling back to name-based
|
||||
detection otherwise."""
|
||||
capability when available (LM Studio), a cached one-time probe for local
|
||||
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||
if is_vision_model(model_name):
|
||||
return True
|
||||
if endpoint_url:
|
||||
try:
|
||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
||||
@@ -173,9 +185,85 @@ def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||
advertised = None
|
||||
if advertised is not None:
|
||||
return advertised
|
||||
try:
|
||||
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
||||
except Exception:
|
||||
probed = None
|
||||
if probed is not None:
|
||||
return probed
|
||||
return is_vision_model(model_name)
|
||||
|
||||
|
||||
# 1x1 red PNG — the cheapest possible image probe payload.
|
||||
_PROBE_IMAGE_DATA_URL = (
|
||||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
_VISION_PROBE_TTL = 3600.0
|
||||
# (host, model) -> (bool | None, expiry); None = undecidable (auth/network).
|
||||
_vision_probe_cache: dict = {}
|
||||
|
||||
|
||||
def _vision_probe_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_VISION_PROBE", "1").strip().lower() not in ("0", "false", "off", "no")
|
||||
|
||||
|
||||
def _chat_completions_url(endpoint_url: str) -> str:
|
||||
base = (endpoint_url or "").rstrip("/")
|
||||
if base.endswith("/chat/completions"):
|
||||
return base
|
||||
return base + "/chat/completions"
|
||||
|
||||
|
||||
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||
an image_url content part for `model_name`?
|
||||
|
||||
Returns True on a 200, False when the endpoint rejects the image (400/413/
|
||||
415/422), and None when we cannot decide (remote host, auth required,
|
||||
unreachable, or probing disabled) so the caller falls back to name rules.
|
||||
"""
|
||||
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||
return None
|
||||
parsed = urlparse(endpoint_url)
|
||||
if not _is_local_host(parsed.hostname):
|
||||
return None # never probe a public provider
|
||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||
now = time.time()
|
||||
cached = _vision_probe_cache.get(key)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"max_tokens": 1,
|
||||
"stream": False,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Reply with one word."},
|
||||
{"type": "image_url", "image_url": {"url": _PROBE_IMAGE_DATA_URL}},
|
||||
],
|
||||
}],
|
||||
}
|
||||
verdict: Optional[bool] = None
|
||||
try:
|
||||
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
||||
if r.status_code == 200:
|
||||
verdict = True
|
||||
elif r.status_code in (400, 413, 415, 422):
|
||||
body = (r.text or "").lower()
|
||||
if any(tok in body for tok in ("image", "vision", "multimodal", "content type")):
|
||||
verdict = False
|
||||
else:
|
||||
verdict = False
|
||||
except Exception:
|
||||
verdict = None
|
||||
_vision_probe_cache[key] = (verdict, now + _VISION_PROBE_TTL)
|
||||
if verdict is not None:
|
||||
logger.info("vision probe %s@%s -> %s", model_name, parsed.hostname, verdict)
|
||||
return verdict
|
||||
|
||||
|
||||
def validate_message(message: str) -> str:
|
||||
"""Validate message input."""
|
||||
if not message:
|
||||
|
||||
+863
-298
File diff suppressed because it is too large
Load Diff
+96
-35
@@ -37,6 +37,41 @@ def _apply_owner_filter(query, DbSession, owner: Optional[str]):
|
||||
return query.filter(DbSession.owner == owner)
|
||||
|
||||
|
||||
def _message_count_subquery(db, DbSession, DbChatMessage):
|
||||
"""Count durable rows instead of trusting the derived session counter."""
|
||||
from sqlalchemy import func
|
||||
return db.query(func.count(DbChatMessage.id)).filter(
|
||||
DbChatMessage.session_id == DbSession.id
|
||||
).correlate(DbSession).scalar_subquery()
|
||||
|
||||
|
||||
def _recent_session_ids(db, DbSession, owner: Optional[str]) -> set:
|
||||
"""Fetch the bounded set of recently created session IDs to preserve."""
|
||||
recent_q = db.query(DbSession.id).order_by(DbSession.created_at.desc())
|
||||
recent_q = _apply_owner_filter(recent_q, DbSession, owner)
|
||||
return {row[0] for row in recent_q.limit(CleanupConfig.PRESERVE_RECENT_COUNT).all()}
|
||||
|
||||
|
||||
def _supports_delete_returning(db) -> bool:
|
||||
"""Check if the current database backend and runtime version support DELETE ... RETURNING."""
|
||||
dialect = getattr(db.get_bind(), "dialect", None)
|
||||
if dialect is None:
|
||||
return False
|
||||
if dialect.name == "postgresql":
|
||||
return True
|
||||
if dialect.name == "sqlite":
|
||||
# SQLite added RETURNING support in 3.35.0 (March 2021).
|
||||
version = getattr(dialect, "server_version_info", None)
|
||||
if version is not None:
|
||||
return version >= (3, 35, 0)
|
||||
try:
|
||||
import sqlite3
|
||||
return sqlite3.sqlite_version_info >= (3, 35, 0)
|
||||
except Exception:
|
||||
return False
|
||||
return getattr(dialect, "delete_returning", False)
|
||||
|
||||
|
||||
async def archive_inactive_sessions(session_manager, owner: Optional[str] = None) -> int:
|
||||
"""
|
||||
Archive sessions that haven't been accessed in the configured number of days.
|
||||
@@ -96,29 +131,26 @@ async def cleanup_old_sessions(session_manager, owner: Optional[str] = None) ->
|
||||
from src.database import SessionLocal, Session as DbSession, ChatMessage as DbChatMessage
|
||||
db = SessionLocal()
|
||||
try:
|
||||
recent_q = db.query(DbSession).order_by(DbSession.created_at.desc())
|
||||
recent_q = _apply_owner_filter(recent_q, DbSession, owner)
|
||||
all_sessions = recent_q.all()
|
||||
recent_session_ids = {session.id for session in all_sessions[:CleanupConfig.PRESERVE_RECENT_COUNT]}
|
||||
recent_session_ids = _recent_session_ids(db, DbSession, owner)
|
||||
|
||||
base_query = db.query(DbSession).filter(
|
||||
DbSession.archived == True,
|
||||
DbSession.last_accessed < cutoff_date,
|
||||
DbSession.is_important == False,
|
||||
DbSession.message_count < CleanupConfig.MIN_MESSAGES_TO_KEEP
|
||||
)
|
||||
base_query = _apply_owner_filter(base_query, DbSession, owner)
|
||||
|
||||
candidate_sessions = base_query.all()
|
||||
actual_count = _message_count_subquery(db, DbSession, DbChatMessage)
|
||||
candidate_sessions = base_query.add_columns(actual_count).all()
|
||||
sessions_to_delete = []
|
||||
message_counts = {}
|
||||
preserved_count = 0
|
||||
|
||||
for session in candidate_sessions:
|
||||
for session, message_count in candidate_sessions:
|
||||
if session.id in recent_session_ids:
|
||||
preserved_count += 1
|
||||
continue
|
||||
|
||||
if session.message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
||||
if message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
||||
preserved_count += 1
|
||||
continue
|
||||
|
||||
@@ -128,20 +160,52 @@ async def cleanup_old_sessions(session_manager, owner: Optional[str] = None) ->
|
||||
continue
|
||||
|
||||
sessions_to_delete.append(session)
|
||||
|
||||
for session in sessions_to_delete:
|
||||
message_count = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session.id
|
||||
).count()
|
||||
space_freed += message_count * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||
message_counts[session.id] = message_count
|
||||
|
||||
session_ids = [session.id for session in sessions_to_delete]
|
||||
if session_ids:
|
||||
db.query(DbSession).filter(DbSession.id.in_(session_ids)).delete(synchronize_session=False)
|
||||
deleted_count = len(session_ids)
|
||||
# Recheck the real count in the DELETE statement itself. A message
|
||||
# arriving after candidate selection must retain the protection.
|
||||
# Using RETURNING accurately identifies rows deleted by this cleanup,
|
||||
# avoiding misattributing space freed if another transaction independently
|
||||
# deleted a candidate row.
|
||||
from sqlalchemy import delete
|
||||
stmt = delete(DbSession).filter(
|
||||
DbSession.id.in_(session_ids),
|
||||
DbSession.archived == True,
|
||||
DbSession.last_accessed < cutoff_date,
|
||||
DbSession.is_important == False,
|
||||
actual_count < CleanupConfig.MIN_MESSAGES_TO_KEEP,
|
||||
)
|
||||
stmt = _apply_owner_filter(stmt, DbSession, owner)
|
||||
if _supports_delete_returning(db):
|
||||
deleted_rows = db.execute(stmt.returning(DbSession.id)).scalars().all()
|
||||
deleted_ids = set(deleted_rows)
|
||||
deleted_count = len(deleted_ids)
|
||||
surviving_ids = {row[0] for row in db.query(DbSession.id).filter(
|
||||
DbSession.id.in_(session_ids)
|
||||
).all()}
|
||||
unaccounted_ids = set(session_ids) - surviving_ids - deleted_ids
|
||||
space_freed = sum(message_counts[sid] for sid in deleted_ids) * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||
else:
|
||||
# Portable fallback for legacy SQLite (< 3.35.0) which lacks RETURNING syntax.
|
||||
res = db.execute(stmt)
|
||||
deleted_count = res.rowcount or 0
|
||||
surviving_ids = {row[0] for row in db.query(DbSession.id).filter(
|
||||
DbSession.id.in_(session_ids)
|
||||
).all()}
|
||||
deleted_ids = set(session_ids) - surviving_ids
|
||||
unaccounted_ids = set()
|
||||
if deleted_count == 0:
|
||||
space_freed = 0
|
||||
elif len(deleted_ids) <= deleted_count:
|
||||
space_freed = sum(message_counts[sid] for sid in deleted_ids) * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||
else:
|
||||
space_freed = sum(message_counts[sid] for sid in sorted(deleted_ids)[:deleted_count]) * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||
db.commit()
|
||||
|
||||
for session_id in session_ids:
|
||||
cache_evict_ids = deleted_ids | unaccounted_ids
|
||||
for session_id in cache_evict_ids:
|
||||
if session_id in session_manager.sessions:
|
||||
del session_manager.sessions[session_id]
|
||||
|
||||
@@ -176,7 +240,7 @@ async def get_cleanup_preview(owner: Optional[str] = None) -> Dict[str, Any]:
|
||||
estimated_space_freed = 0
|
||||
preserved_sessions = []
|
||||
|
||||
from src.database import SessionLocal, Session as DbSession
|
||||
from src.database import SessionLocal, Session as DbSession, ChatMessage as DbChatMessage
|
||||
db = SessionLocal()
|
||||
try:
|
||||
archive_q = db.query(DbSession).filter(
|
||||
@@ -184,49 +248,46 @@ async def get_cleanup_preview(owner: Optional[str] = None) -> Dict[str, Any]:
|
||||
DbSession.archived == False
|
||||
)
|
||||
archive_q = _apply_owner_filter(archive_q, DbSession, owner)
|
||||
archive_candidates = archive_q.all()
|
||||
actual_count = _message_count_subquery(db, DbSession, DbChatMessage)
|
||||
archive_candidates = archive_q.add_columns(actual_count).all()
|
||||
|
||||
for session in archive_candidates:
|
||||
for session, message_count in archive_candidates:
|
||||
sessions_to_archive.append({
|
||||
"id": session.id,
|
||||
"name": session.name,
|
||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||
"message_count": session.message_count
|
||||
"message_count": message_count
|
||||
})
|
||||
|
||||
recent_q = db.query(DbSession).order_by(DbSession.created_at.desc())
|
||||
recent_q = _apply_owner_filter(recent_q, DbSession, owner)
|
||||
all_sessions = recent_q.all()
|
||||
recent_session_ids = {session.id for session in all_sessions[:CleanupConfig.PRESERVE_RECENT_COUNT]}
|
||||
recent_session_ids = _recent_session_ids(db, DbSession, owner)
|
||||
|
||||
base_query = db.query(DbSession).filter(
|
||||
DbSession.archived == True,
|
||||
DbSession.last_accessed < cutoff_delete,
|
||||
DbSession.is_important == False,
|
||||
DbSession.message_count < CleanupConfig.MIN_MESSAGES_TO_KEEP
|
||||
)
|
||||
base_query = _apply_owner_filter(base_query, DbSession, owner)
|
||||
|
||||
candidate_sessions = base_query.all()
|
||||
candidate_sessions = base_query.add_columns(actual_count).all()
|
||||
|
||||
for session in candidate_sessions:
|
||||
for session, message_count in candidate_sessions:
|
||||
if session.id in recent_session_ids:
|
||||
preserved_sessions.append({
|
||||
"id": session.id,
|
||||
"name": session.name,
|
||||
"reason": f"part of last {CleanupConfig.PRESERVE_RECENT_COUNT} sessions",
|
||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||
"message_count": session.message_count
|
||||
"message_count": message_count
|
||||
})
|
||||
continue
|
||||
|
||||
if session.message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
||||
if message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
||||
preserved_sessions.append({
|
||||
"id": session.id,
|
||||
"name": session.name,
|
||||
"reason": f"has {CleanupConfig.MIN_MESSAGES_TO_KEEP}+ messages",
|
||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||
"message_count": session.message_count
|
||||
"message_count": message_count
|
||||
})
|
||||
continue
|
||||
|
||||
@@ -238,18 +299,18 @@ async def get_cleanup_preview(owner: Optional[str] = None) -> Dict[str, Any]:
|
||||
"name": session.name,
|
||||
"reason": f"contains keyword: {matching_keywords[0]}",
|
||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||
"message_count": session.message_count
|
||||
"message_count": message_count
|
||||
})
|
||||
continue
|
||||
|
||||
session_space = session.message_count * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||
session_space = message_count * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||
estimated_space_freed += session_space
|
||||
|
||||
sessions_to_delete.append({
|
||||
"id": session.id,
|
||||
"name": session.name,
|
||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||
"message_count": session.message_count,
|
||||
"message_count": message_count,
|
||||
"estimated_size_kb": round(session_space / 1024, 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
|
||||
from src.model_context import estimate_text_tokens, get_context_length, estimate_tokens
|
||||
from src.llm_core import llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.utility_effort import effort_for_call
|
||||
from src.settings import get_setting
|
||||
from core.models import ChatMessage
|
||||
|
||||
@@ -91,6 +92,83 @@ def prune_multimodal_images(
|
||||
return pruned
|
||||
|
||||
|
||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _image_turn_label(message: Dict) -> str:
|
||||
"""Best-effort '[Image: name]' label for a message whose image parts are dropped."""
|
||||
name = ""
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
|
||||
if candidate:
|
||||
name = str(candidate)
|
||||
break
|
||||
if not name:
|
||||
text = _content_as_text(content)
|
||||
match = _IMAGE_TURN_LABEL_RE.search(text)
|
||||
if match:
|
||||
name = match.group(1).strip()
|
||||
return f"[Image: {name}]" if name else "[Image]"
|
||||
|
||||
|
||||
def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict]:
|
||||
"""Keep image parts only for the last `keep` messages that carry an image.
|
||||
|
||||
Older image blocks are dropped and replaced with a short ``[Image: name]``
|
||||
text label so the model still knows a picture was shared (and its name)
|
||||
without paying the image tokens on every later turn. Non-text, non-image
|
||||
multimodal blocks (e.g. audio) are left untouched.
|
||||
"""
|
||||
limit = max(0, int(keep))
|
||||
image_messages = [
|
||||
index for index, message in enumerate(messages)
|
||||
if isinstance(message, dict)
|
||||
and isinstance(message.get("content"), list)
|
||||
and any(
|
||||
isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES
|
||||
for item in message["content"]
|
||||
)
|
||||
]
|
||||
if len(image_messages) <= limit:
|
||||
return list(messages)
|
||||
selected = set(image_messages[len(image_messages) - limit:]) if limit else set()
|
||||
|
||||
trimmed: List[Dict] = []
|
||||
for message_index, message in enumerate(messages):
|
||||
if message_index in selected or not isinstance(message, dict):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
kept_blocks = [
|
||||
item for item in content
|
||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||
]
|
||||
if len(kept_blocks) == len(content):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
label = _image_turn_label(message)
|
||||
# Attach the label to the trailing text block when possible so we do not
|
||||
# introduce an extra content block; otherwise append a fresh text block.
|
||||
labelled = False
|
||||
for item in reversed(kept_blocks):
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
item["text"] = (item.get("text") or "").rstrip() + f"\n{label}"
|
||||
labelled = True
|
||||
break
|
||||
if not labelled:
|
||||
kept_blocks.append({"type": "text", "text": label})
|
||||
cloned = dict(message)
|
||||
cloned["content"] = kept_blocks
|
||||
trimmed.append(cloned)
|
||||
return trimmed
|
||||
|
||||
|
||||
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
|
||||
SUMMARY_MAX_TOKENS = 1024
|
||||
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
|
||||
@@ -556,10 +634,8 @@ async def maybe_compact(
|
||||
)
|
||||
|
||||
# Use utility model if configured, otherwise fall back to session model
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner)
|
||||
compact_url = util_url or endpoint_url
|
||||
compact_model = util_model or model
|
||||
compact_headers = util_headers if util_url else headers
|
||||
# Compact with the chat model itself (the one being evaluated), never a separate utility model.
|
||||
compact_url, compact_model, compact_headers = endpoint_url, model, headers
|
||||
|
||||
prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace(
|
||||
"{count}", str(len(older))
|
||||
@@ -580,6 +656,7 @@ async def maybe_compact(
|
||||
max_tokens=SUMMARY_MAX_TOKENS,
|
||||
headers=compact_headers,
|
||||
timeout=30,
|
||||
reasoning_effort=effort_for_call(compact_url, compact_model, owner, session),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Compaction summary failed: {e}")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
|
||||
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import base64
|
||||
@@ -496,6 +497,49 @@ def analyze_image_with_vl(image_path: str, owner: str | None = None) -> str:
|
||||
return analyze_image_with_vl_result(image_path, owner=owner).get("text", "")
|
||||
|
||||
|
||||
def _vision_max_side() -> int:
|
||||
"""Longest-side cap for images inlined into the chat request (tokens)."""
|
||||
try:
|
||||
value = int(os.getenv("ODYSSEUS_VISION_MAX_SIDE", "1280") or 1280)
|
||||
except (TypeError, ValueError):
|
||||
value = 1280
|
||||
return max(0, value)
|
||||
|
||||
|
||||
def encode_image_data_url(path: str, fallback_format: str) -> tuple[str, str]:
|
||||
"""Return (data_url, image_format) for an image, downscaled if large.
|
||||
|
||||
Downscale the longest side to ODYSSEUS_VISION_MAX_SIDE (default 1280) and
|
||||
re-encode as JPEG so the chat model does not pay full-resolution image
|
||||
tokens. Small/undecodable images fall back to the original bytes so a
|
||||
real attachment is never dropped.
|
||||
"""
|
||||
max_side = _vision_max_side()
|
||||
try:
|
||||
from PIL import Image # local import: keep module import cost low
|
||||
with Image.open(path) as img:
|
||||
img = img.convert("RGB") if img.mode not in ("RGB", "L") else img
|
||||
width, height = img.size
|
||||
if max_side and max(width, height) > max_side:
|
||||
scale = max_side / float(max(width, height))
|
||||
img = img.resize(
|
||||
(max(1, round(width * scale)), max(1, round(height * scale))),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=85, optimize=True)
|
||||
return (
|
||||
"data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("utf-8"),
|
||||
"jpeg",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Image downscale failed for {path}: {exc}; sending original bytes")
|
||||
with open(path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
||||
image_format = (fallback_format or "png").lower()
|
||||
return f"data:image/{image_format};base64,{encoded_string}", image_format
|
||||
|
||||
|
||||
def build_user_content(
|
||||
text: str,
|
||||
attachment_ids: list[str] | None,
|
||||
@@ -542,15 +586,14 @@ def build_user_content(
|
||||
|
||||
if upload_handler.is_image_file(display_name, mime):
|
||||
try:
|
||||
with open(path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
||||
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
|
||||
# so fall back to the resolved MIME subtype rather than emitting
|
||||
# an invalid "data:image/;base64," with an empty subtype.
|
||||
image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
|
||||
fallback_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
|
||||
data_url, image_format = encode_image_data_url(path, fallback_format)
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
|
||||
"image_url": {"url": data_url},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode image {fid}: {e}")
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Editor agent/planner model resolution.
|
||||
|
||||
The image-editor Quick Edit planner (`/api/editor/plan`, `/api/editor/agent`)
|
||||
runs on an explicitly configured local vision model when one is reachable,
|
||||
with the configured chat model as a fallback. This module owns:
|
||||
|
||||
* the local model base URLs / model id (settings-overridable),
|
||||
* an ordered candidate list (per-request ``agent_model`` override, then the
|
||||
local Qwen3.5-9B, then the hosted default/utility chat model),
|
||||
* a vision fallback chain: local model WITH the canvas screenshot, else a
|
||||
text-only call whose image is described by a separate VL caption model,
|
||||
* a lightweight reachability probe for the configured local bases.
|
||||
|
||||
Everything is best-effort: when no local server is reachable the planner
|
||||
transparently falls back to the previously-used hosted chat model, so the
|
||||
editor agent never regresses to "no plan".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Local endpoints are opt-in through settings or ODYSSEUS_EDITOR_AGENT_BASES.
|
||||
DEFAULT_LOCAL_BASES = []
|
||||
DEFAULT_LOCAL_MODEL = "Qwen/Qwen3.5-9B"
|
||||
|
||||
# Vision caption model used when the editor-agent model cannot accept images.
|
||||
DEFAULT_CAPTION_BASE = ""
|
||||
DEFAULT_CAPTION_MODEL = "Qwen/Qwen3.5-9B"
|
||||
|
||||
# Served-model-id cache per base (populated on first use). The local servers 400
|
||||
# with "Failed to load model" when asked for an id they do not serve (e.g. the
|
||||
# client's default "Qwen3.5-9B-MLX-8bit", or a stale caption model), so we
|
||||
# resolve the requested id against GET {base}/models and fall back to one that
|
||||
# is actually served.
|
||||
_MODELS_CACHE: Dict[str, set] = {}
|
||||
|
||||
_CAPTION_INSTRUCTION = (
|
||||
"Describe this image in one or two sentences for a photo editor: the main "
|
||||
"subject, the background/scene, lighting, dominant colours, and any obvious "
|
||||
"flaws (blown highlights, colour cast, clutter, blur)."
|
||||
)
|
||||
|
||||
|
||||
def _setting(key: str, default: Any = "") -> Any:
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
|
||||
value = load_settings().get(key)
|
||||
return value if value not in (None, "") else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def local_bases() -> List[str]:
|
||||
override = _setting("editor_agent_base_url")
|
||||
if override:
|
||||
return [str(override)]
|
||||
env = os.getenv("ODYSSEUS_EDITOR_AGENT_BASES")
|
||||
if env:
|
||||
bases = [b.strip() for b in env.split(",") if b.strip()]
|
||||
if bases:
|
||||
return bases
|
||||
return list(DEFAULT_LOCAL_BASES)
|
||||
|
||||
|
||||
def local_model() -> str:
|
||||
return str(_setting("editor_agent_model", DEFAULT_LOCAL_MODEL))
|
||||
|
||||
|
||||
def caption_base() -> str:
|
||||
return str(_setting("editor_caption_base_url", DEFAULT_CAPTION_BASE))
|
||||
|
||||
|
||||
def caption_model() -> str:
|
||||
return str(_setting("editor_caption_model", DEFAULT_CAPTION_MODEL))
|
||||
|
||||
|
||||
# Per-request / per-plan context (contextvars keep this correct under the
|
||||
# async event loop without sharing state between concurrent editor turns).
|
||||
_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
||||
"editor_agent_model_override", default=None
|
||||
)
|
||||
_last_model: contextvars.ContextVar[Optional[Tuple[str, str]]] = contextvars.ContextVar(
|
||||
"editor_agent_last_model", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_override(value: Optional[str]) -> None:
|
||||
_override.set(value or None)
|
||||
|
||||
|
||||
def get_override() -> Optional[str]:
|
||||
return _override.get()
|
||||
|
||||
|
||||
def set_last_model(model: str, source: str) -> None:
|
||||
_last_model.set((model, source))
|
||||
|
||||
|
||||
def get_last_model() -> Optional[Tuple[str, str]]:
|
||||
return _last_model.get()
|
||||
|
||||
|
||||
def _chat_url(base: str) -> str:
|
||||
b = str(base or "").rstrip("/")
|
||||
if b.endswith("/chat/completions"):
|
||||
return b
|
||||
return b + "/chat/completions"
|
||||
|
||||
|
||||
def _split_override(override: str) -> Tuple[str, str]:
|
||||
"""Parse ``base::model`` (or a bare model id)."""
|
||||
raw = str(override or "").strip()
|
||||
if "::" in raw:
|
||||
base, model = raw.split("::", 1)
|
||||
return base.strip(), model.strip()
|
||||
return "", raw
|
||||
|
||||
|
||||
def _has_image(messages: List[dict]) -> bool:
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _first_image(messages: List[dict]) -> Optional[str]:
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
url = (part.get("image_url") or {}).get("url")
|
||||
if isinstance(url, str):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _strip_images(messages: List[dict]) -> List[dict]:
|
||||
out: List[dict] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
text = "\n".join(
|
||||
str(part.get("text") or "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
)
|
||||
out.append({"role": msg.get("role", "user"), "content": text})
|
||||
else:
|
||||
out.append({"role": msg.get("role", "user"), "content": content})
|
||||
return out
|
||||
|
||||
|
||||
def _with_caption(messages: List[dict], caption: str) -> List[dict]:
|
||||
note = f"\n\n[Canvas screenshot caption: {caption}]"
|
||||
out = [dict(m) for m in messages]
|
||||
for msg in reversed(out):
|
||||
if msg.get("role") == "user":
|
||||
msg["content"] = str(msg.get("content") or "") + note
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
async def _post_chat(
|
||||
base: str,
|
||||
model: str,
|
||||
messages: List[dict],
|
||||
headers: Optional[dict],
|
||||
timeout: float,
|
||||
max_tokens: int = 1024,
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
if not base or not model:
|
||||
return None
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
# Constrain the planner to a strict JSON ops schema (structured outputs /
|
||||
# grammar) so the model can only emit valid editor ops. Unknown servers
|
||||
# ignore the field; the server-side schema validation is the real enforcer.
|
||||
if isinstance(response_format, dict):
|
||||
payload["response_format"] = response_format
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(_chat_url(base), json=payload, headers=headers or {})
|
||||
if resp.status_code != 200:
|
||||
logger.debug(
|
||||
"editor agent chat %s -> %s (%s)", base, resp.status_code, resp.text[:200]
|
||||
)
|
||||
return None
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.debug("editor agent chat %s failed: %s", base, exc)
|
||||
return None
|
||||
try:
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _served_models(base: str, timeout: float = 3.0) -> set:
|
||||
"""Return the set of model ids a base serves (cached; empty on failure)."""
|
||||
if not base:
|
||||
return set()
|
||||
cached = _MODELS_CACHE.get(base)
|
||||
if cached is not None:
|
||||
return cached
|
||||
ids: set = set()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(base.rstrip("/") + "/models")
|
||||
if resp.status_code == 200:
|
||||
ids = {
|
||||
str(m.get("id"))
|
||||
for m in (resp.json().get("data") or [])
|
||||
if isinstance(m, dict) and m.get("id")
|
||||
}
|
||||
except Exception:
|
||||
ids = set()
|
||||
_MODELS_CACHE[base] = ids
|
||||
return ids
|
||||
|
||||
|
||||
async def _resolve_model(base: str, requested: str, fallback: str) -> str:
|
||||
"""Return a model id ``base`` actually serves.
|
||||
|
||||
If the requested id is served, keep it; otherwise use ``fallback`` (when
|
||||
served) or any served id. When the served list is unknown (server down),
|
||||
keep the requested id (or ``fallback``).
|
||||
"""
|
||||
requested = str(requested or "").strip()
|
||||
fallback = str(fallback or "").strip()
|
||||
ids = await _served_models(base)
|
||||
if ids:
|
||||
if requested and requested in ids:
|
||||
return requested
|
||||
if fallback in ids:
|
||||
return fallback
|
||||
return sorted(ids)[0]
|
||||
return requested or fallback
|
||||
|
||||
|
||||
async def caption_image(data_url: str, owner: Optional[str] = None, timeout: float = 90.0) -> Optional[str]:
|
||||
if not isinstance(data_url, str) or not data_url.startswith("data:image/"):
|
||||
return None
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": _CAPTION_INSTRUCTION},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
base = caption_base()
|
||||
if not base:
|
||||
return None
|
||||
model = await _resolve_model(base, caption_model(), local_model())
|
||||
return await _post_chat(base, model, messages, None, timeout, max_tokens=200)
|
||||
|
||||
|
||||
_DONE_RE = re.compile(r"^\s*DONE\b", re.I)
|
||||
_RETRY_RE = re.compile(r"RETRY:?\s*(.+)", re.I | re.S)
|
||||
|
||||
|
||||
def _parse_self_check(text: str) -> Dict[str, Any]:
|
||||
s = str(text or "").strip()
|
||||
if _DONE_RE.match(s):
|
||||
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||
m = _RETRY_RE.search(s)
|
||||
if m or re.search(r"\bretry\b", s, re.I):
|
||||
hint = (m.group(1).strip() if m else s) or s
|
||||
return {"done": False, "issues": [hint], "retry_hint": hint, "text": s}
|
||||
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||
|
||||
|
||||
async def self_check(
|
||||
before: Any,
|
||||
after: Any,
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Same-origin VL self-check: compare BEFORE vs AFTER against the intent.
|
||||
|
||||
The browser cannot call the local VL server directly (it sends no CORS
|
||||
headers), so ``/api/editor/self-check`` proxies here. Returns
|
||||
``{done, issues, retry_hint, text}``; on any failure (bad input, the model
|
||||
is unreachable, or a timeout) it degrades to ``done=True`` with
|
||||
``reason="unavailable"`` so the client never gets stuck retrying.
|
||||
"""
|
||||
if not (
|
||||
isinstance(before, str) and before.startswith("data:image/")
|
||||
and isinstance(after, str) and after.startswith("data:image/")
|
||||
):
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
bases = local_bases() or list(DEFAULT_LOCAL_BASES)
|
||||
if not bases:
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
base = bases[0]
|
||||
mdl = await _resolve_model(base, str(model or local_model()), local_model())
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": str(prompt or "")},
|
||||
{"type": "image_url", "image_url": {"url": before}},
|
||||
{"type": "image_url", "image_url": {"url": after}},
|
||||
],
|
||||
}]
|
||||
text = await _post_chat(base, mdl, messages, None, timeout, max_tokens=140)
|
||||
if not text:
|
||||
return {"done": True, "issues": [], "retry_hint": "",
|
||||
"reason": "unavailable", "text": "DONE"}
|
||||
out = _parse_self_check(text)
|
||||
out["model"] = mdl
|
||||
out["source"] = "local"
|
||||
return out
|
||||
|
||||
|
||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||
cands: List[Dict[str, Any]] = []
|
||||
if override:
|
||||
base, model = _split_override(override)
|
||||
if model:
|
||||
cands.append(
|
||||
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
|
||||
)
|
||||
for base in local_bases():
|
||||
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model:
|
||||
cands.append({"base": url, "model": model, "headers": headers, "source": "hosted"})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return cands
|
||||
|
||||
|
||||
async def plan_with_editor_agent(
|
||||
messages: List[dict],
|
||||
owner: Optional[str] = None,
|
||||
override: Optional[str] = None,
|
||||
timeout: float = 90.0,
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[Tuple[str, str, str]]:
|
||||
"""Return (raw_plan_text, model_used, source) or None.
|
||||
|
||||
Tries the local model WITH the screenshot, then the same model text-only
|
||||
with a VL caption of the screenshot, then the hosted chat model.
|
||||
|
||||
``response_format`` (a JSON-schema structured-output constraint) is sent
|
||||
ONLY to the local editor-agent bases, so a hosted fallback model that does
|
||||
not support the field never rejects the request.
|
||||
"""
|
||||
has_img = _has_image(messages)
|
||||
text_messages = _strip_images(messages) if has_img else messages
|
||||
image = _first_image(messages) if has_img else None
|
||||
caption: Optional[str] = None
|
||||
_local = {str(b).rstrip("/") for b in local_bases()}
|
||||
for cand in _candidates(owner, override):
|
||||
base, model = cand["base"], cand["model"]
|
||||
headers = cand.get("headers")
|
||||
is_local = cand["source"] == "local" or str(base).rstrip("/") in _local
|
||||
rf = response_format if (response_format and is_local) else None
|
||||
extra = {"response_format": rf} if rf else {}
|
||||
if has_img:
|
||||
raw = await _post_chat(base, model, messages, headers, timeout, **extra)
|
||||
if raw:
|
||||
return raw, model, cand["source"]
|
||||
if caption is None and image:
|
||||
caption = await caption_image(image, owner)
|
||||
if caption:
|
||||
raw = await _post_chat(
|
||||
base, model, _with_caption(text_messages, caption), headers, timeout, **extra,
|
||||
)
|
||||
if raw:
|
||||
return raw, model, cand["source"] + "+caption"
|
||||
else:
|
||||
raw = await _post_chat(
|
||||
base, model, text_messages, headers, timeout, **extra
|
||||
)
|
||||
if raw:
|
||||
return raw, model, cand["source"]
|
||||
return None
|
||||
|
||||
|
||||
async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Models available to the editor agent, for the model picker.
|
||||
|
||||
Lists the local Qwen3.5-9B (one entry per reachable base) plus the hosted
|
||||
default/utility chat model that the planner falls back to.
|
||||
"""
|
||||
models: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
for base in local_bases():
|
||||
model = local_model()
|
||||
key = ("local", base, model)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
models.append({
|
||||
"id": model,
|
||||
"label": f"{model} · local",
|
||||
"source": "local",
|
||||
"vision": True,
|
||||
"base_url": base,
|
||||
})
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
for prefix in ("utility", "default"):
|
||||
try:
|
||||
url, model, _headers = resolve_endpoint(prefix, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
if url and model:
|
||||
key = ("hosted", model)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
models.append({
|
||||
"id": model,
|
||||
"label": model,
|
||||
"source": "hosted",
|
||||
"vision": None,
|
||||
"base_url": url,
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return models
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reachability probe (best effort, non-blocking).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROBE: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def probe_models(base: str, timeout: float = 3.0) -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.get(str(base).rstrip("/") + "/models")
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _probe_worker() -> None:
|
||||
for base in local_bases():
|
||||
ok = probe_models(base)
|
||||
_PROBE[base] = ok
|
||||
logger.info("editor agent local base %s reachable=%s", base, ok)
|
||||
cap = caption_base()
|
||||
if cap:
|
||||
_PROBE[cap] = probe_models(cap)
|
||||
logger.info("editor agent caption base %s reachable=%s", cap, _PROBE[cap])
|
||||
|
||||
|
||||
def startup_probe() -> None:
|
||||
"""Kick off a background reachability probe (never blocks startup)."""
|
||||
try:
|
||||
threading.Thread(target=_probe_worker, name="editor-agent-probe", daemon=True).start()
|
||||
except Exception:
|
||||
logger.debug("editor agent probe thread failed", exc_info=True)
|
||||
|
||||
|
||||
def probe_status() -> Dict[str, bool]:
|
||||
return dict(_PROBE)
|
||||
File diff suppressed because it is too large
Load Diff
+113
-6
@@ -2,7 +2,8 @@
|
||||
import json
|
||||
import time
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,7 +20,7 @@ _DEPENDENCIES = {
|
||||
'web': {'web_search', 'web_fetch', 'private_browser'},
|
||||
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
|
||||
'download_attachment'},
|
||||
'contacts': {'resolve_contact'},
|
||||
'contacts': {'resolve_contact', 'manage_contact'},
|
||||
'documents': {'search_documents', 'read_document'},
|
||||
}
|
||||
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
|
||||
@@ -116,8 +117,9 @@ def parse_email_task_intent(value):
|
||||
|
||||
def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
if intent.operation == 'read' and intent.dependencies:
|
||||
allowed = set().union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
||||
if active_editor:
|
||||
allowed = {tool for dependency in intent.dependencies
|
||||
for tool in _DEPENDENCIES[dependency]}
|
||||
if active_editor and intent.dependencies:
|
||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||
if intent.needs_clarification:
|
||||
allowed.add('ask_user')
|
||||
@@ -126,6 +128,9 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
if intent.operation not in {'draft', 'revise'}:
|
||||
return list(schemas)
|
||||
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
||||
# manage_contact is a read-only runtime source for lookups, not a
|
||||
# composition tool. Drafts still resolve recipients through resolve_contact.
|
||||
allowed.discard('manage_contact')
|
||||
if active_editor:
|
||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||
if not intent.needs_clarification:
|
||||
@@ -145,6 +150,62 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
# as though it were the whole task. This byte budget is deliberately conservative.
|
||||
CLASSIFIER_CONTEXT_BYTES = 24000
|
||||
|
||||
# The model can produce a structurally valid classification while forgetting
|
||||
# the source or mistaking a list of message headers for a request for bodies.
|
||||
_OWN_MAILBOX = re.compile(
|
||||
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails?|mail|inbox|mailbox)\b', re.IGNORECASE,
|
||||
)
|
||||
_MAILBOX_HEADER_LIST = re.compile(
|
||||
r'^\s*(?:list|show|display|what(?:\'s| are))\s+(?:me\s+)?(?:my|our)\s+'
|
||||
r'(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails|messages)\s*'
|
||||
r'(?:with\s+(?:(?:sender|subject|date|time|recipient|status)\s*'
|
||||
r'(?:(?:,|and)\s*)?)+)?[?.!]*\s*$', re.IGNORECASE,
|
||||
)
|
||||
_EMAIL_HEADER_FIELDS = re.compile(r'\b(?:subjects?|senders?|dates?)\b', re.IGNORECASE)
|
||||
_EMAIL_HEADER_FOLLOWUP = re.compile(r'\b(?:those|these|them|their|same)\b', re.IGNORECASE)
|
||||
_READ_REQUEST = re.compile(r'^\s*(?:please\s+)?(?:show|list|display|find|search|read|what|how many|are there|do i have)\b', re.IGNORECASE)
|
||||
_EMAIL_COUNT = re.compile(r'^\s*how many\s+(?:(?:unread|read|new)\s+)?(?:e-?mails|messages)\b',
|
||||
re.IGNORECASE)
|
||||
_EMAIL_BODY_QUERY = re.compile(
|
||||
r'\b(?:mention(?:s|ed|ing)?|body|contents?|amount|owe|says?|said|when|where|why|'
|
||||
r'starts?|deadline|attachments?)\b|\bhow much\b|\bwhat time\b',
|
||||
re.IGNORECASE)
|
||||
_CONTACT_EMAIL_FIELD = re.compile(
|
||||
r'\bcontacts?\b[^.!?\n]{0,100}\bemail\b', re.IGNORECASE,
|
||||
)
|
||||
_MAILBOX_SOURCE = re.compile(
|
||||
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails?|inbox|mailbox)\b|\b(?:inbox|mailbox|email\s+history)\b|'
|
||||
r'\b(?:e-?mails?|messages?)\s+(?:from|to|sent|received)\b|'
|
||||
r'\bemailed?\s+(?:me|us)\b', re.IGNORECASE,
|
||||
)
|
||||
|
||||
EMAIL_TASK_RESPONSE_FORMAT = {
|
||||
'type': 'json_schema',
|
||||
'json_schema': {
|
||||
'name': 'email_task_intent',
|
||||
'strict': True,
|
||||
'schema': {
|
||||
'type': 'object',
|
||||
'additionalProperties': False,
|
||||
'properties': {
|
||||
'operation': {'type': 'string', 'enum': ['draft', 'revise', 'read', 'send', 'other']},
|
||||
'requires_content': {'type': 'boolean'},
|
||||
'dependencies': {'type': 'array', 'items': {
|
||||
'type': 'string', 'enum': list(_DEPENDENCIES),
|
||||
}},
|
||||
'destination': {'type': 'string', 'enum': ['chat', 'mailbox']},
|
||||
'needs_clarification': {'type': 'boolean'},
|
||||
'summary': {'type': 'string'},
|
||||
},
|
||||
'required': ['operation', 'requires_content', 'dependencies', 'destination',
|
||||
'needs_clarification', 'summary'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
supplied_context=None, accounting=None):
|
||||
@@ -152,6 +213,8 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
# text from multimodal messages, so an attached image cannot hide the latest
|
||||
# instruction and leave us classifying an earlier task instead.
|
||||
dialogue = []
|
||||
latest_user_text = ''
|
||||
prior_user_text = ''
|
||||
for row in history:
|
||||
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
|
||||
continue
|
||||
@@ -166,6 +229,9 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
and isinstance(block.get('text'), str))
|
||||
if isinstance(content, str):
|
||||
dialogue.append({'role': row['role'], 'content': content})
|
||||
if row['role'] == 'user':
|
||||
prior_user_text = latest_user_text
|
||||
latest_user_text = content
|
||||
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
|
||||
ensure_ascii=False)
|
||||
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
|
||||
@@ -174,7 +240,7 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
|
||||
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
|
||||
'chat_template_kwargs': {'enable_thinking': False},
|
||||
'response_format': {'type': 'json_object'},
|
||||
'response_format': EMAIL_TASK_RESPONSE_FORMAT,
|
||||
'messages': [{'role': 'system', 'content': (
|
||||
'Classify the current conversational task. Return JSON only with operation '
|
||||
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
|
||||
@@ -250,6 +316,47 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
'response_time': round(time.monotonic() - started, 3),
|
||||
})
|
||||
try:
|
||||
return parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
||||
intent = parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
||||
if intent.operation == 'read' and not intent.needs_clarification:
|
||||
# In a contact-record question, "email" can name an address field
|
||||
# rather than the mailbox as a source. Keep both dependencies when
|
||||
# the request also names a mailbox/history source.
|
||||
contact_email_field = bool(
|
||||
_CONTACT_EMAIL_FIELD.search(latest_user_text)
|
||||
and not _MAILBOX_SOURCE.search(latest_user_text)
|
||||
)
|
||||
if contact_email_field:
|
||||
intent = replace(intent, dependencies=tuple(dict.fromkeys(
|
||||
(*[d for d in intent.dependencies if d != 'email'], 'contacts')
|
||||
)))
|
||||
# Reuse the product's existing source classifier rather than a
|
||||
# second vocabulary of mailbox phrases. The classifier has a known
|
||||
# gap for possessive single-message questions, so retain that
|
||||
# narrow fallback only when it found no competing source family.
|
||||
from src.turn_contract import requested_capabilities
|
||||
capabilities = requested_capabilities(latest_user_text)
|
||||
# A header-only reference can inherit the immediately preceding
|
||||
# mailbox read, but an intervening request or a competing source
|
||||
# breaks that link. Never infer a read from an earlier write.
|
||||
header_followup = (
|
||||
not capabilities
|
||||
and bool(_EMAIL_HEADER_FIELDS.search(latest_user_text))
|
||||
and bool(_EMAIL_HEADER_FOLLOWUP.search(latest_user_text))
|
||||
and bool(_READ_REQUEST.match(prior_user_text))
|
||||
and requested_capabilities(prior_user_text) == {'email'}
|
||||
)
|
||||
mailbox_read = not contact_email_field and (capabilities == {'email'} or (
|
||||
not capabilities and bool(_OWN_MAILBOX.search(latest_user_text))
|
||||
) or header_followup)
|
||||
else:
|
||||
mailbox_read = False
|
||||
if mailbox_read:
|
||||
intent = replace(intent, dependencies=tuple(dict.fromkeys((*intent.dependencies, 'email'))))
|
||||
if (not _EMAIL_BODY_QUERY.search(latest_user_text)
|
||||
and (_MAILBOX_HEADER_LIST.fullmatch(latest_user_text)
|
||||
or _EMAIL_HEADER_FIELDS.search(latest_user_text)
|
||||
or _EMAIL_COUNT.match(latest_user_text))):
|
||||
intent = replace(intent, requires_content=False)
|
||||
return intent
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise ValueError('Invalid classifier response') from exc
|
||||
|
||||
@@ -68,6 +68,7 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
||||
})
|
||||
return {
|
||||
"uid": str(message.get("uid") or _uid(index)),
|
||||
"message_id": str(message.get("message_id") or ""),
|
||||
"account": str(message.get("account") or "fixture"),
|
||||
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
||||
"account_id": str(message.get("account_id") or "fixture"),
|
||||
@@ -78,6 +79,16 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
||||
"folder": str(message.get("folder") or "INBOX"),
|
||||
"attachments": attachments,
|
||||
"has_attachments": bool(attachments),
|
||||
"summary": str(message.get("summary") or message.get("body") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _account_matches(message: dict[str, Any], account: str | None) -> bool:
|
||||
selector = str(account or "").strip().casefold()
|
||||
return not selector or selector in {
|
||||
str(message.get("account") or "fixture").strip().casefold(),
|
||||
str(message.get("account_email") or message.get("to") or "").strip().casefold(),
|
||||
str(message.get("account_id") or "fixture").strip().casefold(),
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +119,12 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
|
||||
bare = tool.removeprefix("mcp__email__")
|
||||
if bare == "list_email_accounts":
|
||||
accounts = {}
|
||||
for index, message in enumerate(messages):
|
||||
row = _row(index, message)
|
||||
identity = (row["account_id"], row["account"], row["account_email"])
|
||||
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
|
||||
"email": row["account_email"], "default": not accounts})
|
||||
return {
|
||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
||||
"output": "Fixture mailbox (default)",
|
||||
@@ -159,6 +176,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
|
||||
if bare == "read_email":
|
||||
uid = str(args.get("uid") or "")
|
||||
message_id = str(args.get("message_id") or "")
|
||||
folder = str(args.get("folder") or "INBOX")
|
||||
try:
|
||||
index = int(uid) - 1
|
||||
@@ -185,6 +203,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
all_messages = _messages()
|
||||
owner_value = str(owner or "").strip()
|
||||
visible_index = -1
|
||||
matches = []
|
||||
for original in all_messages:
|
||||
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
||||
continue
|
||||
@@ -193,6 +212,13 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
||||
continue
|
||||
if not _folder_matches(original.get("folder"), folder):
|
||||
continue
|
||||
if not _account_matches(original, args.get("account")):
|
||||
continue
|
||||
matches.append(original)
|
||||
if len(matches) > 1:
|
||||
return {"error": "Email identity is ambiguous; specify its account and folder.", "exit_code": 1}
|
||||
if matches:
|
||||
original = matches[0]
|
||||
if bare == "archive_email":
|
||||
original["folder"] = "Archive"
|
||||
action = "Archived"
|
||||
|
||||
+42
-1
@@ -1,5 +1,8 @@
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -12,10 +15,47 @@ GENERATED_IMAGE_RE = re.compile(
|
||||
r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$"
|
||||
)
|
||||
GENERATED_IMAGE_HEADERS = {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Cache-Control": "private, no-cache, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
|
||||
_RECENT_GENERATED_IMAGES: dict[str, tuple[str, float]] = {}
|
||||
_RECENT_IMAGES_LOCK = threading.Lock()
|
||||
_RECENT_IMAGES_TTL = 3600 # 1 hour TTL
|
||||
|
||||
|
||||
def register_generated_image(filename: str, owner: Optional[str]) -> None:
|
||||
"""Record ownership of a freshly generated image.
|
||||
|
||||
Ensures legitimate access to freshly generated images even before
|
||||
or in case Gallery database records are created/committed.
|
||||
"""
|
||||
if not filename or not owner:
|
||||
return
|
||||
now = time.time()
|
||||
with _RECENT_IMAGES_LOCK:
|
||||
if len(_RECENT_GENERATED_IMAGES) > 200:
|
||||
expired = [k for k, (_, exp) in _RECENT_GENERATED_IMAGES.items() if exp < now]
|
||||
for k in expired:
|
||||
_RECENT_GENERATED_IMAGES.pop(k, None)
|
||||
_RECENT_GENERATED_IMAGES[filename] = (owner, now + _RECENT_IMAGES_TTL)
|
||||
|
||||
|
||||
def get_registered_image_owner(filename: str) -> Optional[str]:
|
||||
"""Return the owner of a freshly generated image if still valid in the registry."""
|
||||
if not filename:
|
||||
return None
|
||||
now = time.time()
|
||||
with _RECENT_IMAGES_LOCK:
|
||||
entry = _RECENT_GENERATED_IMAGES.get(filename)
|
||||
if not entry:
|
||||
return None
|
||||
owner, expiry = entry
|
||||
if now > expiry:
|
||||
_RECENT_GENERATED_IMAGES.pop(filename, None)
|
||||
return None
|
||||
return owner
|
||||
|
||||
|
||||
def resolve_generated_image_path(filename: str) -> Path:
|
||||
if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename):
|
||||
@@ -27,3 +67,4 @@ def resolve_generated_image_path(filename: str) -> Path:
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
return path
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Thin client for the local jeff-serve decision endpoint (POST /v1/systemone).
|
||||
|
||||
Trained decision adapters (triage, spam, guard-email, ...) answer a situation
|
||||
with calibrated probabilities over named options in a single forward pass, with
|
||||
no generated text to parse. Background jobs can prefer an adapter and fall back
|
||||
to the normal LLM candidate chain whenever this client returns None.
|
||||
|
||||
Every call fails open (returns None) and is disabled per job unless the job's
|
||||
flag is set, so importing/enabling this never changes behaviour on its own.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_JEFF_URL base URL of jeff-serve (default http://127.0.0.1:8765)
|
||||
ODYSSEUS_JEFF_TIMEOUT per-request timeout in seconds (default 4.0)
|
||||
ODYSSEUS_JEFF_<JOB> per-job enable flag, e.g. ODYSSEUS_JEFF_EMAIL_URGENCY=1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_URL = "http://127.0.0.1:8765"
|
||||
_FALSEY = ("", "0", "false", "off", "no")
|
||||
|
||||
|
||||
def jeff_url() -> str:
|
||||
"""Base URL of the local jeff-serve (no trailing slash)."""
|
||||
return os.getenv("ODYSSEUS_JEFF_URL", _DEFAULT_URL).strip().rstrip("/")
|
||||
|
||||
|
||||
def jeff_job_enabled(job: str) -> bool:
|
||||
"""True when ODYSSEUS_JEFF_<JOB> is set to a truthy value. Default off."""
|
||||
slug = "".join(c if (c.isalnum() or c == "_") else "_" for c in (job or "")).upper()
|
||||
return os.getenv("ODYSSEUS_JEFF_" + slug, "0").strip().lower() not in _FALSEY
|
||||
|
||||
|
||||
def jeff_timeout(default: float = 4.0) -> float:
|
||||
try:
|
||||
return float(os.getenv("ODYSSEUS_JEFF_TIMEOUT", str(default)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
async def jeff_systemone(
|
||||
model: str,
|
||||
state: Dict[str, Any],
|
||||
questions: Dict[str, Any],
|
||||
*,
|
||||
timeout: Optional[float] = None,
|
||||
orders: int = 1,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""POST a decision request and return the ``answers`` dict, or None on error.
|
||||
|
||||
Each question is scored in its own forward pass, so send only questions the
|
||||
adapter was trained on. Raises are swallowed and logged (fail open).
|
||||
"""
|
||||
url = jeff_url()
|
||||
if not url:
|
||||
return None
|
||||
payload: Dict[str, Any] = {"model": model, "state": state, "questions": questions}
|
||||
if orders and orders != 1:
|
||||
payload["orders"] = orders
|
||||
to = timeout if timeout is not None else jeff_timeout()
|
||||
try:
|
||||
if client is None:
|
||||
async with httpx.AsyncClient(timeout=to) as _c:
|
||||
resp = await _c.post(url + "/v1/systemone", json=payload)
|
||||
else:
|
||||
resp = await client.post(url + "/v1/systemone", json=payload)
|
||||
resp.raise_for_status()
|
||||
answers = (resp.json() or {}).get("answers")
|
||||
return answers if isinstance(answers, dict) else None
|
||||
except Exception as exc:
|
||||
logger.warning("jeff decision (%s) failed: %s", model, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def jeff_available(timeout: float = 1.5) -> bool:
|
||||
"""Best-effort health probe for the local jeff-serve."""
|
||||
url = jeff_url()
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as _c:
|
||||
resp = await _c.get(url + "/health")
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||
if not isinstance(answer, dict):
|
||||
return None
|
||||
if answer.get("type") == "score":
|
||||
try:
|
||||
return float(answer.get("score"))
|
||||
except Exception:
|
||||
return None
|
||||
if answer.get("type") == "choice":
|
||||
probs = answer.get("probabilities") or {}
|
||||
try:
|
||||
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""The winning key of a jeff ``choice`` answer, or None."""
|
||||
if isinstance(answer, dict) and answer.get("type") == "choice":
|
||||
c = answer.get("choice")
|
||||
return str(c) if c is not None else None
|
||||
return None
|
||||
+50
-7
@@ -2268,10 +2268,25 @@ def normalize_model_id(
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def _apply_explicit_nonstream_reasoning_effort(
|
||||
payload: Dict, reasoning_effort: Optional[str]
|
||||
) -> None:
|
||||
"""Apply explicit effort to an OpenAI-compatible non-streaming request.
|
||||
|
||||
No explicit effort preserves existing provider-specific defaults.
|
||||
Native Ollama, Anthropic and ChatGPT Responses use separate builders.
|
||||
"""
|
||||
effort = str(reasoning_effort or "").strip().lower()
|
||||
if effort and effort != "default":
|
||||
payload["reasoning_effort"] = effort
|
||||
|
||||
|
||||
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
thinking_mode: Optional[str] = None) -> str:
|
||||
thinking_mode: Optional[str] = None,
|
||||
reasoning_effort: Optional[str] = None) -> str:
|
||||
"""Synchronous LLM call with optional prompt type enhancement."""
|
||||
h = _provider_headers(_detect_provider(url))
|
||||
# Tolerate headers that arrive as a JSON string (some sessions stored them
|
||||
@@ -2303,7 +2318,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
provider = _detect_provider(url)
|
||||
cache_key = _get_cache_key(
|
||||
url, model, messages_copy, temperature, max_tokens, headers=headers,
|
||||
thinking_mode=thinking_mode,
|
||||
thinking_mode=thinking_mode, reasoning_effort=reasoning_effort,
|
||||
)
|
||||
cached_response = _get_cached_response(cache_key)
|
||||
if cached_response:
|
||||
@@ -2343,6 +2358,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
)
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
_apply_explicit_nonstream_reasoning_effort(payload, reasoning_effort)
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||
@@ -2465,14 +2481,30 @@ def llm_call_with_fallback(candidates, messages, **kwargs) -> str:
|
||||
|
||||
|
||||
async def llm_call_async_with_fallback(candidates, messages, **kwargs) -> str:
|
||||
"""Async variant of `llm_call_with_fallback` — same semantics."""
|
||||
"""Async variant of `llm_call_with_fallback` — same semantics.
|
||||
|
||||
An optional ``candidate_request_factory(index, url, model, headers)`` may
|
||||
return ``{"messages": ..., "kwargs": {...}}`` to adjust the request per
|
||||
candidate (same contract as `llm_call_async_with_route_fallback`).
|
||||
"""
|
||||
candidate_request_factory = kwargs.pop("candidate_request_factory", None)
|
||||
cands = dedupe_model_candidates(candidates)
|
||||
if not cands:
|
||||
raise HTTPException(503, "No model endpoint configured")
|
||||
last_err = None
|
||||
for i, (url, model, headers) in enumerate(cands):
|
||||
try:
|
||||
return await llm_call_async(url, model, messages, headers=headers, **kwargs)
|
||||
candidate_messages = messages
|
||||
candidate_kwargs = kwargs
|
||||
if candidate_request_factory is not None:
|
||||
request = candidate_request_factory(i, url, model, headers) or {}
|
||||
if hasattr(request, "__await__"):
|
||||
request = await request
|
||||
candidate_messages = request.get("messages", messages)
|
||||
candidate_kwargs = {**kwargs, **(request.get("kwargs") or {})}
|
||||
return await llm_call_async(
|
||||
url, model, candidate_messages, headers=headers, **candidate_kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
tag = "primary" if i == 0 else "candidate"
|
||||
@@ -2740,6 +2772,7 @@ async def llm_call_async(
|
||||
_apply_deepseek_v4_reasoning_defaults(
|
||||
payload, target_url, model, thinking_mode
|
||||
)
|
||||
_apply_explicit_nonstream_reasoning_effort(payload, reasoning_effort)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||
@@ -3249,7 +3282,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'
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama stream error: {e}")
|
||||
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
return
|
||||
|
||||
# ── Anthropic streaming ──
|
||||
@@ -3402,7 +3435,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'
|
||||
except Exception as e:
|
||||
logger.error(f"Anthropic stream error: {e}")
|
||||
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
return
|
||||
|
||||
# ── OpenAI-compatible streaming ──
|
||||
@@ -3872,7 +3905,17 @@ 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'
|
||||
except Exception as e:
|
||||
logger.error(f"Stream error: {e}")
|
||||
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(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:
|
||||
|
||||
@@ -102,15 +102,15 @@ class MemoryVectorStore:
|
||||
return collections
|
||||
|
||||
def add(self, memory_id: str, text: str):
|
||||
"""Insert or refresh a memory entry, preserving unchanged embeddings."""
|
||||
"""Add a single memory entry to the vector index."""
|
||||
if not self._healthy:
|
||||
return
|
||||
for lane in self._lanes:
|
||||
try:
|
||||
existing = lane.collection.get(ids=[memory_id], include=["documents"])
|
||||
if existing["ids"] and existing["documents"] == [text]:
|
||||
existing = lane.collection.get(ids=[memory_id])
|
||||
if existing["ids"]:
|
||||
continue
|
||||
lane.collection.upsert(
|
||||
lane.collection.add(
|
||||
ids=[memory_id],
|
||||
embeddings=lane.encode([text]),
|
||||
documents=[text],
|
||||
|
||||
@@ -56,7 +56,7 @@ def supports_user_thinking_toggle(value: object) -> bool:
|
||||
return False
|
||||
return any(pattern in leaf for pattern in (
|
||||
"kimi-k2.5", "kimi-k2.6", "kimi-k3",
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "deepseek-v4",
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner",
|
||||
"minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||
"magistral", "mistral-small", "mistral-medium",
|
||||
))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Pre-Wave-3 private_browser schema (agent-browser wrapper), used when ODYSSEUS_PRIVATE_BROWSER_LEGACY is on."""
|
||||
|
||||
PRIVATE_BROWSER_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "private_browser",
|
||||
"description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]},
|
||||
"url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"},
|
||||
"selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"},
|
||||
"target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"},
|
||||
"key": {"type": "string", "description": "Key name for press action, e.g. Enter"},
|
||||
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"},
|
||||
"amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"},
|
||||
"text": {"type": "string", "description": "Text for fill action"},
|
||||
"value": {"type": "string", "description": "Alternative text/value for fill action"},
|
||||
"find": {"type": "string", "description": "Visible text to locate for find action"},
|
||||
"script": {"type": "string", "description": "JavaScript expression for evaluate action"},
|
||||
"path": {"type": "string", "description": "Optional output path for screenshot"},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{"type": "object"},
|
||||
]
|
||||
},
|
||||
},
|
||||
"timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ def untrusted_context_message(
|
||||
"""
|
||||
safe_label = _sanitize_label(label)
|
||||
text = "" if content is None else str(content)
|
||||
import logging as _lg
|
||||
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
|
||||
text = _escape_guard_markers(text)
|
||||
metadata: Dict[str, Any] = {
|
||||
"trusted": False,
|
||||
|
||||
+9
-2
@@ -6,11 +6,14 @@ 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).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_readiness() -> Dict[str, object]:
|
||||
"""Run the readiness checks and return a JSON-serialisable report.
|
||||
@@ -33,7 +36,10 @@ def check_readiness() -> Dict[str, object]:
|
||||
conn.execute(sql_text("SELECT 1"))
|
||||
checks["database"] = {"ok": True}
|
||||
except Exception as e:
|
||||
checks["database"] = {"ok": False, "error": str(e)}
|
||||
# The raw driver error can carry the DB host/user/path; keep it in the
|
||||
# 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.
|
||||
try:
|
||||
@@ -44,7 +50,8 @@ def check_readiness() -> Dict[str, object]:
|
||||
os.remove(probe)
|
||||
checks["data_dir"] = {"ok": True, "path": DATA_DIR}
|
||||
except Exception as e:
|
||||
checks["data_dir"] = {"ok": False, "error": str(e)}
|
||||
logger.warning("Readiness data_dir check failed: %s", e)
|
||||
checks["data_dir"] = {"ok": False, "error_type": type(e).__name__}
|
||||
|
||||
# Local-first: storage stays on the home machine (informational, never fatal).
|
||||
local_first = (
|
||||
|
||||
@@ -67,7 +67,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
"""
|
||||
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
|
||||
from src.llm_core import llm_call_async
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -164,8 +164,6 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
|
||||
|
||||
url, model, headers = resolve_task_endpoint(owner=owner or None)
|
||||
if not url:
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. No model endpoint available for sorting."
|
||||
|
||||
names_text = "\n".join(f' "{s["id"][:8]}": "{s["name"]}"' for s in session_list)
|
||||
prompt = (
|
||||
@@ -183,8 +181,12 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
try:
|
||||
# 16384 (was 4096): large folder JSON + reasoning-model thinking
|
||||
# overflowed 4096 and truncated the JSON, so it never parsed.
|
||||
raw = await llm_call_async(url, model, [{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
|
||||
raw = await task_llm_call_async(
|
||||
[{"role": "user", "content": prompt}],
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
owner=owner or None,
|
||||
temperature=0.3, max_tokens=16384, timeout=120,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-sort LLM call failed: {e}")
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. Folder sort skipped (model unreachable)."
|
||||
|
||||
@@ -167,6 +167,10 @@ DEFAULT_SETTINGS = {
|
||||
# Ordered fallback chain for the Utility model (summarization, naming,
|
||||
# tidy actions, etc.).
|
||||
"utility_model_fallbacks": [],
|
||||
# Opt-in reasoning effort for utility/background LLM calls. Empty keeps the
|
||||
# provider default; a value is sent only to utility-chain candidates whose
|
||||
# model advertises that level.
|
||||
"utility_reasoning_effort": "",
|
||||
"teacher_model": "",
|
||||
"teacher_enabled": False,
|
||||
"teacher_tier2_enabled": False,
|
||||
@@ -297,6 +301,7 @@ _PER_USER_KEYS = {
|
||||
# got injected into the chat composer on first open.
|
||||
"default_endpoint_id", "default_model",
|
||||
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
||||
"utility_reasoning_effort",
|
||||
"research_endpoint_id", "research_model",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
|
||||
|
||||
Each round the router (spec_router.next_action) picks one action from the full tool surface:
|
||||
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
|
||||
the executor runs it; the result goes back to the router for the next pick.
|
||||
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
|
||||
ask_user -> the chat model writes the question through the ask_user tool.
|
||||
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
|
||||
authority and tool policy (execute_tool_block); nothing else is layered on top.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from src import spec_router
|
||||
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
|
||||
from src.clean_agent_preview import (
|
||||
event, preview_model_response, preview_tool_result_text,
|
||||
)
|
||||
from src.tool_capabilities import ToolRunSecurityContext
|
||||
from src.tool_execution import execute_tool_block
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS, function_call_to_tool_block
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _text(content):
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return '\n'.join(str(p.get('text') or '') for p in content
|
||||
if isinstance(p, dict) and p.get('type') == 'text')
|
||||
return ''
|
||||
|
||||
|
||||
def _name(schema):
|
||||
return (schema.get('function') or {}).get('name') or ''
|
||||
|
||||
|
||||
# JSON-schema keywords the provider's guided decoding (named tool_choice) cannot compile.
|
||||
_GRAMMAR_UNSUPPORTED = frozenset({
|
||||
'uniqueItems', 'contains', 'minContains', 'maxContains', 'patternProperties', 'propertyNames',
|
||||
'dependentRequired', 'dependentSchemas', 'if', 'then', 'else',
|
||||
'unevaluatedProperties', 'unevaluatedItems',
|
||||
})
|
||||
|
||||
|
||||
def grammar_safe(schema):
|
||||
"""Copy of a schema without keywords guided decoding rejects (validation still runs at execution)."""
|
||||
if isinstance(schema, dict):
|
||||
return {k: grammar_safe(v) for k, v in schema.items() if k not in _GRAMMAR_UNSUPPORTED}
|
||||
if isinstance(schema, list):
|
||||
return [grammar_safe(v) for v in schema]
|
||||
return schema
|
||||
|
||||
|
||||
def spec_surface(disabled_tools):
|
||||
disabled = set(disabled_tools or ())
|
||||
return [s for s in FUNCTION_TOOL_SCHEMAS if _name(s) and _name(s) not in disabled]
|
||||
|
||||
|
||||
async def _model_round(client, endpoint_url, headers, request, recovery):
|
||||
"""Run one chat-model round; return (content, tool_calls, usage)."""
|
||||
content, calls, usage, raw = '', {}, {}, []
|
||||
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if len(raw) < 4 and line.strip():
|
||||
raw.append(line[:400])
|
||||
if line.strip() and not line.startswith('data: '):
|
||||
logger.warning('[spec-driver] unexpected stream line: %s', line[:500])
|
||||
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
except ValueError:
|
||||
continue
|
||||
if chunk.get('error') or chunk.get('object') == 'error':
|
||||
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||
usage = chunk.get('usage') or usage
|
||||
for choice in chunk.get('choices') or []:
|
||||
delta = choice.get('delta') or {}
|
||||
text = delta.get('content') or ''
|
||||
content += text
|
||||
for frag in delta.get('tool_calls') or []:
|
||||
call = calls.setdefault(frag.get('index', 0), {'id': '', 'name': '', 'arguments': ''})
|
||||
call['id'] = frag.get('id') or call['id']
|
||||
fn = frag.get('function') or {}
|
||||
call['name'] += fn.get('name') or ''
|
||||
call['arguments'] += fn.get('arguments') or ''
|
||||
if not calls:
|
||||
logger.warning('[spec-driver] no tool call; status=%s max_tokens=%s msgs=%s first lines=%s',
|
||||
getattr(response, 'status_code', None), request.get('max_tokens'),
|
||||
[(m.get('role'), type(m.get('content')).__name__,
|
||||
[p.get('type') for p in m['content']] if isinstance(m.get('content'), list) else len(str(m.get('content') or '')))
|
||||
for m in request.get('messages', [])], raw[-2:])
|
||||
return content, [calls[i] for i in sorted(calls)], usage
|
||||
|
||||
|
||||
@with_request_authority
|
||||
async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id, owner,
|
||||
disabled_tools, tool_policy, workspace=None, active_document=None,
|
||||
client_runtime_context=None, external_untrusted_context_seen=False,
|
||||
max_tokens=768, max_rounds=8, temperature=0.0,
|
||||
request_authority=MISSING_AUTHORITY, **ignored):
|
||||
from src.turn_contract import _ACTIVE_CONTRACT
|
||||
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
|
||||
started = time.monotonic()
|
||||
surface = spec_surface(disabled_tools)
|
||||
by_name = {_name(s): s for s in surface}
|
||||
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
|
||||
history = list(messages)
|
||||
security = ToolRunSecurityContext(
|
||||
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
|
||||
security.observe_messages(messages)
|
||||
picks, executions, done_calls = [], [], set()
|
||||
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
|
||||
recovery = {}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||
for round_number in range(1, max_rounds + 1):
|
||||
rounds = round_number
|
||||
action = await asyncio.to_thread(
|
||||
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
|
||||
if action is None:
|
||||
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
|
||||
if round_number == max_rounds and action['kind'] == 'tool':
|
||||
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
|
||||
picks.append({'round': round_number, **action})
|
||||
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
|
||||
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
|
||||
'temperature': temperature, 'stream': True,
|
||||
'stream_options': {'include_usage': True},
|
||||
'chat_template_kwargs': {'enable_thinking': False}}
|
||||
|
||||
if action['kind'] == 'answer_directly':
|
||||
answer_parts = []
|
||||
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
except ValueError:
|
||||
continue
|
||||
if chunk.get('error') or chunk.get('object') == 'error':
|
||||
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||
u = chunk.get('usage') or {}
|
||||
usage_in += u.get('prompt_tokens') or 0
|
||||
usage_out += u.get('completion_tokens') or 0
|
||||
for choice in chunk.get('choices') or []:
|
||||
text = (choice.get('delta') or {}).get('content') or ''
|
||||
if text:
|
||||
first_token = first_token or time.monotonic()
|
||||
answer_parts.append(text)
|
||||
yield event({'delta': text})
|
||||
answer = ''.join(answer_parts).strip()
|
||||
if not answer:
|
||||
yield event({'delta': 'The model returned no answer.'})
|
||||
history.append({'role': 'assistant', 'content': answer})
|
||||
break
|
||||
|
||||
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
|
||||
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
|
||||
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
|
||||
options = [name]
|
||||
if action['kind'] == 'tool' and topk > 1:
|
||||
options = [n for n, _ in action.get('top') or []][:topk] or [name]
|
||||
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
|
||||
for n in options]
|
||||
options = [n for n, sc in zip(options, schemas) if sc is not None]
|
||||
schemas = [sc for sc in schemas if sc is not None]
|
||||
if not schemas:
|
||||
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
|
||||
latest = f'Tool {name} is not available.'
|
||||
continue
|
||||
request['tools'] = [grammar_safe(sc) for sc in schemas]
|
||||
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
|
||||
if len(options) == 1 else 'required')
|
||||
if len(options) > 1:
|
||||
logger.info('[spec-driver] round=%s options=%s', round_number, options)
|
||||
try:
|
||||
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
|
||||
except (RuntimeError, httpx.HTTPError) as exc:
|
||||
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
|
||||
latest = f'The {name} call could not be produced: {exc}'
|
||||
continue
|
||||
first_token = first_token or time.monotonic()
|
||||
usage_in += usage.get('prompt_tokens') or 0
|
||||
usage_out += usage.get('completion_tokens') or 0
|
||||
call = next((c for c in calls if c['name'] in options), None)
|
||||
if call is not None and len(options) > 1:
|
||||
name = call['name']
|
||||
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
|
||||
round_number, name, options.index(name) + 1, options)
|
||||
picks[-1]['chosen'] = name
|
||||
if call is None:
|
||||
latest = f'The {name} call was not produced.'
|
||||
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
|
||||
continue
|
||||
arguments = call['arguments'] or '{}'
|
||||
signature = (name, arguments)
|
||||
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
|
||||
history.append({'role': 'assistant', 'content': content or '',
|
||||
'tool_calls': [{'id': call_id, 'type': 'function',
|
||||
'function': {'name': name, 'arguments': arguments}}]})
|
||||
if signature in done_calls:
|
||||
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
|
||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||
latest = output
|
||||
continue
|
||||
try:
|
||||
args = json.loads(arguments)
|
||||
except ValueError:
|
||||
args = {}
|
||||
block = function_call_to_tool_block(name, arguments)
|
||||
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
|
||||
'full_command': arguments, 'round': round_number})
|
||||
tool_started = time.monotonic()
|
||||
if block is None:
|
||||
desc, result = f'{name}: invalid arguments', {
|
||||
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
|
||||
else:
|
||||
desc, result = await execute_tool_block(
|
||||
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||
tool_policy=tool_policy, security_context=security,
|
||||
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
|
||||
client_runtime_context=client_runtime_context, request_authority=request_authority)
|
||||
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
|
||||
output = preview_tool_result_text(result, name, args)
|
||||
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
|
||||
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
|
||||
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
|
||||
'desc': desc, 'round': round_number,
|
||||
'seconds': round(time.monotonic() - tool_started, 3)}
|
||||
if result.get('image_url'):
|
||||
yield event({'type': 'generated_image', 'url': result['image_url'],
|
||||
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
|
||||
if result.get('ui_event'):
|
||||
yield event({'type': 'ui_control', 'data': result})
|
||||
executions.append(tool_event)
|
||||
yield event(tool_event)
|
||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||
if not failed:
|
||||
done_calls.add(signature)
|
||||
latest = output
|
||||
ask = result.get('ask_user')
|
||||
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
|
||||
question = str(ask['question']).strip()
|
||||
history.append({'role': 'assistant', 'content': question})
|
||||
yield event({'type': 'final_response', 'content': question})
|
||||
yield event({'type': 'ask_user', 'data': ask})
|
||||
break
|
||||
elapsed = time.monotonic() - started
|
||||
yield event({'type': 'metrics', 'data': {
|
||||
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
|
||||
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
|
||||
'time_to_first_token': round(first_token - started, 3) if first_token else None,
|
||||
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
|
||||
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
|
||||
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
|
||||
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
|
||||
}})
|
||||
yield 'data: [DONE]\n\n'
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
||||
|
||||
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
||||
message and scores every offered tool in one forward pass. The harness only
|
||||
ever ADDS tools from its answer, never removes any, and fails open: on any
|
||||
error, timeout or low-confidence answer the compact tool set is unchanged.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||
ODYSSEUS_SPEC_ROUTER_MODEL adapter name (default: odysseus-s60)
|
||||
ODYSSEUS_SPEC_ROUTER_TIMEOUT seconds (default: 0.5)
|
||||
ODYSSEUS_SPEC_ROUTER_MIN_CONF minimum top-1 probability to act (default: 0.3)
|
||||
ODYSSEUS_SPEC_ROUTER_SECOND minimum 2nd-choice probability to also add it (default: 0.25)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEAD = {
|
||||
"answer_directly": "No tool is needed: answer the user directly from the conversation",
|
||||
"ask_user": "A tool is needed but required information is missing: ask the user first",
|
||||
}
|
||||
_INSTRUCTIONS = (
|
||||
"Which tool should the agent call next to handle the user's latest message? "
|
||||
"Use the conversation for context. If no tool is needed, choose answer directly. "
|
||||
"If a tool is needed but information it requires is missing, choose ask the user."
|
||||
)
|
||||
_AGENT = "Odysseus, a personal tool-using assistant."
|
||||
# Recent decisions, for debugging in the UI/logs: most recent last.
|
||||
LAST_DECISIONS: List[Dict[str, Any]] = []
|
||||
_MCP_BROWSER_TOOLS = (
|
||||
"mcp__builtin_browser__browser_navigate",
|
||||
"mcp__builtin_browser__browser_snapshot",
|
||||
"mcp__builtin_browser__browser_click",
|
||||
"mcp__builtin_browser__browser_type",
|
||||
"mcp__builtin_browser__browser_press_key",
|
||||
"mcp__builtin_browser__browser_wait_for",
|
||||
"mcp__builtin_browser__browser_take_screenshot",
|
||||
)
|
||||
|
||||
|
||||
def _fn(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return schema.get("function") if isinstance(schema.get("function"), dict) else schema
|
||||
|
||||
|
||||
def tool_line(schema: Dict[str, Any]) -> str:
|
||||
fn = _fn(schema)
|
||||
name = str(fn.get("name") or "")
|
||||
props = ((fn.get("parameters") or {}).get("properties") or {})
|
||||
if os.getenv("ODYSSEUS_SPEC_TOOL_FORMAT", "").strip().lower() == "names":
|
||||
return name + "(" + ", ".join(props) + ")" # names + parameters only (spec_v1n adapters are trained on this)
|
||||
desc = " ".join(str(fn.get("description") or "").split())
|
||||
if len(desc) > 220:
|
||||
desc = desc[:217] + "..."
|
||||
return f"{name}({', '.join(props)}): {desc}"
|
||||
|
||||
|
||||
def prior_tool_names(messages: Optional[Iterable[Dict[str, Any]]]) -> Set[str]:
|
||||
"""Tools already called in this conversation (kept offered: no 'forgetting')."""
|
||||
seen: Set[str] = set()
|
||||
for m in messages or []:
|
||||
if not isinstance(m, dict) or m.get("role") != "assistant":
|
||||
continue
|
||||
for tc in m.get("tool_calls") or []:
|
||||
name = (tc.get("function") or tc).get("name") if isinstance(tc, dict) else None
|
||||
if name:
|
||||
seen.add(str(name))
|
||||
return seen
|
||||
|
||||
|
||||
def _conversation(messages: Optional[Iterable[Dict[str, Any]]], limit: int = 4) -> List[Dict[str, str]]:
|
||||
out: List[Dict[str, str]] = []
|
||||
for m in list(messages or []):
|
||||
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip():
|
||||
out.append({"role": m["role"], "content": m["content"][:600]})
|
||||
# the last user message is passed separately as user_message
|
||||
if out and out[-1]["role"] == "user":
|
||||
out = out[:-1]
|
||||
return out[-limit:]
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip())
|
||||
|
||||
|
||||
def spec_route_tools(
|
||||
user_message: str,
|
||||
schemas: Iterable[Dict[str, Any]],
|
||||
disabled_tools: Optional[Set[str]] = None,
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
return_decision: bool = False,
|
||||
):
|
||||
"""Return tool names the router wants offered (plus tools already used).
|
||||
|
||||
With return_decision=True returns (tools, decided) where decided is True when
|
||||
the router answered confidently (a tool pick, or answer_directly)."""
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url or not (user_message or "").strip():
|
||||
return (added, False) if return_decision else added
|
||||
t0 = time.time()
|
||||
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
||||
try:
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
http = urllib.request.Request(
|
||||
url + "/v1/systemone", data=json.dumps(req).encode(), headers={"content-type": "application/json"}
|
||||
)
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||
break
|
||||
except urllib.error.HTTPError as busy:
|
||||
# 529 = router busy with another request; retry within budget.
|
||||
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||
ranked = sorted(
|
||||
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
||||
)
|
||||
picks: List[str] = []
|
||||
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
||||
picks.append(names[int(choice[1:]) - 1])
|
||||
for p, k in ranked[:3]:
|
||||
nm = names[int(k[1:]) - 1]
|
||||
if nm not in picks and p >= second_min:
|
||||
picks.append(nm)
|
||||
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
||||
if _bmode != "0" and "private_browser" in picks:
|
||||
# This build disables page execution in private_browser (see
|
||||
# src/browser_identity.py). "mcp": use the connected Playwright MCP
|
||||
# browser (no run_code/evaluate); "web": fetch/search only.
|
||||
picks = [n for n in picks if n != "private_browser"]
|
||||
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
||||
for n in extra + ["web_search", "web_fetch"]:
|
||||
if n not in picks:
|
||||
picks.append(n)
|
||||
added.update(picks)
|
||||
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
||||
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||
except Exception as exc: # fail open
|
||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||
logger.warning("spec router failed open: %s", exc)
|
||||
LAST_DECISIONS.append(decision)
|
||||
del LAST_DECISIONS[:-50]
|
||||
decision["decided"] = decided
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
|
||||
# ---- Ajax Spec: system prompt trimmed to the tools actually offered ----------
|
||||
_CORE_PROMPT = (
|
||||
"You are Ajax, the assistant in Odysseus. Emit one native tool call, then stop. "
|
||||
"After a successful tool result, answer from it; never repeat the same tool with the same arguments."
|
||||
)
|
||||
_CODING_PROMPT = (
|
||||
"HARD CODING ORDER: for code changes call read_file, then edit_file or apply_patch, then host_shell for tests. "
|
||||
"Never run tests before the edit succeeds; after a read, your next call is the edit. "
|
||||
"edit_file JSON is {path,old_string,new_string}; write_file only for new files or full rewrites."
|
||||
)
|
||||
_TOOL_NOTES = {
|
||||
"manage_notes": "manage_notes: notes/checklists.",
|
||||
"manage_documents": "manage_documents: document library; actions list/read/delete/tidy, use list+search to find by title/content.",
|
||||
"create_document": "create_document: create editor-panel documents with title/content.",
|
||||
"edit_document": "edit_document: edit the active editor document using command=<<<FIND>>>old<<<REPLACE>>>new<<<END>>> or edits=[{find,replace}].",
|
||||
"update_document": "update_document: full active-document rewrite only.",
|
||||
"manage_calendar": "manage_calendar: calendar events; actions create_event, update_event, delete_event, list_events.",
|
||||
"manage_tasks": "manage_tasks: scheduled/recurring tasks; create uses action=create, name, prompt, task_type, schedule, scheduled_time, output_target; pause/resume/delete use task_id, so list/search first when only the name is known. Recurring reminders use manage_tasks.",
|
||||
"manage_memory": "manage_memory: saved memories.",
|
||||
"manage_contact": "manage_contact: address-book contacts.",
|
||||
"manage_research": "manage_research: saved research reports.",
|
||||
"manage_settings": "manage_settings: app settings.",
|
||||
"manage_endpoints": "manage_endpoints: configured API endpoints.",
|
||||
"list_models": "list_models: available models.",
|
||||
"manage_mcp": "manage_mcp: MCP servers.",
|
||||
"manage_skills": "manage_skills: skills.",
|
||||
"manage_webhooks": "manage_webhooks: webhooks.",
|
||||
"manage_bg_jobs": "manage_bg_jobs: running/background jobs.",
|
||||
"search_chats": "search_chats: past chats.",
|
||||
"web_search": "web_search: public web lookup, URLs, links.",
|
||||
"web_fetch": "web_fetch: fetch a concrete URL/domain.",
|
||||
"private_browser": "private_browser: rendered site interaction. Make separate calls: first action=open with the url, then action=snapshot; read/scroll/find as needed. Do not use action=batch. Only click/fill element refs returned by snapshot (for example @e12), never guessed labels.",
|
||||
"mcp__builtin_browser__browser_navigate": "Browser: browser_navigate {url} opens a page; then browser_snapshot returns the page with element refs; browser_click/browser_type take ref values from the latest snapshot, never guessed labels.",
|
||||
"youtube_tool": "youtube_tool: YouTube comments, transcripts, metadata, and latest channel video.",
|
||||
"read_file": "read_file: workspace text, PDF, or Office document path.",
|
||||
"edit_file": "edit_file: exact replacement in an existing workspace file.",
|
||||
"write_file": "write_file: create or fully rewrite a workspace file.",
|
||||
"apply_patch": "apply_patch: related multi-file workspace edits.",
|
||||
"host_shell": "host_shell: run tests, builds, and verification in the active workspace.",
|
||||
}
|
||||
_EMAIL_NOTES = (
|
||||
"Email: latest email/emails/inbox -> mcp__email__list_emails {folder:INBOX,max_results:1,unread_only:false}; "
|
||||
"account list -> list_email_accounts; subject find+read -> mcp__email__list_emails {folder:INBOX,max_results:20}; "
|
||||
"sender/topic search -> mcp__email__search_emails {max_results:10}; known id -> mcp__email__read_email; "
|
||||
"new draft -> mcp__email__draft_email; attachment -> mcp__email__download_attachment {uid,index,folder,account}; "
|
||||
"reply draft -> mcp__email__draft_email_reply {uid,account,body}; only explicit send-now -> mcp__email__reply_to_email {uid,account,body}; "
|
||||
"archive/delete/mark -> mcp__email__archive_email/mcp__email__delete_email/mcp__email__mark_email_read. Never emit manage_email."
|
||||
)
|
||||
_AJAX_PROMPT_START = "You are Ajax, the assistant in Odysseus."
|
||||
|
||||
|
||||
def trimmed_system_prompt(offered: Iterable[str]) -> str:
|
||||
offered = set(offered or ())
|
||||
parts = [_CORE_PROMPT]
|
||||
if offered & {"edit_file", "apply_patch", "host_shell"}:
|
||||
parts.append(_CODING_PROMPT)
|
||||
parts += [_TOOL_NOTES[n] for n in sorted(offered) if n in _TOOL_NOTES]
|
||||
if any(n.startswith("mcp__email__") or n in {"list_email_accounts"} for n in offered):
|
||||
parts.append(_EMAIL_NOTES)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def trim_system_prompt(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||
"""Swap the generic Ajax tool manual for one covering only the offered tools."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for m in messages:
|
||||
if (
|
||||
isinstance(m, dict)
|
||||
and m.get("role") == "system"
|
||||
and isinstance(m.get("content"), str)
|
||||
and m["content"].startswith(_AJAX_PROMPT_START)
|
||||
):
|
||||
new = dict(m)
|
||||
new["content"] = trimmed_system_prompt(offered)
|
||||
logger.info("[spec-harness] system prompt trimmed %d -> %d chars", len(m["content"]), len(new["content"]))
|
||||
out.append(new)
|
||||
else:
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def drop_skills_index(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||
"""Ajax Spec: omit the ~1000-token skills index unless the skills tool is offered."""
|
||||
if "manage_skills" in set(offered or ()):
|
||||
return messages
|
||||
out = [
|
||||
m for m in messages
|
||||
if not (isinstance(m, dict) and (m.get("metadata") or {}).get("source") == "available skills index")
|
||||
]
|
||||
if len(out) != len(messages):
|
||||
logger.info("[spec-harness] dropped skills index (%d chars)", sum(len(str(m.get("content") or "")) for m in messages if m not in out))
|
||||
return out
|
||||
|
||||
|
||||
# --- Router-decided thinking (progressive thinking trigger) -------------------------------------------------
|
||||
# The router classifies the user's request as THINK (needs reasoning: math/logic, tricky planning, subtle explanation or
|
||||
# weighing options, first-instinct-likely-wrong questions) or DIRECT (plain doing, lookup, chit-chat).
|
||||
# Zero-shot on 143 DeepSeek-labelled app requests (2026-10-13): odysseus-agentic-v1f acc 0.916, think recall 0.985,
|
||||
# think precision 0.859, ~33 ms. Off unless ODYSSEUS_SPEC_THINK_ROUTER=1. Never raises; returns None on any error.
|
||||
_THINK_DEF = ("This request needs genuine reasoning or figuring something out before answering: math and logic puzzles; "
|
||||
"planning a tricky multi-step approach; explaining something subtle or weighing options; or questions where "
|
||||
"a quick first instinct is likely wrong.")
|
||||
_DIRECT_DEF = ("This request is plain doing, lookup, or chit-chat that can be answered directly without reasoning: simple "
|
||||
"tool actions (set a reminder, search email, open a page, rename a file), simple facts, or small talk.")
|
||||
_THINK_INSTRUCTIONS = ("Classify whether this single user request needs the assistant to THINK (deep reasoning / figuring "
|
||||
"something out) before answering, or can be answered DIRECTLY (plain doing, lookup, or chit-chat). "
|
||||
"Answer with exactly one of the two options.")
|
||||
|
||||
|
||||
# 5-way request kind, exactly as trained into adapter thinkclass5 (2026-10-13; held-out 143: think/direct 96.5%,
|
||||
# think precision 98.8%, recall 95.2%). Enabled with ODYSSEUS_SPEC_THINK_QUESTION=kind5 + ODYSSEUS_SPEC_THINK_MODEL=thinkclass5.
|
||||
_KIND5_QUESTION = {"type": "choice", "instructions": "Read the user's request and classify it into exactly one of five kinds: math_logic, tricky_planning, explain_or_weigh, trap_question, or direct. Pick the option whose description fits best.", "criteria": {"math_logic": "Mathematical or logical computation/reasoning: arithmetic, word problems, logic puzzles, counting, proofs.", "tricky_planning": "Planning a multi-step approach with real constraints: schedules, itineraries, sequencing, resource or budget allocation, multi-part procedures.", "explain_or_weigh": "Explaining something subtle or weighing options/tradeoffs: comparisons, 'should I', pros and cons, recommendations with reasoning, nuanced why-explanations.", "trap_question": "A question where the obvious or first-instinct answer is likely wrong: riddles, trick questions, common misconceptions, counterintuitive facts.", "direct": "Plain doing, lookup, or chit-chat: simple tool actions, simple facts, small talk — no deep reasoning needed."}}
|
||||
|
||||
|
||||
def think_router_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_SPEC_THINK_ROUTER", "0").strip() not in ("", "0", "false", "off")
|
||||
|
||||
|
||||
def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return {'think': bool, 'p_think': float, 'ms': float} or None (disabled / error / empty message)."""
|
||||
if not think_router_enabled():
|
||||
return None
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
text = (user_message or "").strip()
|
||||
if not url or not text:
|
||||
return None
|
||||
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
||||
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
||||
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
||||
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
||||
"questions": {"thinking": question},
|
||||
}
|
||||
t0 = time.time()
|
||||
try:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
ans = json.load(urllib.request.urlopen(http, timeout=timeout))["answers"]["thinking"]
|
||||
probs = ans.get("probabilities") or {}
|
||||
# kind5: think = any of the four reasoning kinds (trained adapter thinkclass5); binary: P(think).
|
||||
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
||||
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||
if kind5:
|
||||
out["kind"] = ans.get("choice")
|
||||
logger.info("spec think router: %s msg=%r", out, text[:120])
|
||||
return out
|
||||
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
||||
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Top-1 next action at any confidence: {'kind': 'tool'|'answer_directly'|'ask_user', 'name', 'p'}.
|
||||
|
||||
Same request as training (build_agentic_rows.py): sorted tool lines, and mid-turn the latest tool
|
||||
result appended to the user message as "\\n\\nLatest tool result:\\n...". Returns None on any error."""
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url:
|
||||
return None
|
||||
t0 = time.time()
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
um = (user_message or "")[-3000:]
|
||||
if latest_result:
|
||||
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
try:
|
||||
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||
break
|
||||
except urllib.error.HTTPError as busy:
|
||||
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = str(out.get("choice") or "")
|
||||
if choice.startswith("t") and choice[1:].isdigit():
|
||||
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
||||
elif choice in ("answer_directly", "ask_user"):
|
||||
action = {"kind": choice, "name": choice}
|
||||
else:
|
||||
return None
|
||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
return None
|
||||
@@ -106,4 +106,11 @@ async def task_llm_call_async(
|
||||
raise RuntimeError("No LLM endpoint available for background task")
|
||||
await wait_for_interactive_quiet("background task LLM")
|
||||
kwargs.setdefault("workload", "background")
|
||||
if kwargs.get("reasoning_effort") is None:
|
||||
kwargs.pop("reasoning_effort", None)
|
||||
from src.utility_effort import candidate_effort_factory
|
||||
|
||||
factory = candidate_effort_factory(owner)
|
||||
if factory is not None:
|
||||
kwargs.setdefault("candidate_request_factory", factory)
|
||||
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
|
||||
|
||||
+55
-62
@@ -355,6 +355,36 @@ def _normalize_chat_endpoint(url: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _resolve_research_settings(get_setting_fn):
|
||||
"""Resolve the scheduled-research settings with crash-proof fallbacks.
|
||||
|
||||
A hand-edited or agent-written data/settings.json can hold non-numeric
|
||||
values for the research_* keys; the bare int() reads previously raised
|
||||
inside TaskScheduler._execute_research_task and killed every scheduled
|
||||
research run until settings.json was fixed by hand (#5049).
|
||||
src/research_handler.py already guards the same reads defensively —
|
||||
this mirrors that for the scheduler. Valid values pass through
|
||||
unchanged (numeric strings like "4096" still parse).
|
||||
"""
|
||||
try:
|
||||
max_tokens = int(get_setting_fn("research_max_tokens", 8192))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
max_tokens = 8192
|
||||
try:
|
||||
extraction_timeout = int(
|
||||
get_setting_fn("research_extraction_timeout_seconds", 90) or 90
|
||||
)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
extraction_timeout = 90
|
||||
try:
|
||||
extraction_concurrency = int(
|
||||
get_setting_fn("research_extraction_concurrency", 3) or 3
|
||||
)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
extraction_concurrency = 3
|
||||
return max_tokens, extraction_timeout, extraction_concurrency
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
def __init__(self, session_manager):
|
||||
self._session_manager = session_manager
|
||||
@@ -1969,21 +1999,20 @@ class TaskScheduler:
|
||||
messages.append(datetime_context_msg)
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
# Resolve headers from the endpoint's API key
|
||||
# Recover the registered runtime URL and credentials for this chat route.
|
||||
headers = {}
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||
from src.auth_helpers import owner_filter
|
||||
from core.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if same_endpoint_base(endpoint_url, ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
ep = resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||
endpoint_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception:
|
||||
@@ -2170,30 +2199,27 @@ class TaskScheduler:
|
||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||
if not endpoint_url or not model:
|
||||
raise RuntimeError("No model/endpoint configured for research")
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
# Record the resolved model for the run record (see _execute_task_locked).
|
||||
self._last_run_model = model
|
||||
|
||||
# Resolve headers
|
||||
# Authorize the selected URL before normalization can collapse service paths.
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||
from src.auth_helpers import owner_filter
|
||||
db2 = db
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
if not headers_from_resolver:
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if same_endpoint_base(endpoint_url, ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||
endpoint_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
except Exception:
|
||||
pass
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
|
||||
max_tokens = int(get_setting("research_max_tokens", 8192))
|
||||
extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90)
|
||||
extraction_concurrency = int(get_setting("research_extraction_concurrency", 3) or 3)
|
||||
max_tokens, extraction_timeout, extraction_concurrency = (
|
||||
_resolve_research_settings(get_setting)
|
||||
)
|
||||
|
||||
researcher = DeepResearcher(
|
||||
llm_endpoint=endpoint_url,
|
||||
@@ -2505,36 +2531,6 @@ class TaskScheduler:
|
||||
ScheduledTask.task_type == "action",
|
||||
).all() if row[0]
|
||||
}
|
||||
# Built-in housekeeping rows created before the request-authority
|
||||
# seal carry a NULL snapshot, so every scheduled run failed with
|
||||
# "Scheduled action has no matching server request authority".
|
||||
# Replace them: drop the unsealed row and let the seed loop below
|
||||
# recreate an authorized copy (server-owned ingress), preserving the
|
||||
# user's enable/pause choice. We never grant authority to the
|
||||
# existing row, so a model-origin row can never gain a built-in
|
||||
# grant this way.
|
||||
replaced_actions = {}
|
||||
stale_rows = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())),
|
||||
).all()
|
||||
sealed_actions = {r.action for r in stale_rows
|
||||
if (r.request_authority_json or "").strip()}
|
||||
removed_status = {}
|
||||
for stale in stale_rows:
|
||||
if (stale.request_authority_json or "").strip():
|
||||
continue
|
||||
removed_status.setdefault(stale.action, stale.status)
|
||||
db.delete(stale)
|
||||
for action, status in removed_status.items():
|
||||
if action in sealed_actions:
|
||||
# A sealed copy already exists; the NULL one was just a
|
||||
# stale duplicate. Keep the sealed row and let the seed
|
||||
# loop skip this action.
|
||||
continue
|
||||
replaced_actions[action] = status
|
||||
existing_actions.discard(action)
|
||||
renamed = []
|
||||
builtin_tasks = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
@@ -2695,15 +2691,12 @@ class TaskScheduler:
|
||||
output_target=defs.get("output_target", "none"),
|
||||
notifications_enabled=False,
|
||||
)
|
||||
if action in replaced_actions and replaced_actions[action] in ("active", "paused"):
|
||||
task.status = replaced_actions[action]
|
||||
db.add(task)
|
||||
seeded.append(action)
|
||||
if seeded or renamed or removed_dupes or retired_count or replaced_actions:
|
||||
if seeded or renamed or removed_dupes or retired_count:
|
||||
logger.info(
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s authority_replaced=%s",
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s",
|
||||
owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count,
|
||||
sorted(replaced_actions),
|
||||
)
|
||||
# Always commit — the orphan-run sweep above may have produced
|
||||
# pending deletes even when no defaults changed.
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
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
|
||||
@@ -16,6 +21,126 @@ DENY_APPROVAL_DECISION = "deny"
|
||||
# session history contains a matching, resolved chat-session approval.
|
||||
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):
|
||||
# Surfaces without a resumable chat (the skill tester, unattended audits)
|
||||
|
||||
+25
-4
@@ -415,9 +415,28 @@ class ToolApprovalStore:
|
||||
backend_operation = BoundBackendOperation(backend,
|
||||
request_authority.request_id if request_authority is not None else "",
|
||||
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
|
||||
from src.agent_runtime.process_resources import needs_process_binding, resolve_process_operation
|
||||
from src.agent_runtime.process_resources import (
|
||||
LAUNCH_TOOLS, needs_process_binding, resolve_process_operation, seal_launch_scope)
|
||||
if request_authority is not None and needs_process_binding(operation, backend):
|
||||
process_operation = resolve_process_operation(request_authority, operation, backend)
|
||||
process_authority = request_authority
|
||||
if (not request_authority.inherited and operation.tool in LAUNCH_TOOLS
|
||||
and not any(s.backend == backend for s in request_authority.launch_scopes)):
|
||||
# Launch scopes exist only for granted bash/python, but the
|
||||
# approval exists to admit an ungranted one. Seal its launch
|
||||
# boundary now, in the request's workspace, the same way an
|
||||
# ungranted filesystem action gets its root below (#6635).
|
||||
# With no workspace bound, bash/python run in the agent
|
||||
# workspace (agent_cwd), so the launch is sealed there.
|
||||
roots = request_authority.resource_roots
|
||||
if not roots:
|
||||
from src.tool_execution import default_agent_workspace
|
||||
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
|
||||
owner=_normalized_owner(owner)),)
|
||||
if len(roots) == 1:
|
||||
from dataclasses import replace
|
||||
process_authority = replace(
|
||||
request_authority, launch_scopes=(seal_launch_scope(backend, roots[0]),))
|
||||
process_operation = resolve_process_operation(process_authority, operation, backend)
|
||||
from src.browser_identity import native_browser, resolve_browser_operation
|
||||
if native_browser(operation, backend):
|
||||
if request_authority is None:
|
||||
@@ -440,8 +459,10 @@ class ToolApprovalStore:
|
||||
if tool_name in NATIVE_FILESYSTEM_TOOLS and backend_operation is not None and isinstance(backend_operation.resource, NativeBackendResource):
|
||||
try:
|
||||
roots = request_authority.resource_roots if request_authority is not None else ()
|
||||
if not roots and workspace and (request_authority is None or not request_authority.inherited):
|
||||
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
|
||||
if not roots and (request_authority is None or not request_authority.inherited):
|
||||
from src.tool_execution import default_agent_workspace
|
||||
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
|
||||
owner=_normalized_owner(owner)),)
|
||||
resource_operation = resolve_filesystem_operation(
|
||||
ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "",
|
||||
request_id=request_authority.request_id if request_authority is not None else "")
|
||||
|
||||
@@ -17,7 +17,7 @@ from types import MappingProxyType
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS, is_public_blocked_tool
|
||||
|
||||
|
||||
class ToolEffect(str, Enum):
|
||||
@@ -515,6 +515,43 @@ def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
|
||||
if not isinstance(tool_name, str):
|
||||
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":
|
||||
payload = content
|
||||
if isinstance(payload, str):
|
||||
@@ -655,9 +692,13 @@ 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 = (
|
||||
str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "0")).strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "1")).strip().lower()
|
||||
not in {"0", "false", "no", "off"}
|
||||
)
|
||||
|
||||
|
||||
@@ -724,6 +765,18 @@ def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> boo
|
||||
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
|
||||
class ToolRunSecurityContext:
|
||||
"""Server-owned integrity state for one agent run."""
|
||||
@@ -740,11 +793,19 @@ class ToolRunSecurityContext:
|
||||
# The bypass affects only this automatic gate; current tool policy, ownership,
|
||||
# workspace confinement, and execution/sandbox restrictions still apply.
|
||||
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:
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
message_list = list(messages or ())
|
||||
if any(
|
||||
if self.delegated_credential:
|
||||
# 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)
|
||||
and isinstance(message.get("metadata"), dict)
|
||||
and message["metadata"].get(
|
||||
@@ -769,6 +830,17 @@ class ToolRunSecurityContext:
|
||||
self.external_untrusted_context_seen = True
|
||||
|
||||
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:
|
||||
return ToolGateDecision(True)
|
||||
if self.approval_gate_bypassed:
|
||||
|
||||
+385
-47
@@ -15,6 +15,7 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import stat
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
@@ -36,6 +37,7 @@ from src.tool_policy import ToolPolicy
|
||||
from src.client_tool_contract import TUI_ROUTED_BRIDGE_TOOL_NAMES
|
||||
from src.constants import (
|
||||
DATA_DIR,
|
||||
AGENT_WORKSPACE_DIR,
|
||||
MAX_DIFF_LINES,
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_READ_CHARS,
|
||||
@@ -69,11 +71,11 @@ _MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
|
||||
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
|
||||
|
||||
# Persistent working directory for agent subprocesses.
|
||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||
# (/app/data) and the local data directory for manual installs.
|
||||
# Using this as cwd and HOME prevents the agent from silently creating files
|
||||
# in ephemeral container layers that are lost on the next rebuild.
|
||||
_AGENT_WORKDIR = DATA_DIR
|
||||
# Resolves to <repo_root>/data/agent_workspace, inside the bind-mounted volume
|
||||
# in Docker (/app/data), so files survive a rebuild as before. The subdirectory
|
||||
# rather than data/ itself keeps agent scratch files and dotfiles out of the
|
||||
# directory holding the session store and the auth database.
|
||||
_AGENT_WORKDIR = AGENT_WORKSPACE_DIR
|
||||
|
||||
|
||||
ExecutionBridgeHandler = Callable[
|
||||
@@ -133,6 +135,12 @@ def get_active_execution_bridge() -> AgentExecutionBridge | None:
|
||||
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(
|
||||
client_runtime_context: Optional[Dict[str, Any]],
|
||||
) -> tuple[str, str] | None:
|
||||
@@ -153,11 +161,7 @@ def _tui_host_bridge_patch_url(
|
||||
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
||||
if not is_host_shell_bridge_url_allowed(url):
|
||||
return None
|
||||
if url.endswith("/run"):
|
||||
url = url[:-4] + "/patch"
|
||||
elif not url.endswith("/patch"):
|
||||
url += "/patch"
|
||||
return url, token
|
||||
return _tui_host_bridge_endpoint_url(url, "/patch"), token
|
||||
|
||||
|
||||
async def _apply_patch_via_tui_host_bridge(
|
||||
@@ -173,7 +177,7 @@ async def _apply_patch_via_tui_host_bridge(
|
||||
import httpx
|
||||
|
||||
timeout = httpx.Timeout(125.0, connect=5.0, write=10.0, pool=5.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers={"x-odysseus-tui-bridge-token": token},
|
||||
@@ -233,7 +237,7 @@ async def _bridge_post(bridge: Dict, path: str, payload: Dict, *, timeout_s: flo
|
||||
"error": f"{err_prefix}: invalid TUI host bridge",
|
||||
"exit_code": 1,
|
||||
}
|
||||
base = url.rsplit("/", 1)[0] if url.endswith(("/run", "/read", "/write")) else url.rstrip("/")
|
||||
target = _tui_host_bridge_endpoint_url(url, path)
|
||||
try:
|
||||
import httpx
|
||||
timeout = httpx.Timeout(
|
||||
@@ -242,9 +246,9 @@ async def _bridge_post(bridge: Dict, path: str, payload: Dict, *, timeout_s: flo
|
||||
write=10.0,
|
||||
pool=5.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
response = await client.post(
|
||||
f"{base}{path}",
|
||||
target,
|
||||
headers={"x-odysseus-tui-bridge-token": token},
|
||||
json=payload,
|
||||
)
|
||||
@@ -300,13 +304,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
|
||||
if not token or not is_host_shell_bridge_url_allowed(url):
|
||||
return
|
||||
base = url.rsplit("/", 1)[0]
|
||||
target = _tui_host_bridge_endpoint_url(url, "/cancel")
|
||||
try:
|
||||
import httpx
|
||||
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
await client.post(
|
||||
f"{base}/cancel",
|
||||
target,
|
||||
headers={"x-odysseus-tui-bridge-token": token},
|
||||
json={"request_id": request_id},
|
||||
)
|
||||
@@ -729,7 +733,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 ""
|
||||
if "\n" in path or "\r" in path:
|
||||
path = ""
|
||||
body_value = args.get("content", "")
|
||||
body_value = args.get("content")
|
||||
if not isinstance(body_value, str):
|
||||
return "write_file: invalid arguments", {
|
||||
"error": "write_file: content must be a string",
|
||||
@@ -739,7 +743,7 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
else:
|
||||
path, _, body = content.partition("\n")
|
||||
path = path.strip()
|
||||
if not path:
|
||||
if not path or path.endswith(("/", "\\")):
|
||||
return "write_file: invalid arguments", {
|
||||
"error": "write_file: path is required",
|
||||
"exit_code": 1,
|
||||
@@ -747,6 +751,17 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
rejected = _text_write_to_binary_artifact_result(content)
|
||||
if rejected is not None:
|
||||
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(
|
||||
bridge,
|
||||
"/write",
|
||||
@@ -773,10 +788,15 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
# 1. Sensitive-subpath deny list — checked FIRST. Blocks .ssh,
|
||||
# .gnupg, shell rc files, token/env files even if the root above
|
||||
# them is on the allowlist.
|
||||
# 2. Allowlist — only the directories the agent legitimately needs
|
||||
# (project data/, system tmp). $HOME is NOT on the default list.
|
||||
# 3. Opt-in extra roots — admin can add broader roots via the
|
||||
# "tool_path_extra_roots" setting (list of path strings).
|
||||
# 2. Application-state deny (_is_app_state_path) - DATA_DIR holds the
|
||||
# session store, auth database, app key and settings, so only
|
||||
# _agent_readable_data_subdirs() is readable inside it.
|
||||
# 3. Allowlist - only the directories the agent legitimately needs
|
||||
# (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
|
||||
@@ -787,6 +807,191 @@ 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]:
|
||||
"""Return the list of directory roots that read_file / write_file
|
||||
may touch. Default: project data/ + system temp dirs. Extra roots
|
||||
@@ -794,9 +999,9 @@ def _tool_path_roots() -> list[str]:
|
||||
"""
|
||||
roots: list[str] = []
|
||||
|
||||
# Project data directory — the agent's primary workspace.
|
||||
from src.constants import DATA_DIR
|
||||
roots.append(DATA_DIR)
|
||||
# The agent's workspace plus the user-content directories inside data/.
|
||||
# The rest of DATA_DIR is denied by _is_app_state_path.
|
||||
roots.extend(_agent_readable_data_subdirs())
|
||||
|
||||
# /tmp (and its macOS realpath /private/tmp).
|
||||
roots.append("/tmp")
|
||||
@@ -853,20 +1058,36 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
"""
|
||||
resource_operation = active_resource_operation()
|
||||
if resource_operation is not None:
|
||||
return resource_operation.resolve_path(raw_path)
|
||||
resolved = 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()
|
||||
if ws:
|
||||
return _resolve_tool_path_in_workspace(ws, raw_path)
|
||||
if raw_path is None or not str(raw_path).strip():
|
||||
raise ValueError("path is required")
|
||||
expanded = os.path.expanduser(str(raw_path).strip())
|
||||
# Relative paths resolve where bash/python run (agent_cwd), not against
|
||||
# the server's cwd, which is outside the allowed roots (#6679).
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(_AGENT_WORKDIR, 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):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
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():
|
||||
if is_inside(root, resolved):
|
||||
@@ -903,11 +1124,19 @@ def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
|
||||
# Confinement says "inside the root"; the deny list says "allowed". They
|
||||
# are separate questions and this one stays here, with the policy that
|
||||
# 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):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
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
|
||||
|
||||
|
||||
@@ -966,6 +1195,10 @@ def vet_workspace(raw: str) -> Optional[str]:
|
||||
resolved = os.path.realpath(os.path.expanduser(raw))
|
||||
if not os.path.isdir(resolved) or _is_sensitive_path(resolved):
|
||||
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
|
||||
# workspace would make every absolute path "inside" it, collapsing the
|
||||
# confinement into host-wide file access. A root is its own dirname, which
|
||||
@@ -977,11 +1210,31 @@ def vet_workspace(raw: str) -> Optional[str]:
|
||||
|
||||
def agent_cwd() -> str:
|
||||
"""Working directory for agent subprocesses (bash/python/background jobs):
|
||||
the active workspace when set, else the persistent data dir."""
|
||||
a validated sealed launch root, active workspace, or agent workspace."""
|
||||
from src.agent_runtime.process_resources import active_process_operation
|
||||
bound = active_process_operation()
|
||||
return (bound.launch.scope.root.path if bound is not None and bound.launch is not None
|
||||
else get_active_workspace() or _AGENT_WORKDIR)
|
||||
if bound is not None and bound.launch is not None:
|
||||
bound.launch.scope.validate()
|
||||
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
|
||||
return default_agent_workspace()
|
||||
|
||||
|
||||
def default_agent_workspace() -> str:
|
||||
"""The agent workspace under the data dir, where subprocesses run when no
|
||||
workspace is bound."""
|
||||
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():
|
||||
@@ -996,19 +1249,28 @@ def _resolve_search_root(raw_path: str) -> str:
|
||||
|
||||
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
|
||||
primary root (project data dir) and a supplied path is confined by the
|
||||
global allowlist + sensitive-file policy.
|
||||
primary root (its workspace under the project data dir) and a supplied path
|
||||
is confined by the global allowlist + sensitive-file policy.
|
||||
"""
|
||||
resource_operation = active_resource_operation()
|
||||
if resource_operation is not None:
|
||||
return resource_operation.resolve_path(raw_path, search=True)
|
||||
resolved = 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()
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
return os.path.realpath(ws) if not raw else _resolve_tool_path_in_workspace(ws, raw)
|
||||
# Resolve the empty case as the workspace path rather than returning
|
||||
# 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:
|
||||
roots = _tool_path_roots()
|
||||
return roots[0] if roots else os.path.realpath(".")
|
||||
default_root = os.path.realpath(AGENT_WORKSPACE_DIR)
|
||||
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)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1026,6 +1288,16 @@ _ADMIN_TOOLS = {
|
||||
"serve_preset",
|
||||
"stop_served_model",
|
||||
"cancel_download",
|
||||
# Read-only Cookbook tools reach require_admin routes over the internal
|
||||
# loopback without an owner check (see NON_ADMIN_BLOCKED_TOOLS in
|
||||
# src/tool_security.py), so they are admin-only at dispatch as well.
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_cached_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
}
|
||||
|
||||
|
||||
@@ -1065,7 +1337,8 @@ def _parse_qualified_mcp_args(tool: str, content: str) -> tuple[Dict, Optional[s
|
||||
if tool.startswith("mcp__email__"):
|
||||
return {}, "Email MCP tool arguments must be a JSON object."
|
||||
return {}, None
|
||||
return parsed, None
|
||||
sanitized = {k: v for k, v in parsed.items() if not k.startswith("_odysseus_")}
|
||||
return sanitized, None
|
||||
|
||||
|
||||
def _parse_generate_image(content: str) -> Dict:
|
||||
@@ -1150,9 +1423,12 @@ def _build_mcp_args(tool: str, content: str) -> Dict:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
decoded = None
|
||||
if isinstance(decoded, dict) and any(k in decoded for k in primaries):
|
||||
return decoded
|
||||
return {k: v for k, v in decoded.items() if not k.startswith("_odysseus_")}
|
||||
parser = _MCP_ARG_PARSERS.get(tool)
|
||||
return parser(content) if parser else {}
|
||||
res = parser(content) if parser else {}
|
||||
if isinstance(res, dict):
|
||||
return {k: v for k, v in res.items() if not k.startswith("_odysseus_")}
|
||||
return res
|
||||
|
||||
|
||||
def _normalize_mcp_text_error(result: Dict) -> Dict:
|
||||
@@ -1169,20 +1445,25 @@ async def _call_mcp_tool(
|
||||
tool: str,
|
||||
content: str,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
|
||||
bound = active_backend_operation()
|
||||
if bound is not None and isinstance(bound.resource, NativeBackendResource):
|
||||
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"Native tool '{tool}' unavailable", "exit_code": 1}
|
||||
return await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner) or {"error": f"Native tool '{tool}' unavailable", "exit_code": 1}
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
if bound is not None:
|
||||
raise ResourceIdentityError("Pinned MCP backend is unavailable")
|
||||
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
|
||||
return await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
|
||||
|
||||
server_id, tool_name = _MCP_TOOL_MAP[tool]
|
||||
qualified = f"mcp__{server_id}__{tool_name}"
|
||||
args = _build_mcp_args(tool, content)
|
||||
if tool == "generate_image":
|
||||
args.pop("_odysseus_owner", None)
|
||||
if owner:
|
||||
args["_odysseus_owner"] = owner
|
||||
result = await mcp.call_tool(qualified, args)
|
||||
|
||||
# Stdio MCP servers can only return TextContent, so an explicit
|
||||
@@ -1192,7 +1473,7 @@ async def _call_mcp_tool(
|
||||
|
||||
# If MCP server not connected, try direct fallback
|
||||
if bound is None and isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
|
||||
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb)
|
||||
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner)
|
||||
if fallback:
|
||||
return fallback
|
||||
|
||||
@@ -1277,6 +1558,31 @@ def _agent_subprocess_env() -> dict:
|
||||
return base
|
||||
|
||||
|
||||
def _legacy_private_browser_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
||||
from src.agent_tools.web_tools_prewave3 import PrivateBrowserTool
|
||||
ctx = {
|
||||
"progress_cb": progress_cb,
|
||||
"subproc_env": _agent_subprocess_env(),
|
||||
"session_id": session_id,
|
||||
"owner": owner,
|
||||
}
|
||||
try:
|
||||
result = await PrivateBrowserTool().execute(operation.input, ctx)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning("legacy private_browser failed: %s", error)
|
||||
return f"{transport}: ERROR", {"error": f"private_browser: {error}", "exit_code": 1}
|
||||
if not isinstance(result, dict):
|
||||
result = {"output": str(result), "exit_code": 0}
|
||||
label = "ERROR" if result.get("error") or result.get("exit_code") not in (None, 0) else "EXECUTED"
|
||||
return f"{transport}: {label}", result
|
||||
|
||||
|
||||
async def _direct_fallback(
|
||||
tool: str,
|
||||
content: str,
|
||||
@@ -1399,6 +1705,14 @@ async def execute_tool_block(
|
||||
"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
|
||||
parent = active_request_authority()
|
||||
if isinstance(authority, RequestAuthority) and parent is not None and authority is not parent:
|
||||
@@ -1442,6 +1756,12 @@ async def execute_tool_block(
|
||||
}
|
||||
|
||||
transport = operation.transport_tool
|
||||
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
||||
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
|
||||
# producer in src.browser_identity refuses every page action in this
|
||||
# build. This compatibility transport requires an explicit opt-in.
|
||||
return await _legacy_private_browser(
|
||||
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
|
||||
if operation.tool == "private_browser":
|
||||
try:
|
||||
_, browser_args = parse_browser_operation(operation.input)
|
||||
@@ -1822,6 +2142,17 @@ async def _execute_tool_block_impl(
|
||||
|
||||
mark_authorized()
|
||||
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:
|
||||
return await dispatched(execution_bridge.route_tool(
|
||||
tool,
|
||||
@@ -1923,7 +2254,7 @@ async def _execute_tool_block_impl(
|
||||
elif tool in _MCP_TOOL_MAP:
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await dispatched(_call_mcp_tool(tool, content, progress_cb=progress_cb))
|
||||
result = await dispatched(_call_mcp_tool(tool, content, progress_cb=progress_cb, owner=owner))
|
||||
elif tool in ("grep", "glob", "ls", "get_workspace", "host_shell"):
|
||||
# Code-navigation tools — no MCP server; run the direct implementation.
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
@@ -2110,7 +2441,7 @@ async def _execute_tool_block_impl(
|
||||
)
|
||||
else:
|
||||
if isinstance(parsed, dict):
|
||||
args = parsed
|
||||
args = {k: v for k, v in parsed.items() if not k.startswith("_odysseus_")}
|
||||
else:
|
||||
_args_error = (
|
||||
f"'{tool}' arguments must be a JSON object, "
|
||||
@@ -2119,11 +2450,12 @@ async def _execute_tool_block_impl(
|
||||
if _args_error is not None:
|
||||
result = {"error": _args_error, "exit_code": 1}
|
||||
else:
|
||||
args = dict(args)
|
||||
args.pop(_EMAIL_MCP_OWNER_ARG, None)
|
||||
args.pop(_EMAIL_MCP_SESSION_ARG, None)
|
||||
if owner:
|
||||
args = dict(args)
|
||||
args[_EMAIL_MCP_OWNER_ARG] = owner
|
||||
if session_id:
|
||||
args = dict(args)
|
||||
args[_EMAIL_MCP_SESSION_ARG] = session_id
|
||||
result = await dispatched(mcp.call_tool(qualified, args))
|
||||
else:
|
||||
@@ -2137,14 +2469,20 @@ async def _execute_tool_block_impl(
|
||||
if parse_error:
|
||||
result = {"error": parse_error, "exit_code": 1}
|
||||
else:
|
||||
args = dict(args)
|
||||
args.pop("_odysseus_owner", None)
|
||||
args.pop("_odysseus_session_id", None)
|
||||
if tool.startswith("mcp__email__"):
|
||||
if owner:
|
||||
args = dict(args)
|
||||
args[_EMAIL_MCP_OWNER_ARG] = owner
|
||||
if session_id:
|
||||
args = dict(args)
|
||||
args[_EMAIL_MCP_SESSION_ARG] = session_id
|
||||
elif tool == "mcp__image_gen__generate_image" or tool.startswith("mcp__image_gen__"):
|
||||
if owner:
|
||||
args["_odysseus_owner"] = owner
|
||||
result = _normalize_mcp_text_error(await dispatched(mcp.call_tool(tool, args)))
|
||||
if tool == "mcp__image_gen__generate_image" or tool.startswith("mcp__image_gen__"):
|
||||
_promote_image_fields(result)
|
||||
else:
|
||||
desc = f"mcp: {tool}"
|
||||
result = {"error": "MCP manager not available", "exit_code": 1}
|
||||
|
||||
+2
-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.",
|
||||
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
|
||||
"pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.",
|
||||
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. 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.",
|
||||
"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.",
|
||||
"private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.",
|
||||
"inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.",
|
||||
"extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.",
|
||||
@@ -129,7 +129,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
|
||||
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
|
||||
"suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.",
|
||||
"generate_image": "Generate 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.",
|
||||
"generate_image": "Generate an AI image from a text prompt. Specify model, size, and quality. Art, illustrations, photos.",
|
||||
"chat_with_model": "Send a message to a different AI model. Compare responses, get specialized help, delegate tasks.",
|
||||
"ask_teacher": "Ask a more capable model for help with a difficult problem. Escalate complex tasks.",
|
||||
"pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.",
|
||||
|
||||
+163
-25
@@ -194,10 +194,33 @@ _TOOL_CODE_OPEN_RE = re.compile(r"<tool_code>\s*\{", 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|>
|
||||
_GEMMA_TOOL_CALL_RE = re.compile(
|
||||
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*(\{[\s\S]*?\})\s*<\|?tool_call\|?>",
|
||||
_GEMMA_TOOL_CALL_OPEN_RE = re.compile(
|
||||
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*\{",
|
||||
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.
|
||||
# Example:
|
||||
@@ -542,6 +565,34 @@ _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*(?:`{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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -858,7 +909,10 @@ def _raw_openai_tool_call_to_block(value) -> Optional[ToolBlock]:
|
||||
elif tool_type in ("grep", "glob", "ls", "edit_file"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
elif tool_type == "write_file":
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
# Keep raw OpenAI JSON on the canonical path so explicit empty 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":
|
||||
parts = [args.get("title", "Untitled")]
|
||||
if args.get("language"):
|
||||
@@ -989,6 +1043,43 @@ def _strip_raw_openai_tool_call_json(text: str) -> str:
|
||||
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(
|
||||
text: str,
|
||||
additional_tool_names: Optional[Iterable[str]] = None,
|
||||
@@ -1015,13 +1106,14 @@ def _parse_qwen3_native_text_call(
|
||||
|
||||
# Qwen native text rendering:
|
||||
# <tool_call><function=manage_notes><parameter=action>list</parameter>...
|
||||
fn_match = re.search(r"<function=([A-Za-z_][\w:.-]*)>\s*([\s\S]*?)\s*</function>", text)
|
||||
fn_match = next(_iter_named_blocks(
|
||||
text, _QWEN_FUNCTION_OPEN_RE, _QWEN_FUNCTION_CLOSE_RE
|
||||
), None)
|
||||
if fn_match:
|
||||
name, raw_body = fn_match.groups()
|
||||
name, raw_body = fn_match
|
||||
args = {}
|
||||
for key, raw_value in re.findall(
|
||||
r"<parameter=([A-Za-z_]\w*)>\s*([\s\S]*?)\s*</parameter>",
|
||||
raw_body,
|
||||
for key, raw_value in _iter_named_blocks(
|
||||
raw_body.rstrip(), _QWEN_PARAMETER_OPEN_RE, _QWEN_PARAMETER_CLOSE_RE,
|
||||
):
|
||||
value = raw_value.strip()
|
||||
if value and value[0] in "[{\"":
|
||||
@@ -1082,7 +1174,7 @@ def _parse_qwen3_native_text_call(
|
||||
or normalized_name in declared_names
|
||||
):
|
||||
args = {}
|
||||
for key, raw_value in re.findall(r"([A-Za-z_]\w*)\s*=\s*(['\"].*?['\"]|[^,]+)", raw_args):
|
||||
for key, raw_value in _iter_qwen_python_args(raw_args):
|
||||
try:
|
||||
args[key] = ast.literal_eval(raw_value.strip())
|
||||
except (ValueError, SyntaxError):
|
||||
@@ -1423,6 +1515,45 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
|
||||
return ToolBlock(tool_name, content.strip())
|
||||
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]:
|
||||
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
|
||||
tool_name = tool_name.strip().lower().replace("-", "_")
|
||||
@@ -1447,12 +1578,7 @@ def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
except Exception:
|
||||
# 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
|
||||
params = dict(_iter_gemma_fallback_items(body))
|
||||
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
return function_call_to_tool_block(tool_name, json.dumps(params))
|
||||
@@ -1506,9 +1632,9 @@ def _iter_delimited(text, open_re, close_re):
|
||||
pos = cm.end()
|
||||
|
||||
|
||||
def _strip_delimited(text: str, open_re, close_re) -> str:
|
||||
"""Remove every ``open_re ... close_re`` span (forward-only; see
|
||||
_iter_delimited). Equivalent to ``open_re([\\s\\S]*?)close_re`` ``re.sub('')``
|
||||
def _strip_delimited(text: str, open_re, close_re, replacement: str = "") -> str:
|
||||
"""Replace every ``open_re ... close_re`` span (forward-only; see
|
||||
_iter_delimited). Equivalent to ``open_re([\\s\\S]*?)close_re`` substitution
|
||||
for these delimiters, without the O(n^2) rescan on unclosed openers."""
|
||||
spans = list(_iter_delimited(text, open_re, close_re))
|
||||
if not spans:
|
||||
@@ -1517,11 +1643,23 @@ def _strip_delimited(text: str, open_re, close_re) -> str:
|
||||
last = 0
|
||||
for match_start, _inner_start, _inner_end, match_end in spans:
|
||||
out.append(text[last:match_start])
|
||||
out.append(replacement)
|
||||
last = match_end
|
||||
out.append(text[last:])
|
||||
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):
|
||||
"""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
|
||||
@@ -1955,10 +2093,10 @@ def parse_tool_blocks(
|
||||
|
||||
# Pattern 4b: Gemma-style <|tool_call|> blocks
|
||||
if not blocks:
|
||||
for m in _GEMMA_TOOL_CALL_RE.finditer(text):
|
||||
tool_name = m.group(1)
|
||||
body = m.group(2)
|
||||
block = _parse_gemma_tool_call(tool_name, body)
|
||||
for tool_name, body in _iter_named_blocks(
|
||||
text, _GEMMA_TOOL_CALL_OPEN_RE, _GEMMA_TOOL_CALL_CLOSE_RE
|
||||
):
|
||||
block = _parse_gemma_tool_call(tool_name, "{" + body + "}")
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
@@ -1995,7 +2133,7 @@ def parse_tool_blocks(
|
||||
# from weaker native-tool models after reading the tool docs but failing to
|
||||
# emit the actual structured call.
|
||||
if not blocks:
|
||||
m = _PLAIN_UI_OPEN_PANEL_RE.search(text)
|
||||
m = next(_iter_plain_ui_open_panel(text), None)
|
||||
if m:
|
||||
blocks.append(ToolBlock("ui_control", f"open_panel {m.group(1).lower()}{m.group(2).lower()}".strip()))
|
||||
|
||||
@@ -2038,7 +2176,7 @@ def strip_tool_blocks(
|
||||
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
|
||||
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
|
||||
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _strip_delimited(cleaned, _GEMMA_TOOL_CALL_OPEN_RE, _GEMMA_TOOL_CALL_CLOSE_RE)
|
||||
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
|
||||
cleaned = _strip_raw_openai_tool_call_json(cleaned)
|
||||
declared_xml_calls = _parse_declared_direct_xml_calls(
|
||||
@@ -2057,7 +2195,7 @@ def strip_tool_blocks(
|
||||
if raw_web_json:
|
||||
_, (start, end) = raw_web_json
|
||||
cleaned = cleaned[:start] + cleaned[end:]
|
||||
cleaned = _PLAIN_UI_OPEN_PANEL_RE.sub("", cleaned)
|
||||
cleaned = _strip_plain_ui_open_panel(cleaned)
|
||||
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
||||
cleaned = _strip_bare_invoke_markup(cleaned)
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
||||
|
||||
+51
-26
@@ -8,6 +8,7 @@ Extracted from agent_tools.py to keep schema definitions separate from
|
||||
tool parsing / execution logic.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -70,7 +71,7 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
|
||||
command = args.get("command")
|
||||
if isinstance(command, str):
|
||||
path, separator, content = command.partition("\n")
|
||||
if separator and path.strip() and content:
|
||||
if separator and path.strip() and content.strip():
|
||||
args["path"] = path.strip()
|
||||
args["content"] = content
|
||||
logger.warning("Normalized legacy `command` write shape to path/content")
|
||||
@@ -113,6 +114,15 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
|
||||
args["url"] = path
|
||||
args.pop("path", None)
|
||||
args.pop("file_path", None)
|
||||
elif (
|
||||
tool_type == "ui_control"
|
||||
and str(args.get("action") or "").casefold() == "open_panel"
|
||||
and not str(args.get("name") or "").strip()
|
||||
):
|
||||
panel = _first_nonempty_string(args, ("panel",))
|
||||
if panel:
|
||||
args["name"] = panel
|
||||
args.pop("panel", None)
|
||||
if tool_type == "manage_documents" and "limit" not in args and "max_results" in args:
|
||||
# Collection APIs use both names across the native tool surface. The
|
||||
# document contract calls this integer ``limit``.
|
||||
@@ -494,14 +504,14 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "youtube_tool",
|
||||
"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.",
|
||||
"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.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["comments", "transcript", "metadata", "latest_channel_video"], "description": "What YouTube data to retrieve. Use latest_channel_video with max_results for latest N uploads from a channel."},
|
||||
"url": {"type": "string", "description": "A youtube.com or youtu.be URL (video, watch?v=, shorts, or a channel /@handle/videos URL). No other hosts."},
|
||||
"video_url": {"type": "string", "description": "A youtube.com or youtu.be video URL"},
|
||||
"video_id": {"type": "string", "description": "The 11-character YouTube video id (e.g. jNQXAC9IVRw)"},
|
||||
"url": {"type": "string", "description": "YouTube video or channel URL"},
|
||||
"video_url": {"type": "string", "description": "YouTube video URL"},
|
||||
"video_id": {"type": "string", "description": "YouTube video id"},
|
||||
"channel_url": {"type": "string", "description": "YouTube channel URL or /@handle/videos URL"},
|
||||
"handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"},
|
||||
"max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"}
|
||||
@@ -904,13 +914,13 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ui_control",
|
||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, tasks, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["toggle", "open_panel", "open_email_reply", "set_mode", "switch_model", "set_theme", "create_theme", "get_theme", "get_toggles"],
|
||||
"description": "The UI action. Use set_theme for presets, create_theme to build a custom theme with any hex colors"},
|
||||
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
|
||||
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, tasks, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
|
||||
"view": {"type": "string", "description": "Optional open_panel subview: calendar day/week/month/year/agenda, or Cookbook models/download, launch/serve, active/running, dependencies, settings."},
|
||||
"value": {"type": "string", "description": "Value: on/off for toggle, agent/chat for set_mode, model name for switch_model, theme name for set_theme, or folder for open_email_reply"},
|
||||
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
|
||||
@@ -1497,7 +1507,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_image",
|
||||
"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.",
|
||||
"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.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1510,23 +1520,6 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_image",
|
||||
"description": "Edit an existing gallery image, preserving it as the source. For follow-ups such as adding an object or changing colors, use action=prompt with the previous tool result's image_id and the edit instructions. This sends the actual image plus prompt to the configured image model and saves a new copy. Also supports upscale and rembg. Report a missing optional dependency or unavailable editing directly; do not install packages or substitute a new text-only generation or shell commands.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_id": {"type": "string", "description": "Gallery image ID or supplied odysseus://attachment/ID reference for an owned upload"},
|
||||
"action": {"type": "string", "enum": ["prompt", "upscale", "rembg"], "description": "Edit action"},
|
||||
"prompt": {"type": "string", "description": "For action=prompt: requested changes, preserving the rest of the source image"},
|
||||
"scale": {"type": "number", "description": "For upscale: scale factor (default 2)"},
|
||||
},
|
||||
"required": ["image_id", "action"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1596,6 +1589,23 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_image",
|
||||
"description": "Edit an existing gallery image, preserving it as the source. For follow-ups such as adding an object or changing colors, use action=prompt with the previous tool result's image_id and the edit instructions. This sends the actual image plus prompt to the configured image model and saves a new copy. Also supports upscale and rembg. Report a missing optional dependency or unavailable editing directly; do not install packages or substitute a new text-only generation or shell commands.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_id": {"type": "string", "description": "Gallery image ID or supplied odysseus://attachment/ID reference for an owned upload"},
|
||||
"action": {"type": "string", "enum": ["prompt", "upscale", "rembg"], "description": "Edit action"},
|
||||
"prompt": {"type": "string", "description": "For action=prompt: requested changes, preserving the rest of the source image"},
|
||||
"scale": {"type": "number", "description": "For upscale: scale factor (default 2)"},
|
||||
},
|
||||
"required": ["image_id", "action"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1990,6 +2000,15 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
},
|
||||
]
|
||||
|
||||
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
|
||||
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
|
||||
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
|
||||
|
||||
FUNCTION_TOOL_SCHEMAS[:] = [
|
||||
_PB_LEGACY if (schema.get("function") or {}).get("name") == "private_browser" else schema
|
||||
for schema in FUNCTION_TOOL_SCHEMAS
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Converter: native function call -> ToolBlock
|
||||
@@ -2269,7 +2288,13 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
elif tool_type == "get_workspace":
|
||||
content = ""
|
||||
elif tool_type == "write_file":
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
body = args.get("content")
|
||||
if isinstance(body, str) and body.strip():
|
||||
content = args.get("path", "") + "\n" + body
|
||||
else:
|
||||
# Preserve missing/empty intent for WriteFileTool instead of folding
|
||||
# all empty shapes into the same path-plus-newline representation.
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "edit_file":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "apply_patch":
|
||||
|
||||
@@ -79,6 +79,22 @@ NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
|
||||
"stop_served_model",
|
||||
"cancel_download",
|
||||
"adopt_served_model",
|
||||
# Read-only Cookbook inspection tools. They look harmless, but every one
|
||||
# of them calls a require_admin route over the internal loopback with the
|
||||
# process-wide INTERNAL_TOOL_TOKEN and never checks the session owner, so
|
||||
# a non-admin agent would inherit admin reach through them:
|
||||
# list_cached_models forwards a caller-chosen `host` into
|
||||
# /api/model/cached, which SSHes to that host with the app user's keys and
|
||||
# walks caller-chosen `model_dir`s; list_served_models / tail_serve_output
|
||||
# expose serve/download logs and running process command lines. Model
|
||||
# serving is admin-only in THREAT_MODEL.md, so block the read side too.
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_cached_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
}
|
||||
|
||||
|
||||
@@ -276,3 +292,16 @@ def blocked_tools_for_owner(owner: Optional[str]) -> Set[str]:
|
||||
if owner_is_admin_or_single_user(owner):
|
||||
return set()
|
||||
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)
|
||||
|
||||
@@ -227,7 +227,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
desc = str(raw_args.get("description") or "")
|
||||
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
|
||||
raw = desc
|
||||
if raw is None or raw == "" or raw is False:
|
||||
if raw in (None, "", False):
|
||||
return None
|
||||
if raw is True:
|
||||
return 10
|
||||
@@ -236,10 +236,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
text = str(raw).strip().lower()
|
||||
if text in {"none", "no", "off", "false"}:
|
||||
return None
|
||||
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
m = re.search(r"(?<!\d)(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)))
|
||||
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
m = re.search(r"(?<!\d)(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)) * 60)
|
||||
if text.isdigit():
|
||||
@@ -251,7 +251,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
if minutes_before is None:
|
||||
return desc
|
||||
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.*$",
|
||||
re.I,
|
||||
)
|
||||
@@ -497,8 +497,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
delta = None
|
||||
if dur:
|
||||
import re as _re_d
|
||||
h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
|
||||
h = _re_d.search(r'(?<!\d)(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(?<!\d)(\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)
|
||||
if secs > 0:
|
||||
delta = timedelta(seconds=secs)
|
||||
|
||||
+13
-3
@@ -16,7 +16,7 @@ from typing import Dict, Optional
|
||||
from src.tools._common import _parse_tool_args
|
||||
|
||||
|
||||
async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
async def do_edit_image(content: str, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Create an owner-scoped edited copy of a gallery image."""
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
@@ -45,7 +45,7 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if not load_settings().get('image_gen_enabled', True):
|
||||
return {'error': 'Image generation is disabled by the administrator.', 'exit_code': 1}
|
||||
from src.ai_interaction import do_edit_image as edit_with_model
|
||||
return await edit_with_model(str(args.get('prompt') or '').strip(), info['path'], owner=owner, size='auto')
|
||||
return await edit_with_model(str(args.get('prompt') or '').strip(), info['path'], owner=owner, size='auto', incognito=incognito)
|
||||
|
||||
from core.database import GalleryImage, SessionLocal
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
@@ -79,7 +79,7 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
session_id = source.session_id
|
||||
db.close()
|
||||
from src.ai_interaction import do_edit_image as edit_with_model
|
||||
return await edit_with_model(prompt, str(source_path), session_id=session_id, owner=owner, size='auto')
|
||||
return await edit_with_model(prompt, str(source_path), session_id=session_id, owner=owner, size='auto', incognito=incognito)
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -111,6 +111,16 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
output_bytes = output.getvalue()
|
||||
width, height = image.size
|
||||
|
||||
if incognito:
|
||||
import base64
|
||||
return {
|
||||
'output': f'Image edited ({action}). Private result, not saved to gallery.',
|
||||
'exit_code': 0, 'image_id': None,
|
||||
'image_url': 'data:image/png;base64,' + base64.b64encode(output_bytes).decode('ascii'),
|
||||
'image_prompt': source.prompt or action, 'image_model': f'edit_image:{action}',
|
||||
'image_size': f'{width}x{height}', 'image_quality': source.quality or '',
|
||||
}
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
(root / filename).write_bytes(output_bytes)
|
||||
|
||||
+1
-1
@@ -322,7 +322,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if looks_like_reminder:
|
||||
temporal = re.search(
|
||||
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
|
||||
r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
|
||||
r"|\b\d{1,2}(?::\d{2})?(?:\s*(?:am|pm))?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
|
||||
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
|
||||
lower_combined,
|
||||
)
|
||||
|
||||
@@ -325,6 +325,26 @@ def _task_structured_schedule(args, fallback_time=None):
|
||||
'cron_expression': f'{parsed.minute} {parsed.hour} * * {cron_days}'}
|
||||
|
||||
|
||||
def _admin_task_action_denied(owner: Optional[str], task_type: Optional[str], action_name: Optional[str]) -> Optional[Dict]:
|
||||
"""Apply the shared admin-only task-action policy to a tool call.
|
||||
|
||||
``POST /api/tasks`` and the scheduler both refuse ``run_local``,
|
||||
``run_script``, ``ssh_command`` and ``cookbook_serve`` action tasks for
|
||||
owners without admin task privileges (``src.task_action_policy``).
|
||||
Those actions reach ``subprocess.run(shell=True)`` / SSH with no sandbox,
|
||||
so the model-facing tool must apply the same policy at create/edit/
|
||||
resume/run time instead of storing a task the scheduler will only pause
|
||||
later. Returns the tool error dict to hand back, or None when allowed.
|
||||
"""
|
||||
from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
if is_admin_only_task_action(task_type, action_name) and not owner_has_admin_task_privileges(owner):
|
||||
return {"error": f"Action '{action_name}' requires admin privileges", "exit_code": 1}
|
||||
return None
|
||||
|
||||
|
||||
async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_tasks tool calls: CRUD on scheduled tasks."""
|
||||
import uuid as _uuid
|
||||
@@ -462,6 +482,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"error": "Prompt is required for llm/research tasks", "exit_code": 1}
|
||||
if task_type == "action" and not args.get("action_name"):
|
||||
return {"error": "action_name is required for action tasks", "exit_code": 1}
|
||||
denied = _admin_task_action_denied(owner, task_type, args.get("action_name"))
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
# Compute next_run for schedule triggers
|
||||
next_run = None
|
||||
@@ -525,6 +548,14 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if owner and task.owner != owner:
|
||||
return {"error": "Access denied", "exit_code": 1}
|
||||
|
||||
# Check the policy against the task as it would be after the
|
||||
# edit, so an llm task can't be flipped into a shell action.
|
||||
next_task_type = args["task_type"] if args.get("task_type") is not None else task.task_type
|
||||
next_action = args["action_name"] if args.get("action_name") is not None else task.action
|
||||
denied = _admin_task_action_denied(owner, next_task_type, next_action)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
if 'weekdays' in args or 'day_of_month' in args:
|
||||
clock = task.scheduled_time
|
||||
if task.schedule == 'cron':
|
||||
@@ -624,6 +655,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if action == "pause":
|
||||
task.status = "paused"
|
||||
else:
|
||||
denied = _admin_task_action_denied(owner, task.task_type, task.action)
|
||||
if denied:
|
||||
return denied
|
||||
task.status = "active"
|
||||
if (task.trigger_type or "schedule") == "schedule":
|
||||
task.next_run = compute_next_run(
|
||||
@@ -644,6 +678,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return error
|
||||
if owner and task.owner != owner:
|
||||
return {"error": "Access denied", "exit_code": 1}
|
||||
denied = _admin_task_action_denied(owner, task.task_type, task.action)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
from src.event_bus import get_task_scheduler
|
||||
scheduler = get_task_scheduler()
|
||||
|
||||
+277
-131
@@ -17,6 +17,7 @@ from typing import Iterable, Mapping
|
||||
|
||||
from src.action_intents import classify_tool_intent
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.text_scanning import has_prefixed_token_match
|
||||
|
||||
|
||||
FAMILY_TOOLS = {
|
||||
@@ -48,6 +49,62 @@ FAMILY_TOOLS = {
|
||||
CONTRACT_CORE_TOOLS = frozenset({
|
||||
"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 = {
|
||||
"calendar": r"\b(?:calendar|calender|events?|appointments?|meetings?|agenda)\b",
|
||||
"notes": r"\b(?:notes?|checklists?|groceries|remind\s+me)\b",
|
||||
@@ -123,13 +180,7 @@ def _normalize_request_lead(value: str) -> str:
|
||||
text,
|
||||
flags=re.I,
|
||||
)
|
||||
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 = _strip_cancelled_request_lead(text)
|
||||
text = re.sub(r"^k(?:ay)?\s*[,!]?\s+(?=\S)", "", text, flags=re.I)
|
||||
text = re.sub(
|
||||
r"^(?:(?:great|nice|cool)\s*[,!.]|thanks?\s*[.!])\s+"
|
||||
@@ -164,6 +215,21 @@ 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"\boffical\b", "official", text, flags=re.I)
|
||||
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(
|
||||
r"^\s*" + _REQUEST_PREFIX + r"(?:reserch|reasearch|reseach)\b",
|
||||
re.I,
|
||||
@@ -196,6 +262,21 @@ _PURE_ACTION_PROHIBITION = re.compile(
|
||||
r"[^.;\n]*[.!?]*\s*$",
|
||||
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)
|
||||
_PANEL_NAVIGATION = re.compile(
|
||||
r"^\s*" + _REQUEST_PREFIX
|
||||
@@ -449,6 +530,107 @@ _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})$",
|
||||
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 = {
|
||||
"calendar": "manage_calendar", "notes": "manage_notes",
|
||||
"tasks": "manage_tasks", "skills": "manage_skills",
|
||||
@@ -716,60 +898,6 @@ 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:
|
||||
"""The explicitly created container owns its content, not vice versa."""
|
||||
text = _normalize_request_lead(message)
|
||||
@@ -908,11 +1036,7 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
|
||||
tools = {"inspect_media"}
|
||||
if (
|
||||
re.search(r"\b(?:create|write|save|build|produce)\b", raw_text, re.I)
|
||||
and re.search(
|
||||
r"(?:file://)?/workspace/[^\s`\"']+\.(?:csv|html?|json|md|svg|txt)\b",
|
||||
raw_text,
|
||||
re.I,
|
||||
)
|
||||
and _mentions_workspace_artifact(raw_text)
|
||||
):
|
||||
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):
|
||||
@@ -2175,6 +2299,34 @@ _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]:
|
||||
"""Strip only whole, known presentation/safety suffixes, never actions."""
|
||||
text = _normalize_request_lead(message)
|
||||
@@ -2224,11 +2376,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
if keep_few_suffix:
|
||||
maximum = 3
|
||||
text = text[:keep_few_suffix.start()].strip()
|
||||
few_suffix = re.search(
|
||||
few_suffix = _terminal_clause_match(
|
||||
text,
|
||||
r"[,.;?]\s*(?:(?:only|just)\s+)?(?:(?:list|show)\s+(?:me\s+)?)?a\s+few"
|
||||
r"(?:\s+(?:task\s+)?(?:names?|items?|results?|entries?))?"
|
||||
r"(?:\s+and\s+(?:whether|if)\s+[^.;\n]+)?[.!?]*\s*$",
|
||||
text, re.I,
|
||||
r"(?:\s+and\s+(?:whether|if)\s+[^.;\n]+)?",
|
||||
)
|
||||
if few_suffix:
|
||||
maximum = 3
|
||||
@@ -2240,42 +2392,43 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
text,
|
||||
flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
terminal_read_only = _terminal_clause_match(
|
||||
text,
|
||||
r"[.;]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
||||
r"(?:(?:and\s+)?(?:do\s+not|don['’]?t|dont)\s+"
|
||||
r"(?:change|edit|modify)(?:\s+or\s+send)?\s+(?:anything|data))?"
|
||||
r"[.!?]*\s*$",
|
||||
"",
|
||||
text,
|
||||
flags=re.I,
|
||||
).strip()
|
||||
r"(?:change|edit|modify)(?:\s+or\s+send)?\s+(?:anything|data))?",
|
||||
)
|
||||
if terminal_read_only:
|
||||
text = text[:terminal_read_only.start()].strip()
|
||||
# Explanatory/safety tails do not alter a preceding exact read request.
|
||||
text = re.sub(
|
||||
safety_tail = _terminal_clause_match(
|
||||
text,
|
||||
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()
|
||||
r"(?:touch|change|edit|modify)(?:\s+(?:anything|data|them))?(?:\s+yet)?",
|
||||
)
|
||||
if safety_tail:
|
||||
text = text[:safety_tail.start()].strip()
|
||||
text = re.sub(
|
||||
r"[.!?]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
||||
r"[.!?]\s*+read[- ]only(?:\s++(?:please|pls|plz))?\s*+(?:,\s*+)?"
|
||||
r"(?:do\s+not|don['’]?t|dont)\s+(?:change|edit|modify)\s+"
|
||||
r"(?:or\s+send\s+)?anything[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+changes?[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+edits?[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
for terminal_core in (
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+changes?",
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+edits?",
|
||||
):
|
||||
terminal_match = _terminal_clause_match(text, terminal_core)
|
||||
if terminal_match:
|
||||
text = text[:terminal_match.start()].strip()
|
||||
text = re.sub(
|
||||
r"[,;]\s*no\s+edits?[.!?]*\s*$", "", text, flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+writes?[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
terminal_no_write = _terminal_clause_match(
|
||||
text, r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+writes?"
|
||||
)
|
||||
if terminal_no_write:
|
||||
text = text[:terminal_no_write.start()].strip()
|
||||
text = re.sub(
|
||||
r"[.!?]\s*(?:i['’]?m|i\s+am)\s+(?:just\s+)?checking\b[^\n]*$",
|
||||
"", text, flags=re.I,
|
||||
@@ -2293,12 +2446,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
text,
|
||||
flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"[,;]\s*(?:keep\s+(?:them|it)\s+)?short\s+lines?\s*,?[.!?]*\s*$",
|
||||
"",
|
||||
text,
|
||||
flags=re.I,
|
||||
).strip()
|
||||
short_lines = _terminal_clause_match(
|
||||
text, r"[,;]\s*(?:keep\s+(?:them|it)\s+)?short\s+lines?\s*,?"
|
||||
)
|
||||
if short_lines:
|
||||
text = text[:short_lines.start()].strip()
|
||||
text = re.sub(
|
||||
r"[,.;]\s*keep\s+(?:the\s+answer|it|them)\s+short[.!?]*\s*$",
|
||||
"",
|
||||
@@ -2323,11 +2475,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||
text = text[:approximate_limit.start()].strip()
|
||||
natural_limit = re.search(
|
||||
r"(?:[,.;?]|[—–-]|\s+but\s+|\s+)\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"|cap(?:\s+(?:it|them|the\s+(?:answer|list)))?\s+at\s+"
|
||||
r"|(?:maybe\s+)?(?:first|same)\s+)"
|
||||
r"(?:[,.;?]|[—–-]|(?<!\s)\s++but\s++|(?<!\s)\s++)\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"|cap(?:\s++(?:it|them|the\s++(?:answer|list)))?\s++at\s++"
|
||||
r"|(?:maybe\s++)?(?:first|same)\s++)"
|
||||
r"(" + _READ_COUNT + r")"
|
||||
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?|bits?|things?))?"
|
||||
r"(?:\s*(?:and|\+)\s+(?:their\s+)?(?:status(?:es)?|states?))?"
|
||||
@@ -2365,7 +2517,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||
text = text[:compact_limit.start()].strip()
|
||||
conversational_limit = re.search(
|
||||
r"(?:[,.;?]\s*|\s+)(?:maybe\s+)?(?:keep\s+it\s+to\s+|stick\s+to\s+|(?:first|top)\s+)"
|
||||
r"(?:[,.;?]\s*|(?<!\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"[.!?]*\s*$",
|
||||
text, re.I,
|
||||
@@ -2375,7 +2527,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
value = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||
maximum = value if maximum is None else min(maximum, value)
|
||||
text = text[:conversational_limit.start()].rstrip(' ,.;?')
|
||||
text = re.sub(r"\s+but\s*$", "", text, flags=re.I)
|
||||
text = _strip_terminal_but(text)
|
||||
need_limit = re.search(
|
||||
r"[.!?]\s*(?:i\s+)?only\s+need\s+(" + _READ_COUNT + r")"
|
||||
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
||||
@@ -2845,6 +2997,18 @@ def _natural_safe_inventory_operation(message: str, maximum: int | None = None)
|
||||
return RequiredReadOperation(tool, {"action": action} if action else {}, maximum)
|
||||
|
||||
|
||||
def note_read_intent(user_text: str) -> tuple[str, bool]:
|
||||
"""Return a quoted mention query and whether the note body is needed."""
|
||||
text = str(user_text or "")
|
||||
mention = re.search(r"\bmentions?\s+(['\"])([^'\"]{1,120})\1", text, re.I)
|
||||
query = mention.group(2).strip() if mention else ""
|
||||
needs_body = bool(
|
||||
re.search(r"\b(?:summari[sz]e|summary|main\s+topics)\b", text, re.I)
|
||||
or re.search(r"\b(?:show|open|read|view)\b.{0,80}\b(?:first|newest|most\s+recent)\s+note\b", text, re.I)
|
||||
)
|
||||
return query, needs_body
|
||||
|
||||
|
||||
def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None:
|
||||
"""Resolve complete list/read requests, or repeat an exact prior read.
|
||||
|
||||
@@ -4014,7 +4178,7 @@ def _families_for_tool(tool: str) -> frozenset[str]:
|
||||
def _clause_capabilities(text: str) -> set[str]:
|
||||
# A prohibition constrains authority; it must never grant the family named
|
||||
# only as the forbidden side effect (for example, "do not create a file").
|
||||
if _PURE_ACTION_PROHIBITION.fullmatch(text):
|
||||
if _is_pure_action_prohibition(text):
|
||||
return set()
|
||||
container_tool = creation_container_tool(text)
|
||||
if container_tool:
|
||||
@@ -4158,11 +4322,6 @@ def _clause_capabilities(text: str) -> set[str]:
|
||||
if intent.reason == "terse calendar follow-up action":
|
||||
return set()
|
||||
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 & {
|
||||
"calendar", "notes", "tasks", "skills", "memory", "documents", "email",
|
||||
}
|
||||
@@ -4270,14 +4429,6 @@ def _clause_capabilities(text: str) -> set[str]:
|
||||
mapped = "tasks"
|
||||
if mapped in FAMILY_TOOLS:
|
||||
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;
|
||||
# merely discussing a domain must not offer its mutation tools.
|
||||
return words if _has_action_signal(text) or personal_lookup else set()
|
||||
@@ -4688,12 +4839,7 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
||||
raw_text,
|
||||
re.I,
|
||||
)
|
||||
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,
|
||||
)
|
||||
and _mentions_workspace_output(raw_text)
|
||||
):
|
||||
families.add("shell_files")
|
||||
if re.search(
|
||||
@@ -5152,7 +5298,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(?:price|cheap|under|dimensions?|quote|trustworthy|app)\b", text, re.I)
|
||||
)
|
||||
or re.search(r"\bunder\s+[¥$€£]?\s*\d+(?:[.,]\d+)?(?:\s*yen)?\b", text, re.I)
|
||||
or _mentions_under_budget(text)
|
||||
):
|
||||
return frozenset({"search_browser"})
|
||||
if recent_family == ("search_browser",) and re.fullmatch(
|
||||
@@ -6089,8 +6235,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
||||
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)):
|
||||
families = {"search_browser"}
|
||||
if not families and (recall := _WARM_RECALL.fullmatch(text)):
|
||||
target = recall["target"].lower()
|
||||
if not families and (recall := _warm_recall_parts(text)):
|
||||
target = recall[0].lower()
|
||||
family = {
|
||||
"email": "email", "emails": "email", "inbox": "email",
|
||||
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
||||
@@ -6102,8 +6248,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
||||
}[target]
|
||||
if family in recently_executed_families(history):
|
||||
families.add(family)
|
||||
if not families and (recall := _WARM_RECALL_WITH_FOLLOWUP.fullmatch(text)):
|
||||
target = recall["target"].lower()
|
||||
if not families and (recall := _warm_recall_parts(text, with_followup=True)):
|
||||
target = recall[0].lower()
|
||||
family = {
|
||||
"email": "email", "emails": "email", "inbox": "email",
|
||||
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
||||
|
||||
+121
-2
@@ -12,7 +12,7 @@ break the primary use case. What it *always* rejects:
|
||||
- a non-HTTP(S) scheme (``file://``, ``gopher://``, ``ftp://`` …), and
|
||||
- the link-local range (``169.254.0.0/16`` / ``fe80::/10``), i.e. the cloud
|
||||
instance-metadata SSRF credential-exfil vector — nobody serves embeddings
|
||||
there — plus multicast / reserved / unspecified addresses.
|
||||
there, plus multicast / non-loopback reserved / unspecified addresses.
|
||||
|
||||
For exposed multi-tenant deployments, set ``EMBEDDING_BLOCK_PRIVATE_IPS=true`` to
|
||||
additionally reject all private and loopback targets (full SSRF lockdown).
|
||||
@@ -57,6 +57,10 @@ _SHARED_ADDRESS_SPACE_V4 = ipaddress.ip_network("100.64.0.0/10")
|
||||
# membership test excludes and which stays rejected as reserved.
|
||||
_NAT64_WELL_KNOWN_PREFIX_V6 = ipaddress.ip_network("64:ff9b::/96")
|
||||
|
||||
_LOCALHOST_NAMES = frozenset({"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"})
|
||||
# Alibaba's metadata endpoint is shared address space, not link-local.
|
||||
_METADATA_IPS = {ipaddress.ip_address("100.100.100.200")}
|
||||
|
||||
|
||||
def _nat64_well_known_embedded_ipv4(
|
||||
ip: ipaddress._BaseAddress,
|
||||
@@ -92,13 +96,19 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st
|
||||
if reason:
|
||||
return f"NAT64 translated destination blocked: {reason}"
|
||||
return None
|
||||
if ip in _METADATA_IPS:
|
||||
return f"metadata endpoint blocked: {ip}"
|
||||
if ip.is_link_local:
|
||||
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:
|
||||
return f"disallowed address: {ip}"
|
||||
if block_private and (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or (isinstance(ip, ipaddress.IPv4Address) and ip in _SHARED_ADDRESS_SPACE_V4)
|
||||
):
|
||||
return f"private/shared/loopback address blocked: {ip}"
|
||||
@@ -154,3 +164,112 @@ def check_outbound_url(
|
||||
if not saw_ip:
|
||||
return False, "host does not resolve to an IP"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
|
||||
def check_outbound_host(
|
||||
host: str,
|
||||
*,
|
||||
block_private: bool = False,
|
||||
resolver: Optional[Callable[[str], List[str]]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Validate a bare mail hostname/IP, rejecting unresolved destinations.
|
||||
|
||||
Saving an unresolvable hostname is unsafe: another consumer of the stored
|
||||
configuration may later connect after its DNS records change. Live mail
|
||||
connections separately pin their sockets with connect_outbound_tcp.
|
||||
"""
|
||||
if not isinstance(host, str):
|
||||
return False, "host must be a string"
|
||||
host = host.strip()
|
||||
if not host:
|
||||
return False, "host is required"
|
||||
if any(ch.isspace() for ch in host) or any(ch in host for ch in "/\\@?#"):
|
||||
return False, "host must be a bare hostname or IP address"
|
||||
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||
try:
|
||||
ip = ipaddress.ip_address(literal.split("%")[0])
|
||||
except ValueError:
|
||||
if ":" in literal:
|
||||
return False, "host must be a bare hostname or IP address"
|
||||
else:
|
||||
reason = _classify(ip, block_private=block_private)
|
||||
return (False, reason) if reason else (True, "ok")
|
||||
name = host.lower().rstrip(".")
|
||||
if name in _LOCALHOST_NAMES or name.endswith(".localhost"):
|
||||
if block_private:
|
||||
return False, f"private/shared/loopback address blocked: {host}"
|
||||
return True, "ok"
|
||||
try:
|
||||
ips = (resolver or _default_resolver)(host)
|
||||
if not ips:
|
||||
return False, "host does not resolve to an IP"
|
||||
saw_ip = False
|
||||
for raw in ips:
|
||||
# Malformed answers fail closed, including mixed valid/invalid ones.
|
||||
ip = ipaddress.ip_address(raw.split("%")[0])
|
||||
saw_ip = True
|
||||
reason = _classify(ip, block_private=block_private)
|
||||
if reason:
|
||||
return False, reason
|
||||
except Exception as exc:
|
||||
return False, f"host does not resolve to valid IP addresses: {exc}"
|
||||
if not saw_ip:
|
||||
return False, "host does not resolve to an IP"
|
||||
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.
|
||||
|
||||
An injected resolver must implement socket.getaddrinfo's argument and
|
||||
result contract. Resolver errors propagate without an unvetted retry.
|
||||
"""
|
||||
if isinstance(port, bool) or not isinstance(port, (int, str)):
|
||||
raise ValueError("port must be an integer between 1 and 65535")
|
||||
port = int(port)
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port must be an integer between 1 and 65535")
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Reasoning effort for utility-role LLM calls.
|
||||
|
||||
One owner for the rule. Precedence for a utility call:
|
||||
|
||||
1. an explicit caller effort (never touched here);
|
||||
2. the ``utility_reasoning_effort`` setting, sent only to routes on the
|
||||
utility chain (the Utility endpoint/model plus its fallbacks, matched by
|
||||
``(url, model)``);
|
||||
3. the chat session's own effort (``thinking_mode == "effort:<level>"``), when
|
||||
the call is made for a chat session and its route is that session's route.
|
||||
|
||||
Every level is validated against what the model advertises; a model without
|
||||
evidence gets nothing. Calls with no session only ever use (2).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def utility_routes(owner: Optional[str] = None) -> set:
|
||||
"""The ``(url, model)`` routes that make up the utility chain."""
|
||||
from src.endpoint_resolver import resolve_endpoint, resolve_utility_fallback_candidates
|
||||
|
||||
return {
|
||||
(url, model)
|
||||
for url, model, _headers in [
|
||||
resolve_endpoint("utility", owner=owner),
|
||||
*resolve_utility_fallback_candidates(owner=owner),
|
||||
]
|
||||
if url and model
|
||||
}
|
||||
|
||||
|
||||
def configured_effort(owner: Optional[str] = None) -> str:
|
||||
"""The raw setting, lowercased; empty when unset (the default)."""
|
||||
from src.settings import get_user_setting
|
||||
|
||||
return str(get_user_setting("utility_reasoning_effort", owner or "", "") or "").strip().lower()
|
||||
|
||||
|
||||
def fallback_route_efforts(owner: Optional[str] = None) -> dict[tuple[str, str], str]:
|
||||
"""Configured effort per fallback candidate in ``utility_model_fallbacks``."""
|
||||
from src.endpoint_resolver import resolve_endpoint_by_id
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
chain = get_user_setting(
|
||||
"utility_model_fallbacks",
|
||||
owner or "",
|
||||
settings.get("utility_model_fallbacks") or [],
|
||||
) or []
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
res = {}
|
||||
for entry in chain:
|
||||
if not isinstance(entry, dict) or "reasoning_effort" not in entry:
|
||||
continue
|
||||
effort = str(entry.get("reasoning_effort") or "").strip().lower()
|
||||
ep = resolve_endpoint_by_id(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
)
|
||||
if ep:
|
||||
url, model, _ = ep
|
||||
if url and model:
|
||||
res[(url, model)] = effort
|
||||
return res
|
||||
|
||||
|
||||
def effort_for_route(url: str, model: str, owner: Optional[str] = None) -> Optional[str]:
|
||||
"""Validated effort for ``(url, model)``, or None when it must not be sent."""
|
||||
fb_efforts = fallback_route_efforts(owner)
|
||||
if (url, model) in fb_efforts:
|
||||
fb_effort = fb_efforts[(url, model)]
|
||||
if not fb_effort or fb_effort in {"off", "none", "default"}:
|
||||
return None
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
return validate_reasoning_effort(model, fb_effort)
|
||||
|
||||
effort = configured_effort(owner)
|
||||
if not effort or (url, model) not in utility_routes(owner):
|
||||
return None
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
return validate_reasoning_effort(model, effort)
|
||||
|
||||
|
||||
def session_effort(session) -> Optional[str]:
|
||||
"""The raw effort level the chat session is set to, or None."""
|
||||
mode = str(getattr(session, "thinking_mode", "") or "")
|
||||
if not mode.startswith("effort:"):
|
||||
return None
|
||||
return mode[len("effort:"):].strip().lower() or None
|
||||
|
||||
|
||||
def _is_session_route(url: str, model: str, session) -> bool:
|
||||
from src.endpoint_resolver import same_endpoint_base
|
||||
|
||||
return bool(
|
||||
model
|
||||
and model == getattr(session, "model", None)
|
||||
and same_endpoint_base(url, getattr(session, "endpoint_url", None))
|
||||
)
|
||||
|
||||
|
||||
def effort_for_call(url: str, model: str, owner: Optional[str] = None, session=None) -> Optional[str]:
|
||||
"""Effort an owner-aware utility call should send to ``(url, model)``.
|
||||
|
||||
``session`` is the chat session the call is made for, or None for
|
||||
session-less work (scheduled tasks, pollers, auto-sort). Never raises:
|
||||
a lookup failure means "send nothing".
|
||||
"""
|
||||
try:
|
||||
raw = configured_effort(owner)
|
||||
if raw in {"off", "none", "default"}:
|
||||
return None
|
||||
effort = effort_for_route(url, model, owner)
|
||||
if effort or session is None:
|
||||
return effort
|
||||
level = session_effort(session)
|
||||
if not level or not _is_session_route(url, model, session):
|
||||
return None
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
return validate_reasoning_effort(model, level)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def candidate_effort_factory(owner=None):
|
||||
"""Per-candidate factory for `llm_call_async_with_fallback`.
|
||||
|
||||
Returns None when neither the setting nor any fallback effort is configured.
|
||||
Otherwise a factory sending the effort only to utility-chain candidates
|
||||
(owner-scoped) whose model advertises it.
|
||||
"""
|
||||
raw = configured_effort(owner)
|
||||
has_primary = bool(raw and raw not in {"off", "none", "default"})
|
||||
fb_efforts = fallback_route_efforts(owner)
|
||||
has_fb = any(eff and eff not in {"off", "none", "default"} for eff in fb_efforts.values())
|
||||
if not has_primary and not has_fb:
|
||||
return None
|
||||
|
||||
def factory(_index, url, model, _headers):
|
||||
effort = effort_for_route(url, model, owner)
|
||||
return {"kwargs": {"reasoning_effort": effort}} if effort else {}
|
||||
|
||||
return factory
|
||||
@@ -61,4 +61,3 @@ def backend_workspace_path(path: str | None) -> str:
|
||||
return container_root
|
||||
return str(PurePosixPath(container_root) / rel)
|
||||
return expanded
|
||||
|
||||
|
||||
Reference in New Issue
Block a user