mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
Merge current dev and refresh PR #6738 from the 7011 integration
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user