mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-28 16:52:22 +01:00
feat(security): apply audited PR #6738 remediations and test hardening
- Enforce vision probe SSRF safe local host validation, SafeProbeBackend TOCTOU elimination, and session owner authorization - Authorize session titling endpoints against registered owner configuration - Sanitize sensitive metadata logging from agent loop debug logs - Add async non-blocking execution for spec router tools and thinking decisions - Harden email cache invalidation fencing and thread-safe mutation - Add regression test suites for legacy browser authority, spec contracts, spec router request scoping, and task MCP delivery
This commit is contained in:
+154
-139
@@ -21061,6 +21061,103 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_author
|
||||
|
||||
|
||||
|
||||
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
disabled = set(disabled_tools or ())
|
||||
from src.spec_router import latest_decision
|
||||
_spec_decision = latest_decision() or {}
|
||||
_spec_choice = _spec_decision.get("choice")
|
||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
and decided and _spec_choice == "answer_directly")
|
||||
try:
|
||||
_mgr = get_mcp_manager()
|
||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||
except Exception:
|
||||
mcp_schemas = []
|
||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||
if _spec_choice == "ask_user":
|
||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||
# can act when the detail is there or ask when it is not.
|
||||
_top = _spec_decision.get("top") or []
|
||||
if _top:
|
||||
picks = set(picks) | {_top[0][0]}
|
||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
base_offered = set(turn_contract.offered)
|
||||
# The router sees the full catalog, while this request's policy may
|
||||
# deny a tool (for example, a browser turn with Web access switched
|
||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||
# old empty menu instead lets the model invent a tool result in prose.
|
||||
_top = _spec_decision.get("top") or []
|
||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||
_primary = f"mcp__email__{_primary}"
|
||||
# A policy denial is surfaced even if the router chose outside the
|
||||
# base inventory; it never grants access to that tool.
|
||||
def _denied(name):
|
||||
return (name in disabled
|
||||
or (tool_policy is not None and (
|
||||
tool_policy.blocks(name)
|
||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||
_unavailable_pick = ""
|
||||
if _primary in picks and _denied(_primary):
|
||||
_unavailable_pick = _primary
|
||||
elif picks and all(_denied(name) for name in picks):
|
||||
_unavailable_pick = sorted(picks)[0]
|
||||
if _unavailable_pick:
|
||||
blocked = replace(
|
||||
turn_contract, required=frozenset(), offered=frozenset(),
|
||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||
)
|
||||
_spec_active_contract.set(blocked)
|
||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||
return blocked
|
||||
want = set(picks) - disabled
|
||||
# Always-on basics only when the base turn offers them, so the Spec
|
||||
# arm never offers a tool the base arm would not.
|
||||
if not _spec_direct:
|
||||
want |= {"ask_user", "update_plan"} & base_offered
|
||||
if not (strict and decided):
|
||||
want |= base_offered
|
||||
want &= base_offered
|
||||
if not want:
|
||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||
sorted(picks), sorted(base_offered))
|
||||
return turn_contract
|
||||
offer = want & base_offered & set(turn_contract.executable)
|
||||
# A router may narrow the menu only when every existing obligation
|
||||
# remains satisfiable. It cannot redefine the user's requested work.
|
||||
required_read = getattr(turn_contract, "required_read_operation", None)
|
||||
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
|
||||
or (required_read is not None and required_read.tool not in offer)):
|
||||
return turn_contract
|
||||
by_name = {}
|
||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer and nm not in by_name:
|
||||
by_name[nm] = sc
|
||||
for sc in turn_contract.schemas():
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer:
|
||||
by_name[nm] = sc
|
||||
if not by_name and not _spec_direct:
|
||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||
return turn_contract
|
||||
# Required tools and the required read carry over unchanged: harness
|
||||
# completion logic keys on them (e.g. a calendar list is a read, not an
|
||||
# effect that may end with "Done."). If one lost its schema, contract
|
||||
# validation fails and the original contract is retained below.
|
||||
new = replace(
|
||||
turn_contract, offered=frozenset(by_name),
|
||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||
routing_experiment="spec-router")
|
||||
_spec_active_contract.set(new)
|
||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||
return new
|
||||
|
||||
|
||||
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=None):
|
||||
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
||||
@@ -21069,10 +21166,17 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
||||
Runs before any runtime dispatch so the compact preview runtime and the
|
||||
main loop both serve the router's set; only the offered tools change.
|
||||
"""
|
||||
# Contexts can be inherited by a child task; a turn must never inherit a
|
||||
# previous turn's decision when routing is disabled or unavailable.
|
||||
from src import spec_router as _router_context
|
||||
if hasattr(_router_context, "clear_decision"):
|
||||
_router_context.clear_decision()
|
||||
if (turn_contract is None
|
||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||
return turn_contract
|
||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||
return turn_contract
|
||||
try:
|
||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||
return turn_contract
|
||||
@@ -21080,105 +21184,38 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
||||
return turn_contract
|
||||
try:
|
||||
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
disabled = set(disabled_tools or ())
|
||||
picks, decided = _spec_route_tools_early(
|
||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||
from src.spec_router import LAST_DECISIONS as _spec_decisions
|
||||
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
|
||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
and decided and _spec_choice == "answer_directly")
|
||||
try:
|
||||
_mgr = get_mcp_manager()
|
||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||
except Exception:
|
||||
mcp_schemas = []
|
||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||
if _spec_choice == "ask_user":
|
||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||
# can act when the detail is there or ask when it is not.
|
||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||
if _top:
|
||||
picks = set(picks) | {_top[0][0]}
|
||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
base_offered = set(turn_contract.offered)
|
||||
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
# The router sees the full catalog, while this request's policy may
|
||||
# deny a tool (for example, a browser turn with Web access switched
|
||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||
# old empty menu instead lets the model invent a tool result in prose.
|
||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||
_primary = f"mcp__email__{_primary}"
|
||||
# `executable` belongs to the base contract. In subset=0, Spec may
|
||||
# legitimately add a policy-permitted tool that the base inventory did
|
||||
# not include. Only an explicit request-policy denial blocks the pick.
|
||||
def _denied(name):
|
||||
return (name in disabled
|
||||
or (tool_policy is not None and (
|
||||
tool_policy.blocks(name)
|
||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||
_unavailable_pick = ""
|
||||
if _primary in picks and _denied(_primary):
|
||||
_unavailable_pick = _primary
|
||||
elif picks and all(_denied(name) for name in picks):
|
||||
_unavailable_pick = sorted(picks)[0]
|
||||
if _unavailable_pick:
|
||||
blocked = replace(
|
||||
turn_contract, required=frozenset(), offered=frozenset(),
|
||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||
)
|
||||
_spec_active_contract.set(blocked)
|
||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||
return blocked
|
||||
want = set(picks) - disabled
|
||||
# Always-on basics only when the base turn offers them, so the Spec
|
||||
# arm never offers a tool the base arm would not.
|
||||
if not _spec_direct:
|
||||
want |= {"ask_user", "update_plan"} & base_offered
|
||||
if not (strict and decided):
|
||||
want |= base_offered
|
||||
if subset_of_base:
|
||||
# A/B control: Spec offered set is a subset of the base offered set
|
||||
# (measures narrowing only). Empty intersection keeps the base set.
|
||||
want &= base_offered
|
||||
if not want:
|
||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||
sorted(picks), sorted(base_offered))
|
||||
return turn_contract
|
||||
offer = want if strict else (want & set(turn_contract.executable))
|
||||
by_name = {}
|
||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer and nm not in by_name:
|
||||
by_name[nm] = sc
|
||||
for sc in turn_contract.schemas():
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer:
|
||||
by_name[nm] = sc
|
||||
if not by_name and not _spec_direct:
|
||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||
return turn_contract
|
||||
# Keep the contract's required tools and required read whenever they
|
||||
# are still offered: harness completion logic keys on them (e.g. a
|
||||
# calendar list is a read, not an effect that may end with "Done.").
|
||||
_rro = getattr(turn_contract, "required_read_operation", None)
|
||||
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
|
||||
new = replace(
|
||||
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
|
||||
offered=frozenset(by_name),
|
||||
executable=frozenset(set(turn_contract.executable) | set(by_name)),
|
||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
|
||||
_spec_active_contract.set(new)
|
||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||
return new
|
||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||
except Exception as exc:
|
||||
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
|
||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||
return turn_contract
|
||||
|
||||
|
||||
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=None):
|
||||
"""Asynchronous non-blocking turn contract routing."""
|
||||
from src import spec_router as _router_context
|
||||
if hasattr(_router_context, "clear_decision"):
|
||||
_router_context.clear_decision()
|
||||
if (turn_contract is None
|
||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||
return turn_contract
|
||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||
return turn_contract
|
||||
try:
|
||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||
return turn_contract
|
||||
except Exception:
|
||||
return turn_contract
|
||||
try:
|
||||
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
|
||||
picks, decided = await _spec_route_tools_early_async(
|
||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||
except Exception as exc:
|
||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||
return turn_contract
|
||||
|
||||
|
||||
@@ -21263,20 +21300,11 @@ async def stream_agent_loop(
|
||||
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
||||
and not (tool_policy and tool_policy.block_all_tool_calls)
|
||||
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
||||
from src.spec_driver import stream_spec_turn
|
||||
async for chunk in stream_spec_turn(
|
||||
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
|
||||
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
|
||||
client_runtime_context=client_runtime_context,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
|
||||
temperature=0.0, request_authority=request_authority,
|
||||
):
|
||||
yield chunk
|
||||
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
turn_contract = _spec_router_contract(
|
||||
turn_contract = await _spec_router_contract_async(
|
||||
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||
tool_policy=tool_policy)
|
||||
if (turn_contract is not None
|
||||
@@ -21289,6 +21317,20 @@ async def stream_agent_loop(
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
try:
|
||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||
except Exception:
|
||||
_spec_turn = False
|
||||
# Without a turn contract (scheduled, skill and monitor runs) no Spec
|
||||
# routing applies; the turn runs on the compact surface as usual.
|
||||
if _spec_turn and _blocks_before_inference(turn_contract):
|
||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
|
||||
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
if is_compact_preview_contract(turn_contract):
|
||||
from src.clean_agent_preview import stream_preview
|
||||
async for chunk in stream_preview(
|
||||
@@ -21314,26 +21356,9 @@ async def stream_agent_loop(
|
||||
yield chunk
|
||||
return
|
||||
|
||||
_spec_turn = False
|
||||
try:
|
||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||
except Exception:
|
||||
_spec_turn = False
|
||||
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
|
||||
# Ajax Spec: the router, not the regex turn contract, decides the tools.
|
||||
# Drop the contract here and in the dispatcher's context so neither the
|
||||
# route build nor execute_tool_block enforces its offered set.
|
||||
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
|
||||
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
|
||||
turn_contract = None
|
||||
try:
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
_spec_active_contract.set(None)
|
||||
except Exception as _exc:
|
||||
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
|
||||
if turn_contract is not None:
|
||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||
if not _spec_turn and _blocks_before_inference(turn_contract):
|
||||
if _blocks_before_inference(turn_contract):
|
||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||
clarification = (
|
||||
"I can’t perform this request with the currently permitted tools "
|
||||
@@ -24336,7 +24361,7 @@ async def stream_agent_loop(
|
||||
# "spec" = compact tools plus a small-model router; the harness-facing
|
||||
# surface already reads "compact", so detect spec from the raw mode.
|
||||
try:
|
||||
_spec_surface = _configured_model_tool_surface_raw(
|
||||
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
|
||||
candidate_url, candidate_model, owner, headers=candidate_headers,
|
||||
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
||||
except Exception:
|
||||
@@ -24410,7 +24435,7 @@ async def stream_agent_loop(
|
||||
if _spec_mode == "strict" and _spec_decided:
|
||||
# The router decides which tools are injected: its pick(s),
|
||||
# tools already used in this chat, and the always-on basics.
|
||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
|
||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
|
||||
if _spec_strict:
|
||||
route_tools = _spec_strict
|
||||
_spec_offered = frozenset(route_tools)
|
||||
@@ -25361,15 +25386,6 @@ async def stream_agent_loop(
|
||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||
in _spec_names
|
||||
]
|
||||
_have = {
|
||||
(schema.get("function", {}).get("name") or schema.get("name"))
|
||||
for schema in contract_schemas
|
||||
}
|
||||
contract_schemas += [
|
||||
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
|
||||
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
|
||||
and schema.get("function", {}).get("name") not in disabled_tools
|
||||
]
|
||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||
if route_state["is_api_model"]:
|
||||
if tool_surface == "full":
|
||||
@@ -26321,7 +26337,7 @@ async def stream_agent_loop(
|
||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||
if round_num == 1:
|
||||
try:
|
||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
|
||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
||||
@@ -33925,7 +33941,6 @@ async def stream_agent_loop(
|
||||
"mutation_epoch": _workspace_mutation_epoch,
|
||||
"error": _failure_text[:600],
|
||||
}
|
||||
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
|
||||
logger.info(
|
||||
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
||||
_call_signature,
|
||||
|
||||
@@ -211,10 +211,15 @@ class ChatHandler:
|
||||
from src.settings import get_setting
|
||||
vision_enabled = get_setting("vision_enabled", True)
|
||||
if vision_enabled:
|
||||
from src.owner_identity import auth_disabled
|
||||
sess_owner = getattr(sess, "owner", None)
|
||||
if not sess_owner and not auth_disabled():
|
||||
sess_owner = ""
|
||||
main_is_vision = await asyncio.to_thread(
|
||||
model_supports_vision,
|
||||
sess.model or "",
|
||||
getattr(sess, "endpoint_url", "") or "",
|
||||
sess_owner,
|
||||
)
|
||||
|
||||
if effective_att_ids and vision_enabled:
|
||||
|
||||
+243
-24
@@ -5,9 +5,14 @@ import re
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import ipaddress
|
||||
import logging
|
||||
import httpx
|
||||
import typing
|
||||
import httpcore
|
||||
from httpcore._backends.sync import SyncBackend, SyncStream
|
||||
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import HTTPException
|
||||
from fastapi import UploadFile
|
||||
@@ -17,6 +22,9 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ORIGINAL_HTTPX_GET = httpx.get
|
||||
_ORIGINAL_HTTPX_POST = httpx.post
|
||||
|
||||
|
||||
def extract_urls(text: str) -> List[str]:
|
||||
"""Extract URLs from text using regex pattern."""
|
||||
@@ -101,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
|
||||
_lmstudio_models_cache: dict = {}
|
||||
|
||||
|
||||
def _is_local_host(host: Optional[str]) -> bool:
|
||||
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
|
||||
host = (host or "").lower()
|
||||
_BLOCKED_METADATA_IPS = {
|
||||
ipaddress.ip_address("169.254.169.254"),
|
||||
ipaddress.ip_address("169.254.169.253"),
|
||||
ipaddress.ip_address("100.100.100.200"),
|
||||
}
|
||||
|
||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||
|
||||
|
||||
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
|
||||
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
||||
|
||||
if ip in _BLOCKED_METADATA_IPS:
|
||||
return False
|
||||
if ip.is_link_local:
|
||||
return False
|
||||
# Explicit loopback check before reserved rejection:
|
||||
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
|
||||
if ip.is_loopback:
|
||||
return True
|
||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
return False
|
||||
if ip.is_private:
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_safe_local_host(host: Optional[str]) -> bool:
|
||||
"""True for loopback/LAN/Tailscale hosts.
|
||||
|
||||
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
|
||||
unresolved single-label names, and public domains.
|
||||
"""
|
||||
host = (host or "").strip().lower()
|
||||
if not host:
|
||||
return False
|
||||
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
|
||||
return True
|
||||
# Strip optional IPv6 square brackets: [::1] -> ::1
|
||||
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
ip = ipaddress.ip_address(literal)
|
||||
return _is_ip_safe_local(ip)
|
||||
except ValueError:
|
||||
return "." not in host
|
||||
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
||||
return True
|
||||
return ip in ipaddress.ip_network("100.64.0.0/10")
|
||||
pass
|
||||
|
||||
allowed_names = {"localhost", "host.docker.internal"}
|
||||
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
|
||||
return False
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
if not infos:
|
||||
return False
|
||||
resolved_ips = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
ip_str = str(sockaddr[0]).split("%")[0]
|
||||
resolved_ips.append(ipaddress.ip_address(ip_str))
|
||||
if not resolved_ips:
|
||||
return False
|
||||
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
_is_local_host = _is_safe_local_host
|
||||
|
||||
|
||||
class SafeProbeBackend(SyncBackend):
|
||||
"""Enforces SSRF address policies at TCP connection time.
|
||||
|
||||
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
|
||||
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
|
||||
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
|
||||
"""
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||
) -> httpcore.NetworkStream:
|
||||
source_address = None if local_address is None else (local_address, 0)
|
||||
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
|
||||
with map_exceptions(exc_map):
|
||||
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
|
||||
if not infos:
|
||||
raise ConnectError(f"Host {host} did not resolve")
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
ip_str = str(sockaddr[0]).split("%")[0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
raise ConnectError(f"Invalid resolved IP: {ip_str}")
|
||||
if not _is_ip_safe_local(ip):
|
||||
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
|
||||
last_err: Optional[OSError] = None
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
try:
|
||||
if timeout is not None:
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sockaddr)
|
||||
if socket_options:
|
||||
for opt in socket_options:
|
||||
sock.setsockopt(*opt)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
return SyncStream(sock)
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
sock.close()
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise ConnectError(f"Could not connect to {host}:{port}")
|
||||
|
||||
|
||||
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
|
||||
if httpx.get is not _ORIGINAL_HTTPX_GET:
|
||||
try:
|
||||
if headers:
|
||||
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
|
||||
return httpx.get(url, timeout=timeout, follow_redirects=False)
|
||||
except TypeError:
|
||||
if headers:
|
||||
return httpx.get(url, headers=headers, timeout=timeout)
|
||||
return httpx.get(url, timeout=timeout)
|
||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||
transport = httpx.HTTPTransport(trust_env=False)
|
||||
transport._pool = pool
|
||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||
return client.get(url, headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
|
||||
if httpx.post is not _ORIGINAL_HTTPX_POST:
|
||||
try:
|
||||
if headers:
|
||||
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
|
||||
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
|
||||
except TypeError:
|
||||
if headers:
|
||||
return httpx.post(url, headers=headers, json=json, timeout=timeout)
|
||||
return httpx.post(url, json=json, timeout=timeout)
|
||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||
transport = httpx.HTTPTransport(trust_env=False)
|
||||
transport._pool = pool
|
||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||
return client.post(url, headers=headers, json=json, timeout=timeout)
|
||||
|
||||
|
||||
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
|
||||
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
||||
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
||||
parsed = urlparse(url)
|
||||
@@ -127,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
cached = _lmstudio_models_cache.get(key)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
authority = host if parsed.port is None else f"{host}:{parsed.port}"
|
||||
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
|
||||
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
||||
try:
|
||||
r = httpx.get(probe_url, timeout=1.0)
|
||||
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
@@ -148,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||
return models
|
||||
|
||||
|
||||
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
|
||||
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
||||
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
||||
if not model:
|
||||
return None
|
||||
# Never probe a remote provider; LM Studio is always a local/LAN host.
|
||||
if not _is_local_host(urlparse(url).hostname):
|
||||
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
|
||||
if not _is_safe_local_host(urlparse(url).hostname):
|
||||
return None
|
||||
models = _probe_lmstudio_models(url)
|
||||
models = _probe_lmstudio_models(url, headers=headers)
|
||||
if not models:
|
||||
return None
|
||||
want = model.strip().lower()
|
||||
@@ -172,21 +324,79 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
return None
|
||||
|
||||
|
||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||
def model_supports_vision(
|
||||
model_name: str,
|
||||
endpoint_url: str = "",
|
||||
owner: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Whether a model accepts images, using the endpoint's reported
|
||||
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:
|
||||
target_url = endpoint_url
|
||||
headers: Optional[dict] = None
|
||||
if owner is not None:
|
||||
if not owner:
|
||||
# Authentication enabled, but caller has no owner: refuse probing immediately
|
||||
return is_vision_model(model_name)
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||
target_url = normalize_base(resolved_base or ep.base_url)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||
except ValueError:
|
||||
# Foreign-owned, disabled, or unregistered endpoint
|
||||
return is_vision_model(model_name)
|
||||
except Exception as e:
|
||||
logger.warning("Endpoint authorization check failed: %s", e)
|
||||
return is_vision_model(model_name)
|
||||
else:
|
||||
# Single-user / auth-disabled mode (owner is None):
|
||||
try:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
|
||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||
target_url = normalize_base(resolved_base or ep.base_url)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||
except Exception:
|
||||
# In single-user mode, an unregistered endpoint is allowed
|
||||
# only if target URL passes safe local host checks (probed with headers=None).
|
||||
target_url = endpoint_url
|
||||
headers = None
|
||||
|
||||
try:
|
||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
||||
if headers:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
|
||||
else:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||
except TypeError:
|
||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||
except Exception:
|
||||
advertised = None
|
||||
if advertised is not None:
|
||||
return advertised
|
||||
try:
|
||||
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
||||
if headers:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
|
||||
else:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||
except TypeError:
|
||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||
except Exception:
|
||||
probed = None
|
||||
if probed is not None:
|
||||
@@ -215,7 +425,11 @@ def _chat_completions_url(endpoint_url: str) -> str:
|
||||
return base + "/chat/completions"
|
||||
|
||||
|
||||
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
||||
def probe_endpoint_supports_vision(
|
||||
endpoint_url: str,
|
||||
model_name: str,
|
||||
headers: Optional[dict] = None,
|
||||
) -> Optional[bool]:
|
||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||
an image_url content part for `model_name`?
|
||||
|
||||
@@ -226,8 +440,8 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
||||
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
|
||||
if not _is_safe_local_host(parsed.hostname):
|
||||
return None # never probe a public provider or disallowed host
|
||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||
now = time.time()
|
||||
cached = _vision_probe_cache.get(key)
|
||||
@@ -247,7 +461,12 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
||||
}
|
||||
verdict: Optional[bool] = None
|
||||
try:
|
||||
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
||||
r = _safe_probe_post(
|
||||
_chat_completions_url(endpoint_url),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=8.0,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
verdict = True
|
||||
elif r.status_code in (400, 413, 415, 422):
|
||||
|
||||
@@ -1466,6 +1466,29 @@ def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_us
|
||||
return None
|
||||
|
||||
|
||||
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
|
||||
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
||||
try:
|
||||
import sys as _sys
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
if not spec_router.think_router_enabled():
|
||||
return None
|
||||
from src.agent_loop import _configured_model_tool_surface_raw
|
||||
if _configured_model_tool_surface_raw(
|
||||
endpoint_url, model, owner, headers=headers
|
||||
) != 'spec':
|
||||
return None
|
||||
if hasattr(spec_router, 'route_thinking_async'):
|
||||
return await spec_router.route_thinking_async(direct_user_text)
|
||||
return spec_router.route_thinking(direct_user_text)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
'spec think router decision failed; using rules only', exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
||||
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
||||
other rule already selected a deliberate reason."""
|
||||
@@ -1508,10 +1531,7 @@ def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
|
||||
spec_router = _sys.modules.get('src.spec_router')
|
||||
if spec_router is None:
|
||||
from src import spec_router # noqa: F401
|
||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||
if not decisions:
|
||||
return None
|
||||
decision = decisions[-1]
|
||||
decision = spec_router.latest_decision()
|
||||
if not isinstance(decision, dict):
|
||||
return None
|
||||
# Guard against a stale decision from a previous turn.
|
||||
@@ -1573,8 +1593,7 @@ def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
|
||||
set(disabled_tools or ()), list(conversation or []),
|
||||
return_decision=True,
|
||||
)
|
||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||
decision = decisions[-1] if decisions else {}
|
||||
decision = spec_router.latest_decision() or {}
|
||||
if not isinstance(decision, dict) or decision.get('error'):
|
||||
return None
|
||||
choice = decision.get('choice')
|
||||
@@ -5600,6 +5619,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
external_tool_schemas=None, temperature=0.0,
|
||||
context_resolution=None,
|
||||
request_authority=MISSING_AUTHORITY, **ignored):
|
||||
if spec_driver_active(endpoint_url, model, owner, headers):
|
||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
from src.generation_sampling import validate_temperature
|
||||
temperature = validate_temperature(temperature)
|
||||
# This path sends requests directly with httpx and therefore bypasses
|
||||
@@ -6095,7 +6118,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
||||
# Optional env-gated router decision (spec surface only). Computed
|
||||
# once per user turn; disabled/None leaves behaviour unchanged.
|
||||
_spec_route_think = spec_router_thinking_decision(
|
||||
_spec_route_think = await spec_router_thinking_decision_async(
|
||||
endpoint_url, model, owner, headers, direct_user_text)
|
||||
if _spec_route_think is not None:
|
||||
logging.getLogger(__name__).info(
|
||||
@@ -6108,12 +6131,17 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
# round 1 is sent with tool_choice 'none' (same tools list).
|
||||
_spec_direct_p = spec_direct_no_tools_p(
|
||||
direct_user_text, endpoint_url, model, owner, headers)
|
||||
if (turn_contract.required or turn_contract.unavailable
|
||||
or turn_contract.required_read_operation is not None):
|
||||
_spec_direct_p = None
|
||||
# Option B: send round 1 with no tools at all for a direct answer.
|
||||
_spec_direct_empty_menu = (
|
||||
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||
# every round; the 9B only writes the arguments or the answer.
|
||||
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
|
||||
# Driver execution is refused at entry. Keep this false even if
|
||||
# configuration changes while the stream is already in progress.
|
||||
_spec_driver_on = False
|
||||
# Driver mode narrows the contract per round to the Spec pick.
|
||||
# Rebuild each round from this frozen base so the narrowing does
|
||||
# not accumulate/mutate across rounds.
|
||||
|
||||
@@ -92,7 +92,7 @@ def prune_multimodal_images(
|
||||
return pruned
|
||||
|
||||
|
||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _image_turn_label(message: Dict) -> str:
|
||||
@@ -102,7 +102,8 @@ def _image_turn_label(message: Dict) -> str:
|
||||
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")
|
||||
image_url = item.get("image_url")
|
||||
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
|
||||
if candidate:
|
||||
name = str(candidate)
|
||||
break
|
||||
@@ -146,7 +147,7 @@ def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict
|
||||
trimmed.append(message)
|
||||
continue
|
||||
kept_blocks = [
|
||||
item for item in content
|
||||
dict(item) if isinstance(item, dict) else item for item in content
|
||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||
]
|
||||
if len(kept_blocks) == len(content):
|
||||
|
||||
+12
-11
@@ -17,6 +17,7 @@ Env:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -98,18 +99,18 @@ 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:
|
||||
try:
|
||||
if answer.get("type") == "score":
|
||||
value = float(answer.get("score"))
|
||||
elif answer.get("type") == "choice":
|
||||
probs = answer.get("probabilities") or {}
|
||||
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||
else:
|
||||
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
|
||||
except Exception:
|
||||
return None
|
||||
# A malformed adapter reply must fall back like any other failure.
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
|
||||
@@ -78,8 +78,6 @@ 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,
|
||||
|
||||
+5
-167
@@ -1,13 +1,4 @@
|
||||
"""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.
|
||||
"""
|
||||
"""Helpers for the experimental Spec driver, whose execution is disabled."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -110,161 +101,8 @@ async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id
|
||||
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,
|
||||
}})
|
||||
# This experimental runtime lacks the normal turn and completion gates.
|
||||
# Refuse direct callers too, before contacting a router or executor.
|
||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||
yield 'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
+274
-9
@@ -1,9 +1,9 @@
|
||||
"""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.
|
||||
message and scores offered tools in one forward pass. The harness may narrow
|
||||
the existing turn contract, but cannot add authority or drop its obligations.
|
||||
On an error, timeout or low-confidence answer it retains the existing contract.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||
@@ -17,12 +17,17 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEAD = {
|
||||
@@ -35,8 +40,16 @@ _INSTRUCTIONS = (
|
||||
"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]] = []
|
||||
_LAST_DECISION: ContextVar[Optional[Dict[str, Any]]] = ContextVar('spec_router_decision', default=None)
|
||||
|
||||
|
||||
def latest_decision() -> Optional[Dict[str, Any]]:
|
||||
"""Return the router decision in the current request context."""
|
||||
return _LAST_DECISION.get()
|
||||
|
||||
|
||||
def clear_decision() -> None:
|
||||
_LAST_DECISION.set(None)
|
||||
_MCP_BROWSER_TOOLS = (
|
||||
"mcp__builtin_browser__browser_navigate",
|
||||
"mcp__builtin_browser__browser_snapshot",
|
||||
@@ -105,6 +118,7 @@ def spec_route_tools(
|
||||
the router answered confidently (a tool pick, or answer_directly)."""
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
clear_decision()
|
||||
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
|
||||
@@ -143,6 +157,15 @@ def spec_route_tools(
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
if not isinstance(probs, dict) or choice not in criteria:
|
||||
raise ValueError("invalid router choice")
|
||||
for key, probability in probs.items():
|
||||
if key not in criteria or isinstance(probability, bool):
|
||||
raise ValueError("invalid router probability key")
|
||||
number = float(probability)
|
||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||
raise ValueError("invalid router probability")
|
||||
probs = {key: float(value) for key, value in probs.items()}
|
||||
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(
|
||||
@@ -172,11 +195,133 @@ def spec_route_tools(
|
||||
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
|
||||
added = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
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
|
||||
_LAST_DECISION.set(decision)
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
|
||||
_ORIGINAL_SPEC_ROUTE_TOOLS = spec_route_tools
|
||||
|
||||
|
||||
async def spec_route_tools_async(
|
||||
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,
|
||||
):
|
||||
"""Asynchronous non-blocking tool routing.
|
||||
|
||||
Preserves request-local ContextVar semantics, decision propagation,
|
||||
cancellation behavior, timeout limits, and safe fallback on router failure.
|
||||
"""
|
||||
if spec_route_tools is not _ORIGINAL_SPEC_ROUTE_TOOLS:
|
||||
return spec_route_tools(
|
||||
user_message,
|
||||
schemas,
|
||||
disabled_tools=disabled_tools,
|
||||
messages=messages,
|
||||
return_decision=return_decision,
|
||||
)
|
||||
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
clear_decision()
|
||||
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}},
|
||||
}
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||
deadline = time.time() + timeout
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
while True:
|
||||
remaining = max(0.2, deadline - time.time())
|
||||
try:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=remaining,
|
||||
)
|
||||
if resp.status_code == 529:
|
||||
if time.time() + 0.15 >= deadline:
|
||||
resp.raise_for_status()
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["answers"]["tool"]
|
||||
break
|
||||
except httpx.HTTPStatusError as busy:
|
||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
await asyncio.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
if not isinstance(probs, dict) or choice not in criteria:
|
||||
raise ValueError("invalid router choice")
|
||||
for key, probability in probs.items():
|
||||
if key not in criteria or isinstance(probability, bool):
|
||||
raise ValueError("invalid router probability key")
|
||||
number = float(probability)
|
||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||
raise ValueError("invalid router probability")
|
||||
probs = {key: float(value) for key, value in probs.items()}
|
||||
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:
|
||||
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
|
||||
added = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||
logger.warning("spec router failed open: %s", exc)
|
||||
decision["decided"] = decided
|
||||
_LAST_DECISION.set(decision)
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
@@ -328,13 +473,59 @@ def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
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])
|
||||
logger.info("spec think router: %s", out)
|
||||
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
|
||||
|
||||
|
||||
_ORIGINAL_ROUTE_THINKING = route_thinking
|
||||
|
||||
|
||||
async def route_thinking_async(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
"""Asynchronous non-blocking route_thinking."""
|
||||
if route_thinking is not _ORIGINAL_ROUTE_THINKING:
|
||||
return route_thinking(user_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"))
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
ans = resp.json()["answers"]["thinking"]
|
||||
probs = ans.get("probabilities") or {}
|
||||
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", out)
|
||||
return out
|
||||
except Exception as exc:
|
||||
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,
|
||||
@@ -389,7 +580,81 @@ def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[st
|
||||
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])
|
||||
logger.info("spec next action: %s", action)
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
_ORIGINAL_NEXT_ACTION = next_action
|
||||
|
||||
|
||||
async def next_action_async(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]]:
|
||||
"""Asynchronous non-blocking next_action."""
|
||||
if next_action is not _ORIGINAL_NEXT_ACTION:
|
||||
return next_action(user_message, latest_result, schemas, messages=messages, disabled_tools=disabled_tools)
|
||||
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:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
deadline = time.time() + timeout
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
while True:
|
||||
remaining = max(0.2, deadline - time.time())
|
||||
try:
|
||||
resp = await client.post(
|
||||
url + "/v1/systemone",
|
||||
json=req,
|
||||
headers={"content-type": "application/json"},
|
||||
timeout=remaining,
|
||||
)
|
||||
if resp.status_code == 529:
|
||||
if time.time() + 0.15 >= deadline:
|
||||
resp.raise_for_status()
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["answers"]["tool"]
|
||||
break
|
||||
except httpx.HTTPStatusError as busy:
|
||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
await asyncio.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", action)
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
|
||||
+10
-12
@@ -47,25 +47,23 @@ def resolve_task_candidates(
|
||||
candidates.append((url, model, headers or {}))
|
||||
|
||||
if override_url and override_model:
|
||||
headers = override_headers or {}
|
||||
try:
|
||||
from src.database import ModelEndpoint, SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, resolve_endpoint_runtime, build_headers
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from src.auth_helpers import owner_filter
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
for ep in owner_filter(query, ModelEndpoint, owner).all():
|
||||
base = normalize_base(getattr(ep, "base_url", "") or "")
|
||||
if _same_endpoint_base(override_url, base):
|
||||
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
headers = build_headers(api_key, runtime_base or base)
|
||||
break
|
||||
ep = resolve_owner_registered_endpoint(db, override_url, owner)
|
||||
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
_append(build_chat_url(runtime_base), override_model, build_headers(api_key, runtime_base))
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
# A missing, foreign, disabled or unreadable registration is not
|
||||
# authority to contact a caller-provided endpoint or use its key.
|
||||
pass
|
||||
_append(override_url, override_model, headers)
|
||||
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
||||
_append(*resolve_endpoint("utility", owner=owner))
|
||||
_append(*resolve_endpoint("default", owner=owner))
|
||||
|
||||
+50
-25
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS, auth_disabled
|
||||
from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
@@ -21,6 +21,15 @@ from src.task_action_policy import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskEndpointAuthorityError(RuntimeError):
|
||||
"""A scheduled task's endpoint or its credentials cannot be authorized."""
|
||||
|
||||
|
||||
def _require_task_owner(owner):
|
||||
if not str(owner or "").strip() and not auth_disabled():
|
||||
raise TaskEndpointAuthorityError("Scheduled task has no owner in authenticated mode")
|
||||
|
||||
|
||||
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
||||
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
||||
return str(owner or "").strip().lower().startswith("sft_")
|
||||
@@ -1345,6 +1354,7 @@ class TaskScheduler:
|
||||
|
||||
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
||||
"""Execute a built-in action (no LLM needed)."""
|
||||
_require_task_owner(task.owner)
|
||||
from src.builtin_actions import BUILTIN_ACTIONS
|
||||
from src.agent_runtime.authority import (
|
||||
bind_request_authority, restore_task_authority, task_operation,
|
||||
@@ -1634,6 +1644,7 @@ class TaskScheduler:
|
||||
async def _execute_llm_task(self, task, db) -> str:
|
||||
"""Execute an LLM task with full tool access via the agent loop."""
|
||||
from core.database import Session as DbSession, ChatMessage, CrewMember
|
||||
_require_task_owner(task.owner)
|
||||
|
||||
# If this task is wired to a CrewMember (personal assistant, custom
|
||||
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
||||
@@ -1654,6 +1665,11 @@ class TaskScheduler:
|
||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||
if not endpoint_url or not model:
|
||||
raise RuntimeError("No model/endpoint configured")
|
||||
# Authorize the original URL before normalization can erase a query,
|
||||
# fragment, or caller-selected path. Stored tasks do not themselves
|
||||
# grant permission to contact an endpoint.
|
||||
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
# Record the resolved model so _execute_task_locked can persist it on
|
||||
# the run (tasks rarely pin a model, so this is the only record of
|
||||
@@ -1772,6 +1788,8 @@ class TaskScheduler:
|
||||
relevant_tools=relevant_tools,
|
||||
datetime_context_msg=_dt_msg,
|
||||
)
|
||||
except TaskEndpointAuthorityError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
||||
from src.task_endpoint import task_llm_call_async
|
||||
@@ -1986,6 +2004,7 @@ class TaskScheduler:
|
||||
override_user_message: str | None = None,
|
||||
datetime_context_msg: dict | None = None) -> str:
|
||||
"""Run the full agent loop with tool access, collecting the final text."""
|
||||
_require_task_owner(task.owner)
|
||||
from src.agent_loop import stream_agent_loop
|
||||
from src.agent_runtime.authority import restore_task_authority
|
||||
|
||||
@@ -2015,8 +2034,8 @@ class TaskScheduler:
|
||||
headers = build_headers(api_key, base)
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
raise TaskEndpointAuthorityError("Scheduled task endpoint is unavailable or unauthorized") from exc
|
||||
full_text = ""
|
||||
final_text_replaced = False
|
||||
tool_results = []
|
||||
@@ -2172,11 +2191,16 @@ class TaskScheduler:
|
||||
from src.settings import get_setting
|
||||
|
||||
# Resolve endpoint/model: research settings > task settings > session defaults
|
||||
_require_task_owner(task.owner)
|
||||
endpoint_url = task.endpoint_url
|
||||
model = task.model
|
||||
headers = {}
|
||||
headers_from_resolver = False
|
||||
|
||||
if endpoint_url:
|
||||
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||
|
||||
if not endpoint_url or not model:
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
@@ -2213,8 +2237,8 @@ class TaskScheduler:
|
||||
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
|
||||
except Exception as exc:
|
||||
raise TaskEndpointAuthorityError("Scheduled research endpoint is unavailable or unauthorized") from exc
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
|
||||
max_tokens, extraction_timeout, extraction_concurrency = (
|
||||
@@ -2348,11 +2372,11 @@ class TaskScheduler:
|
||||
have to special-case each tool's schema; the MCP tool ignores keys it
|
||||
doesn't recognise.
|
||||
"""
|
||||
_require_task_owner(task.owner)
|
||||
from src.tool_utils import get_mcp_manager
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
logger.warning(f"Task {task.id}: MCP manager not available for delivery")
|
||||
return
|
||||
raise RuntimeError("MCP manager not available for task delivery")
|
||||
|
||||
# Resolve recipient — prefer the configured email From (the established
|
||||
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
||||
@@ -2361,7 +2385,7 @@ class TaskScheduler:
|
||||
recipient = None
|
||||
try:
|
||||
from routes.email_helpers import _get_email_config
|
||||
cfg = _get_email_config() or {}
|
||||
cfg = _get_email_config(owner=task.owner or "") or {}
|
||||
recipient = cfg.get("from_address") or None
|
||||
except Exception as _e:
|
||||
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
||||
@@ -2377,6 +2401,14 @@ class TaskScheduler:
|
||||
"X-Odysseus-Ref": str(task.id),
|
||||
},
|
||||
}
|
||||
if tool_name.startswith("mcp__email__"):
|
||||
# The email server uses this reserved field for its account and
|
||||
# approval scope; it must come from the task owner, never a model.
|
||||
from src.tool_execution import _EMAIL_MCP_OWNER_ARG, _EMAIL_MCP_SESSION_ARG
|
||||
if task.owner:
|
||||
args[_EMAIL_MCP_OWNER_ARG] = task.owner
|
||||
if getattr(task, "session_id", None):
|
||||
args[_EMAIL_MCP_SESSION_ARG] = task.session_id
|
||||
if recipient:
|
||||
# Cover the common field names so we work across MCP servers (Gmail,
|
||||
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
||||
@@ -2391,25 +2423,18 @@ class TaskScheduler:
|
||||
)
|
||||
try:
|
||||
mcp_result = await mcp.call_tool(tool_name, args)
|
||||
stderr = mcp_result.get("stderr", "")
|
||||
stdout = mcp_result.get("stdout", "")
|
||||
body_len = len(result or "")
|
||||
exit_code = mcp_result.get("exit_code", 0)
|
||||
if exit_code != 0:
|
||||
logger.warning(
|
||||
f"Task {task.id} MCP delivery FAILED via {tool_name}: "
|
||||
f"exit={exit_code} stderr={stderr[:400]!r} stdout={stdout[:400]!r}"
|
||||
)
|
||||
else:
|
||||
# Include the MCP tool's own stdout (e.g. email_server returns
|
||||
# "Sent email to ... with subject ...") + the body size so a
|
||||
# silent SMTP failure is easier to spot in the logs.
|
||||
logger.info(
|
||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
||||
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
||||
)
|
||||
if isinstance(mcp_result, dict):
|
||||
from src.tool_execution import _normalize_mcp_text_error
|
||||
mcp_result = _normalize_mcp_text_error(dict(mcp_result))
|
||||
if not isinstance(mcp_result, dict) or mcp_result.get("error") or mcp_result.get("exit_code") != 0:
|
||||
raise RuntimeError("MCP task delivery failed or returned an invalid result")
|
||||
if tool_name == "mcp__email__send_email" and not str(mcp_result.get("stdout") or "").strip().startswith("Sent email to "):
|
||||
raise RuntimeError("MCP email delivery did not report a completed send")
|
||||
logger.info("Task %s delivered via MCP tool %s (recipient_set=%s, body=%sb)",
|
||||
task.id, tool_name, bool(recipient), len(result or ""))
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||
raise
|
||||
|
||||
async def run_task_now(self, task_id: str, *, force: bool = False):
|
||||
"""Manually trigger a task execution."""
|
||||
|
||||
@@ -1559,7 +1559,7 @@ def _agent_subprocess_env() -> dict:
|
||||
|
||||
|
||||
def _legacy_private_browser_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
|
||||
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):
|
||||
@@ -1757,11 +1757,13 @@ 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)
|
||||
# The legacy process cannot establish the sealed browser/page identity
|
||||
# required by the current runtime. Never execute it ahead of resource
|
||||
# binding and the untrusted-context approval gate, even when opted in.
|
||||
return f"{transport}: UNSUPPORTED", {
|
||||
**page_unavailable(),
|
||||
"error": "Legacy browser transport cannot establish the required browser authority.",
|
||||
}
|
||||
if operation.tool == "private_browser":
|
||||
try:
|
||||
_, browser_args = parse_browser_operation(operation.input)
|
||||
|
||||
+1
-1
@@ -2000,7 +2000,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
},
|
||||
]
|
||||
|
||||
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user