merge(dev): reconcile photo editor security and upstream changes

- Reconcile conflict resolution in routes/editor_agent_routes.py and src/editor_agent_model.py
- Enforce strict server-authorized endpoint resolution and reject client URL overrides
- Enforce bearer-token privilege separation: delegated API tokens are capped at non-admin scope per src/tool_security.py and cannot inspect private base URLs or probe metadata
- Preserve chat-scope authorization, missing-session 401 rejection, and owner-scoped browser bridge/SSE queues
- Preserve administrator-disabled-tool denylist enforcement introduced by PR #6357
- Fix mobile toolbar flyout hit-testing and stacking context trapping on viewports <= 700px via scoped elevation and mask suppression
- Stabilize project recovery test fixture by awaiting document draft quiescence prior to baseline capture
This commit is contained in:
Alexandre Teixeira
2026-10-28 19:30:08 +00:00
94 changed files with 12199 additions and 477 deletions
+4 -2
View File
@@ -240,11 +240,13 @@ def _artifact_match_is_callable(instruction: str, match: re.Match[str], path: st
def _artifact_match_is_email_host(instruction: str, match: re.Match[str]) -> bool:
"""Reject the domain portion of an email address as an output path."""
"""Reject either dotted portion of an email address as an output path."""
start = match.start("path")
prefix = instruction[max(0, start - 80):start]
return bool(re.search(r"[A-Za-z0-9_.+-]+@$", prefix))
return instruction[match.end("path"):].startswith("@") or bool(
re.search(r"[A-Za-z0-9_.+-]+@$", prefix)
)
def _workspace_path_identity(value: str) -> str:
+370 -18
View File
@@ -162,7 +162,7 @@ def _resolved_tool_call_id(
logger = logging.getLogger(__name__)
_MODEL_TOOL_SURFACES = {"none", "compact", "full"}
_MODEL_TOOL_SURFACES = {"none", "compact", "full", "spec"}
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
_NO_THINKING_COMPACT_DOMAINS = {
"email",
@@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
@@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
"manage_notes": """\
```manage_notes
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
@@ -8472,7 +8472,7 @@ def _agent_route_tool_mode(
return is_api_model, is_ollama_native, ollama_openai_compat
def _configured_model_tool_surface(
def _configured_model_tool_surface_raw(
endpoint_url: str,
model: str,
owner: Optional[str] = None,
@@ -8561,6 +8561,19 @@ _ADMIN_KEYWORDS = [
"note", "notes", "todo", "todos", "reminder", "reminders",
]
def _configured_model_tool_surface(
endpoint_url: str,
model: str,
owner: Optional[str] = None,
headers: Optional[Dict] = None,
endpoint_id: Optional[str] = None,
) -> str:
"""Tool surface as the harness sees it: Ajax Spec runs the compact runtime
(same dispatch, prompts, completions); only Spec hooks read the raw mode."""
value = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers, endpoint_id=endpoint_id)
return "compact" if value == "spec" else value
def _detect_admin_intent(messages: List[Dict]) -> bool:
"""Check if the last user message suggests admin/management tool usage."""
for msg in reversed(messages):
@@ -13565,6 +13578,20 @@ def _ody_qwen_terminal_tool_summary(tool_event: dict[str, Any], user_text: str =
"""Return a deterministic user-facing answer for tools we can render safely."""
tool_name = _resolved_tool_event_name(tool_event)
output = str(tool_event.get("output") or "")
if tool_name in {
"mcp__email__list_email_accounts", "mcp__email__list_emails",
"mcp__email__search_emails", "mcp__email__read_email",
} and output.lstrip().startswith("{"):
# The external email bridge wraps stdout in a JSON result. Render its
# successful read payload, never the wrapper or a failed result.
try:
email_result = json.loads(output)
except (TypeError, ValueError):
email_result = None
if isinstance(email_result, dict) and isinstance(email_result.get("stdout"), str):
if email_result.get("exit_code") not in (None, 0) or email_result.get("error"):
return ""
output = email_result["stdout"]
command = str(tool_event.get("command") or "")
action = ""
try:
@@ -21019,9 +21046,179 @@ def _blocks_before_inference(turn_contract) -> bool:
)
def _spec_unavailable_reply(unavailable) -> str:
blocked = set(unavailable or ())
if blocked & (set(WEB_TOOL_NAMES) | {"private_browser", "youtube_tool"}):
return ("Web or browser access is unavailable for this chat. Enable Web search "
"in the chat controls, if available, and ask me to check again. "
"I have not opened the page or verified its contents.")
return ("The tool needed for this request is unavailable in this chat. "
"Enable the required capability, if available, and try again. "
"I have not performed the requested lookup or action.")
from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_authority, with_request_authority
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
router's picks (plus ask_user/update_plan), or the contract unchanged.
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
except Exception:
return turn_contract
try:
from src.spec_router import spec_route_tools as _spec_route_tools_early
picks, decided = _spec_route_tools_early(
_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
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
@with_request_authority
@with_turn_contract
@with_teacher_takeover
@@ -21099,6 +21296,41 @@ async def stream_agent_loop(
set(tool_policy.all_disabled_names()) if tool_policy else set()
))
# Ajax Spec driver: the Spec router picks every step (src/spec_driver.py).
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"):
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 = 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
and getattr(turn_contract, "routing_experiment", "") == "spec-policy-denied"):
# A selected but disallowed tool is a policy outcome, not an invitation
# to generate an ungrounded answer with an empty tool menu.
reply = _spec_unavailable_reply(turn_contract.unavailable)
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
yield 'data: ' + json.dumps({"delta": reply}) + '\n\n'
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(
@@ -24126,6 +24358,15 @@ async def stream_agent_loop(
headers=candidate_headers,
endpoint_id=(route_descriptor or {}).get("endpoint_id"),
)
# "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 = 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:
_spec_surface = False
_spec_offered = None
textual_tools = (
force_textual_tool_transport
or force_textual_tools
@@ -24159,6 +24400,57 @@ async def stream_agent_loop(
_last_user,
_intent_domains,
)
if _spec_surface:
from src.spec_router import spec_route_tools as _spec_route_tools
if turn_contract is not None and getattr(turn_contract, "routing_experiment", "") == "spec-router":
_spec_added, _spec_decided = set(turn_contract.offered) - {"ask_user", "update_plan"}, True
else:
_spec_added, _spec_decided = _spec_route_tools(
_last_user,
FUNCTION_TOOL_SCHEMAS,
disabled_tools,
compacted_source,
return_decision=True,
)
_spec_added = _spec_added - set(disabled_tools)
# The router catalog uses the legacy native email names; the
# live tools are the MCP ones, and the legacy alias schemas are
# dropped when the MCP server is connected. Map picks over.
try:
from src.tool_utils import get_mcp_manager as _spec_mcp_mgr
_mgr = _spec_mcp_mgr()
_mcp_names = {
(schema.get("function") or {}).get("name")
for schema in (_mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else [])
}
except Exception:
_mcp_names = set()
_spec_added = {
(f"mcp__email__{name}" if f"mcp__email__{name}" in _mcp_names else name)
for name in _spec_added
}
_spec_mode = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower()
_compact_before = set(route_tools)
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(turn_contract.offered) - set(disabled_tools)
if _spec_strict:
route_tools = _spec_strict
_spec_offered = frozenset(route_tools)
else:
logger.info("[spec-harness] strict set empty after disabled-tool filter; keeping compact set")
route_tools = set(route_tools) | _spec_added
else:
route_tools = set(route_tools) | _spec_added
logger.info("[spec-debug] disabled_tools (%d): %s | policy block_all=%s disable_mcp=%s",
len(set(disabled_tools)), sorted(set(disabled_tools))[:80],
getattr(tool_policy, "block_all_tool_calls", None), getattr(tool_policy, "disable_mcp", None))
logger.info(
"[spec-harness] mode=%s decided=%s compact=%s final=%s",
_spec_mode, _spec_decided, sorted(_compact_before), sorted(route_tools),
)
# The compact router only receives user text, while native task
# inputs may be declared out-of-band by the runner. Reapply that
# concrete input contract after compaction so an implicit video
@@ -24199,7 +24491,7 @@ async def stream_agent_loop(
_irrelevant_local_media_web_tools.add("private_browser")
route_tools.difference_update(_irrelevant_local_media_web_tools)
if turn_contract is not None and tool_surface != "full":
route_tools = set(turn_contract.offered)
route_tools = set(_spec_offered) if _spec_offered else set(turn_contract.offered)
if prompt_compact:
route_tools.update(
_blocked_network_recovery_tools(tool_events)
@@ -24299,7 +24591,7 @@ async def stream_agent_loop(
disabled_tools,
needs_admin=_needs_admin,
relevant_tools=prompt_route_tools,
preserve_conversation=turn_contract is not None,
preserve_conversation=(turn_contract is not None) or _spec_surface,
mcp_disabled_map=_mcp_disabled_map,
compact=prompt_compact,
owner=owner,
@@ -24511,6 +24803,13 @@ async def stream_agent_loop(
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
if guide_only:
_prepend_agent_directive(route_messages, GUIDE_ONLY_DIRECTIVE)
if _spec_offered and os.getenv("ODYSSEUS_SPEC_TRIM_PROMPT", "1") != "0":
from src.spec_router import trim_system_prompt as _spec_trim
route_messages = _spec_trim(route_messages, _spec_offered)
from src.spec_router import drop_skills_index as _spec_drop_skills
route_messages = _spec_drop_skills(route_messages, _spec_offered)
return {
"messages": route_messages,
"mcp_schemas": route_mcp_schemas,
@@ -24519,6 +24818,8 @@ async def stream_agent_loop(
"is_ollama_native": is_native_ollama,
"ollama_openai_compat": is_ollama_compat,
"tool_surface": tool_surface,
"spec_offered": _spec_offered,
"spec_surface": _spec_surface,
"ody_qwen_finetune_model": is_ody,
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
"ody_doc_finetune_mode": doc_mode,
@@ -25076,6 +25377,15 @@ async def stream_agent_loop(
if (schema.get("function", {}).get("name") or schema.get("name"))
not in disabled_tools
]
_spec_names = route_state.get("spec_offered")
if _spec_names:
# Ajax Spec: the router, not the turn contract, decides which
# tools are injected for this turn.
contract_schemas = [
schema for schema in contract_schemas
if (schema.get("function", {}).get("name") or schema.get("name"))
in _spec_names
]
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
if route_state["is_api_model"]:
if tool_surface == "full":
@@ -25137,15 +25447,18 @@ async def stream_agent_loop(
and schema.get("name") not in disabled_tools
]
if _pure_web_turn and tool_surface != "full":
allowed = _web_only_route_tools(_last_user, disabled_tools)
schemas = [
schema for schema in schemas
if (
schema.get("function", {}).get("name")
or schema.get("name")
) in allowed
or schema.get("function", {}).get("name") in external_names
]
strict_spec = (route_state.get("spec_surface") and
os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict")
if not strict_spec:
allowed = _web_only_route_tools(_last_user, disabled_tools)
schemas = [
schema for schema in schemas
if (
schema.get("function", {}).get("name")
or schema.get("name")
) in allowed
or schema.get("function", {}).get("name") in external_names
]
schemas = _drop_legacy_email_alias_schemas_when_mcp_available(schemas)
schemas = _apply_tool_surface_to_schemas(schemas, tool_surface)
if (
@@ -25997,6 +26310,8 @@ async def stream_agent_loop(
"is_ollama_native": _is_ollama_native,
"ollama_openai_compat": _ollama_openai_compat,
"tool_surface": _route_state.get("tool_surface", ""),
"spec_offered": _route_state.get("spec_offered"),
"spec_surface": _route_state.get("spec_surface"),
"ody_qwen_finetune_model": _ody_qwen_finetune_model,
"qwen38_tool_router": _qwen38_tool_router,
"ody_doc_finetune_mode": _ody_doc_finetune_mode,
@@ -26012,10 +26327,19 @@ async def stream_agent_loop(
_active_route_state["request_messages"] = _initial_route_request_messages
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
if turn_contract is not None:
_relevant_tools = set(turn_contract.offered)
_relevant_tools = (
set(_route_state.get("spec_offered"))
if _route_state.get("spec_offered")
else set(turn_contract.offered)
)
agent_stream_timeout = int(get_setting("agent_stream_timeout_seconds", 300) or 300)
_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 ""))) 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'}")
_routing_intentional_exclusions = set(disabled_tools)
if (
@@ -27605,7 +27929,11 @@ async def stream_agent_loop(
),
active_document=active_document,
last_user=_last_user,
offered_tool_names=(set(turn_contract.offered) if turn_contract is not None else set(_tool_names_sent)),
offered_tool_names=(
set(turn_contract.offered)
if turn_contract is not None and not _active_route_state.get("spec_offered")
else set(_tool_names_sent)
),
recover_unoffered_tool_names=_recover_unoffered_local_tools,
passthrough_tool_names={
schema["function"]["name"]
@@ -32169,6 +32497,29 @@ async def stream_agent_loop(
budget_hit = False
local_network_budget_hit = False
local_inspection_budget_hit = False
if _active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and tool_blocks and not used_native and _is_api_model:
# Ajax Spec with a native-API model: a round without a native call
# carries only harness-synthesized recovery blocks. Drop them all.
logger.info("[spec-harness] dropped %d synthesized tool block(s) (no native call): %s",
len(tool_blocks), [b.tool_type for b in tool_blocks])
tool_blocks = []; converted_calls = []
if (
_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and used_native and tool_blocks
and len(converted_calls) == len(tool_blocks)
):
# Ajax Spec: only the model's own native calls run; harness-
# synthesized recovery blocks (no native call) are dropped. When
# converted_calls was reset (singleton collapse) keep every block.
_spec_keep = [
i for i in range(len(tool_blocks))
if i < len(converted_calls) and converted_calls[i] is not None
]
if len(_spec_keep) != len(tool_blocks):
logger.info("[spec-harness] dropped %d synthesized tool block(s): %s",
len(tool_blocks) - len(_spec_keep),
[tool_blocks[i].tool_type for i in range(len(tool_blocks)) if i not in _spec_keep])
tool_blocks = [tool_blocks[i] for i in _spec_keep]
converted_calls = [converted_calls[i] for i in _spec_keep]
for i, block in enumerate(tool_blocks):
native_call = converted_calls[i] if i < len(converted_calls) else None
tool_call_id = _resolved_tool_call_id(
@@ -33463,7 +33814,8 @@ async def stream_agent_loop(
),
}
if (
(_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
not (_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and block.tool_type == "private_browser")
and (_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
and not _artifact_creation_requested
and (block.tool_type == "web_search" or _web_execution_budget.searches)
and not _web_execution_budget.admit(
+12
View File
@@ -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}
+35 -13
View File
@@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key):
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
# Tool policy is not a general setting, even if a future default
# is added: resetting it must never re-enable disabled tools.
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = {
"image_quality": ["low", "medium", "high"],
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
save_settings(s)
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
elif action in ("disable_tool", "enable_tool", "list_tools"):
elif action == "enable_tool":
# The global disabled_tools denylist is the one hard permission
# boundary the Settings -> Agent Tools panel offers admins. It
# must only ever be *narrowed* from inside an agent turn: a
# model that can call enable_tool can undo the boundary itself,
# and any web page, email, or document the agent reads can ask
# it to (prompt injection re-enabling bash/email/etc.). Widening
# the tool set stays a human action in the Settings panel.
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
return {
"error": (
f"Re-enabling tools is not available from chat"
f"{f' (requested: {tool_name})' if tool_name else ''}. "
"Disabled tools are an admin policy: ask the user to turn the tool "
"back on in Settings > Agent Tools. You can still disable tools "
"(disable_tool) or list the current state (list_tools)."
),
"exit_code": 1,
}
elif action in ("disable_tool", "list_tools"):
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> browser tools,
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc.
# manage_memory, etc. Only disable_tool mutates: see the
# enable_tool branch above for why the agent can never widen
# the denylist.
from src.settings import get_setting, save_settings, load_settings
_ALIASES = {
"shell": ["bash"],
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"response": (
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
"Common toggles: shell (bash), search (web_search), browser, documents, "
"memory, skills, images, tasks, notes, calendar, email."
"memory, skills, images, tasks, notes, calendar, email. "
"To re-enable a tool, open Settings > Agent Tools."
),
"disabled": list(current),
"exit_code": 0,
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
settings = load_settings()
current = list(settings.get("disabled_tools") or [])
before = set(current)
if action == "disable_tool":
for t in targets:
if t not in current:
current.append(t)
else: # enable_tool
current = [t for t in current if t not in targets]
for t in targets:
if t not in current:
current.append(t)
after = set(current)
settings["disabled_tools"] = current
save_settings(settings)
verb = "Disabled" if action == "disable_tool" else "Enabled"
changed = sorted(after.symmetric_difference(before))
return {
"response": (
f"{verb} {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}."
f"Disabled {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}. "
"Re-enabling is done by the user in Settings > Agent Tools."
),
"changed": changed,
"disabled": list(current),
+68 -17
View File
@@ -78,7 +78,16 @@ def _parse(content: Any) -> Dict[str, Any]:
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
# Explicit editor session bound in context is authoritative;
# otherwise fallback to caller/tool args, then default.
# Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session.
return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default")
def _incognito(ctx: Dict[str, Any]) -> bool:
runtime = ctx.get("client_runtime_context")
state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None
return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True)
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
@@ -107,17 +116,26 @@ def _data_url_bytes(data_url: str) -> bytes:
return base64.b64decode(b64)
def _image_reference_path(value: str) -> bool:
# Never use the internal tool credential to fetch an arbitrary app route.
generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value)
upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value)
return bool(generated or upload)
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
from core.database import GalleryImage, SessionLocal
from src.constants import GENERATED_IMAGES_DIR
db = SessionLocal()
image_path = None
try:
root = Path(GENERATED_IMAGES_DIR).resolve()
root.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
(root / filename).write_bytes(image_bytes)
image_path = root / filename
image_path.write_bytes(image_bytes)
new_id = str(uuid.uuid4())
width = height = 0
try:
@@ -163,6 +181,11 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
except Exception:
db.rollback()
if image_path is not None:
try:
image_path.unlink(missing_ok=True)
except OSError:
logger.warning("Failed to remove uncommitted editor image", exc_info=True)
raise
finally:
db.close()
@@ -198,7 +221,7 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
}
try:
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
return data.get("ok") is not False
return bool(data.get("ok") is True and data.get("completed", True) is not False)
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor layer placement failed: %s", exc)
return False
@@ -367,28 +390,46 @@ class RunEditorCommandTool:
args = _parse(content)
op = str(args.get("op") or "").strip()
if not op:
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
params = args.get("params") if isinstance(args.get("params"), dict) else {}
wait = bool(args.get("wait", True))
payload = {
"session": _session(args, ctx),
"op": op,
"params": params,
"wait": bool(args.get("wait", True)),
"wait": wait,
"timeout": float(args.get("timeout") or 60),
}
try:
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
except Exception as exc:
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
ok = data.get("ok")
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
is_queued = bool(data.get("queued")) or data.get("status") == "queued"
if is_queued and (not wait or not data.get("ok")):
summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False)
return f"run_editor_command: {op}", {
"output": summary,
"queued": data.get("queued"),
"status": "queued",
"pending": True,
"completed": False,
"ok": False,
"exit_code": 1,
}
ok = bool(data.get("ok") is True and data.get("completed", True) is not False)
status = data.get("status") or ("completed" if ok else "failed")
result = data.get("result")
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
return f"run_editor_command: {op}", {
"output": summary[:4000],
"result": result,
"screenshot": data.get("screenshot"),
"status": status,
"completed": ok,
"ok": ok,
"exit_code": 0 if ok is not False else 1,
"exit_code": 0 if ok else 1,
}
@@ -442,10 +483,14 @@ class AiEditImageTool:
image = _as_data_url(args.get("image"))
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
if not _image_reference_path(args["image"]):
return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1}
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
resp.raise_for_status()
if not resp.headers.get("content-type", "").lower().startswith("image/"):
return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1}
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
except Exception:
image = None
@@ -550,10 +595,13 @@ class AiEditImageTool:
"model": data.get("model") or model_choice or "",
"exit_code": 0,
}
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
except Exception as exc:
logger.warning("ai_edit_image persist failed: %s", exc)
if _incognito(ctx):
info["image_url"] = data_url
else:
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
except Exception as exc:
logger.warning("ai_edit_image persist failed: %s", exc)
_t["persist"] = time.perf_counter()
if bool(args.get("place", True)):
placed = await _place_layer(
@@ -626,10 +674,13 @@ class GenerateImageLayerTool:
"model": data.get("image_model") or str(args.get("model") or ""),
"exit_code": 0,
}
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
except Exception as exc:
logger.warning("generate_image persist failed: %s", exc)
if _incognito(ctx):
info["image_url"] = data_url
else:
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
except Exception as exc:
logger.warning("generate_image persist failed: %s", exc)
if bool(args.get("place", True)):
placed = await _place_layer(
data_url,
File diff suppressed because it is too large Load Diff
+104
View File
@@ -2465,6 +2465,100 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
return str(e), False
async def _spec_triage_email_urgency(item: dict, *, triage_version: int) -> dict | None:
"""Classify one email's urgency with the trained jeff ``triage`` adapter.
Returns an email-urgency verdict (same shape as the LLM path) or None when
the per-job flag is off or the adapter is unavailable, so the caller falls
through to the LLM candidate chain. Only the ``urgency`` question is sent:
jeff scores each question in its own forward pass, and that is the question
this adapter was trained on.
"""
from src.jeff_decision import jeff_job_enabled, jeff_systemone, score_value
if not jeff_job_enabled("EMAIL_URGENCY"):
return None
message = f"Subject: {item.get('subject','')}\n\n{item.get('body','')}".strip()
if not message:
return None
answers = await jeff_systemone(
"triage",
{"channel": "email", "message": message[:8000]},
{"urgency": {
"type": "score",
"instructions": "How urgently does this need a response?",
"criteria": [
"No action needed",
"Can wait a few days",
"Handle within a day",
"Handle within hours",
"Handle immediately",
],
}},
)
if not answers:
return None
exp = score_value(answers.get("urgency"))
if exp is None:
return None
# Adapter's 0-4 urgency scale -> the app's 0-3 score.
score = max(0, min(3, int(round(exp))))
reason = "triage adapter"
# Same high-precision correction the prompt path applies: a person waiting
# in person is always urgent, whatever the model thinks.
blob = f"{item.get('headers','')}\n{item.get('subject','')}\n{item.get('body','')}".lower()
if re.search(r"\b(i'?m|i am|im|we'?re|we are)\s+outside\b", blob) or re.search(
r"\b(waiting outside|at the door|locked out|can'?t get in|cannot get in)\b", blob
):
if score < 3:
reason = "person is waiting outside"
score = 3
# Same bulk/marketing guard the prompt path applies: a newsletter or
# promotion is not urgent no matter how time-limited it sounds.
bulkish = bool(re.search(
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
blob,
))
# The adapter was trained on support tickets, so it over-rates promotions.
# Require a clear bulk header or several marketing terms before zeroing.
marketing_hits = sum(1 for t in (
"advertisement", "sponsored", "promo", "promotion", "sale", "discount",
"offer", "limited time", "deal", "coupon", "shop now", "buy now",
"sold out", "low tickets",
) if t in blob)
if bulkish or marketing_hits >= 2:
score = 0
reason = "bulk mail; no personal reply needed"
tags = []
if score >= 3:
tags.append("urgent")
elif score >= 2:
tags.append("reply-soon")
_from_raw = item.get("from", "") or ""
if "<" in _from_raw:
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
else:
_from_short = _from_raw
return {
"score": score,
"tags": tags[:4],
"spam": False,
"reason": reason,
"subject": (item.get("subject") or "")[:200],
"from": _from_short[:120],
"triage_version": triage_version,
"message_id": (item.get("message_id") or "").strip(),
"unread": bool(item.get("unread")),
"ts": time.time(),
}
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
"""Scan unread emails across all accounts, LLM-triage new ones, cache
per-UID verdicts, tag the inbox, and fire a reminder when a previously
@@ -2852,6 +2946,16 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
per_uid_scores[key] = verdict
saved_classifications += 1
continue
# ── Spec decision adapter (triage): a calibrated urgency score
# with no generated-text prompt. Enabled per-job via
# ODYSSEUS_JEFF_EMAIL_URGENCY; any failure returns None and the
# LLM candidate chain below still runs.
_tri = await _spec_triage_email_urgency(item, triage_version=TRIAGE_VERSION)
if _tri is not None:
cache.setdefault("uids", {})[item["uid"]] = _tri
per_uid_scores[key] = _tri
saved_classifications += 1
continue
# ── LLM-classify. JSON-only response; bullet-proof parse.
llm_attempts += 1
prompt = (
+5
View File
@@ -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:
+328 -21
View File
@@ -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."""
@@ -81,6 +89,16 @@ def is_vision_model(model_name: str) -> bool:
drops the image entirely. See issue #124.
"""
m = (model_name or "").lower()
if not m:
return False
# Per-model override (comma-separated substrings), e.g. a local model whose
# name carries no vision/VL hint but answers images natively.
override = os.getenv("ODYSSEUS_VISION_MODELS", "")
if override:
for token in override.split(","):
token = token.strip().lower()
if token and token in m:
return True
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
return True
return bool(_VISION_VL_RE.search(m))
@@ -91,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)
@@ -117,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:
@@ -138,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()
@@ -162,20 +324,165 @@ 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) and falling back to name-based
detection otherwise."""
capability when available (LM Studio), a cached one-time probe for local
OpenAI-compatible endpoints, and name-based detection otherwise."""
if is_vision_model(model_name):
return True
if endpoint_url:
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:
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:
return probed
return is_vision_model(model_name)
# 1x1 red PNG — the cheapest possible image probe payload.
_PROBE_IMAGE_DATA_URL = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
_VISION_PROBE_TTL = 3600.0
# (host, model) -> (bool | None, expiry); None = undecidable (auth/network).
_vision_probe_cache: dict = {}
def _vision_probe_enabled() -> bool:
return os.getenv("ODYSSEUS_VISION_PROBE", "1").strip().lower() not in ("0", "false", "off", "no")
def _chat_completions_url(endpoint_url: str) -> str:
base = (endpoint_url or "").rstrip("/")
if base.endswith("/chat/completions"):
return base
return base + "/chat/completions"
def probe_endpoint_supports_vision(
endpoint_url: str,
model_name: str,
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`?
Returns True on a 200, False when the endpoint rejects the image (400/413/
415/422), and None when we cannot decide (remote host, auth required,
unreachable, or probing disabled) so the caller falls back to name rules.
"""
if not endpoint_url or not model_name or not _vision_probe_enabled():
return None
parsed = urlparse(endpoint_url)
if not _is_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)
if cached is not None and cached[1] > now:
return cached[0]
payload = {
"model": model_name,
"max_tokens": 1,
"stream": False,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Reply with one word."},
{"type": "image_url", "image_url": {"url": _PROBE_IMAGE_DATA_URL}},
],
}],
}
verdict: Optional[bool] = None
try:
r = _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):
body = (r.text or "").lower()
if any(tok in body for tok in ("image", "vision", "multimodal", "content type")):
verdict = False
else:
verdict = False
except Exception:
verdict = None
_vision_probe_cache[key] = (verdict, now + _VISION_PROBE_TTL)
if verdict is not None:
logger.info("vision probe %s@%s -> %s", model_name, parsed.hostname, verdict)
return verdict
def validate_message(message: str) -> str:
"""Validate message input."""
if not message:
+675 -14
View File
@@ -20,7 +20,11 @@ from pathlib import Path
import httpx
import jsonschema
from src.context_compactor import prune_multimodal_images, trim_for_context
from src.context_compactor import (
keep_recent_image_turns,
prune_multimodal_images,
trim_for_context,
)
from src.agent_runtime.runtime_selection import COMPACT_PREVIEW_MODE
from src import agent_runs
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
@@ -42,6 +46,7 @@ from src.turn_contract import (
_REQUEST_PREFIX,
calendar_retiming_request,
FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_request,
note_read_intent,
targets_bound_editor_request, inline_text_transformation, editor_request_instructions,
_bound_editor_requests_web_verification, scheduled_automation_request, creation_container_tool,
)
@@ -206,9 +211,17 @@ CONTRACT_REQUIRED_TOOLS = frozenset({
})
from src.turn_contract import CONTRACT_CORE_TOOLS
# Scoped NL photo-editor surface. These are offered only when the
# ``photo_editor`` capability family is active (editor Quick Edit popup or an
# image-edit chat turn); they drive the in-process, owner-scoped editor bridge
# and the existing AI image ops.
EDITOR_AGENT_TOOLS = frozenset({
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
})
PREVIEW_TOOLS = (
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
| EDITOR_AGENT_TOOLS
)
# Keep a small recovery-capable surface on every interactive compact agent
# turn. Routing still adds domain tools, while policy and action guards remain
@@ -1035,6 +1048,22 @@ def notes_terminal_response(raw, *, user_text='', max_items=20):
return _bounded_structured_list(summary, user_text=user_text)
def note_locator_followup(raw, *, user_text=''):
"""Require a body read before answering a note-content request."""
_, needs_body = note_read_intent(user_text)
if not needs_body or note_search_result_empty(raw):
return ''
listing = notes_terminal_response(raw, user_text=user_text, max_items=5)
match = re.search(r'\(#note-([A-Za-z0-9_-]+)\)', listing)
if not match:
return ''
return (
'The notes result contains titles only, not the body. '
f'Call manage_notes with action="view" and id="{match.group(1)}" now. '
'Use the returned body to answer the request; do not infer content from the title.'
)
def documents_terminal_response(raw, *, user_text='', max_items=8):
"""Render linked document rows under the user's explicit global cap."""
from src.agent_loop import _document_list_summary_from_tool_output
@@ -1368,17 +1397,26 @@ def contentless_final_response(content):
def action_promise_response(content):
"""Recognize a short promise to do work that contains no result yet."""
text = re.sub(r'\s+', ' ', str(content or '')).strip()
if not text or len(text.split()) > 48:
"""Recognize a short promise to do work that contains no result yet.
Fenced code blocks are stripped before the word count and the verb match,
so a promise followed by a code block (e.g. "I will browse Google Maps ..."
plus a requests/BeautifulSoup snippet and no tool call) still counts as an
action promise. The caller only reaches this when no tool call was made.
"""
without_code = re.sub(
r'\s+', ' ', re.sub(r'```.*?```', ' ', str(content or ''), flags=re.DOTALL)
).strip()
if not without_code or len(without_code.split()) > 48:
return False
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', text, re.I):
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', without_code, re.I):
return False
return bool(re.match(
r"^(?:okay[,.:]?\s*)?(?:let me|i(?:'ll| will)|next[,.:]?\s+i(?:'ll| will))\s+"
r"(?:now\s+)?(?:inspect|extract|read|check|review|analy[sz]e|verify|open|"
r"search|look|create|write|render|run|use|continue|finish|provide)\b",
text,
r"search|look|create|write|render|run|use|continue|finish|provide|"
r"browse|find|fetch|visit|navigate|go|get|scrape)\b",
without_code,
re.I,
))
@@ -1407,6 +1445,210 @@ def progressive_thinking_for_turn(model, offered_schemas, thinking_mode=None):
return uses_odysseus_progressive_thinking(model) and not bool(offered_schemas)
def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_user_text):
"""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
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
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."""
if (round_number == 1 and not existing_deliberate
and isinstance(decision, dict) and decision.get('think')):
return 'router_think'
return ''
def spec_direct_mode():
"""Which direct-answer behaviour to use when the gate is on.
'tool_choice_none' (default) keeps the tools list but forbids a tool call
for the round; 'empty_menu' sends no tools at all. Any other value falls
back to the default.
"""
mode = str(
os.getenv('ODYSSEUS_SPEC_DIRECT_MODE', 'tool_choice_none') or ''
).strip().lower()
return 'empty_menu' if mode == 'empty_menu' else 'tool_choice_none'
def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
"""Env-gated confidence for a spec 'answer_directly' turn.
Returns the answer_directly probability when the spec router just decided
to answer directly with enough confidence, else None. Default OFF, so the
caller behaves exactly as before unless ODYSSEUS_SPEC_DIRECT_NOTOOLS is set.
"""
if str(os.getenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '')).strip().lower() not in (
'1', 'true', 'yes', 'on'):
return None
try:
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
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
decision = spec_router.latest_decision()
if not isinstance(decision, dict):
return None
# Guard against a stale decision from a previous turn.
msg = str(user_message or '')
decided_msg = str(decision.get('message') or '')
if decided_msg and msg and decided_msg[:40] != msg[:40]:
return None
if decision.get('choice') != 'answer_directly':
return None
try:
p = float(decision.get('answer_directly') or 0.0)
except (TypeError, ValueError):
return None
try:
min_p = float(os.getenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.9') or 0.9)
except (TypeError, ValueError):
min_p = 0.9
return p if p >= min_p else None
except Exception:
logging.getLogger(__name__).warning(
'spec direct-answer decision failed; using rules only', exc_info=True)
return None
def spec_driver_active(endpoint_url, model, owner, headers):
"""True when ODYSSEUS_SPEC_MODE=driver and the turn uses the spec surface."""
if str(os.getenv('ODYSSEUS_SPEC_MODE', '') or '').strip().lower() != 'driver':
return False
try:
from src.agent_loop import _configured_model_tool_surface_raw
return _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers) == 'spec'
except Exception:
return False
def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
"""Per-round ODYSSEUS_SPEC_MODE=driver decision: the router drives tools.
Ask the router once for this round and return its TOP-1 choice at ANY
confidence: ``{'choice': 'tool'|'answer_directly'|'ask_user', 'p': float,
'tool_name': str|None}``. Returns None on any router error/timeout or an
unusable pick, so the caller keeps the normal menu for that round.
``schemas`` is the round's offered menu; the router only ever picks from
it, so a top-1 tool is always one the turn can actually execute (passing
every known tool here made the driver name tools outside the offered set,
which then looped on permission errors).
"""
try:
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
menu = list(schemas) if schemas else list(FUNCTION_TOOL_SCHEMAS)
picks, _decided = spec_router.spec_route_tools(
str(user_text or ''), menu,
set(disabled_tools or ()), list(conversation or []),
return_decision=True,
)
decision = spec_router.latest_decision() or {}
if not isinstance(decision, dict) or decision.get('error'):
return None
choice = decision.get('choice')
top = decision.get('top') or []
if choice == 'answer_directly':
return {'choice': 'answer_directly',
'p': float(decision.get('answer_directly') or 0.0),
'tool_name': None}
if choice == 'ask_user':
return {'choice': 'ask_user',
'p': float(decision.get('ask_user') or 0.0),
'tool_name': None}
# Driver mode takes the router's TOP-1 at ANY confidence, so read the
# ranked ``top`` (not the MIN_CONF-filtered ``picks``/``added``).
if top and isinstance(top[0], (list, tuple)) and top[0]:
name = str(top[0][0] or '')
p = None
if len(top[0]) >= 2:
try:
p = float(top[0][1])
except (TypeError, ValueError):
p = None
if name:
return {'choice': 'tool', 'p': p, 'tool_name': name}
if picks:
try:
name = str(next(iter(picks)))
except TypeError:
name = str(picks[0]) if len(picks) else ''
if name:
return {'choice': 'tool', 'p': None, 'tool_name': name}
return None
except Exception:
logging.getLogger(__name__).warning(
'spec driver decision failed; using the normal menu', exc_info=True)
return None
def tool_deliberation_reason(name, args):
"""Classify selected operations, never infer intent from tool availability."""
name = canonical(name)
if name in {'python', 'edit_file', 'apply_patch'}:
return 'code'
if name in {'edit_document', 'suggest_document', 'update_document'}:
return 'document_edit'
if name in {'draft_email', 'draft_email_reply', 'ai_draft_email_reply'}:
return 'email_draft'
action = str(args.get('action') or '').lower()
if name == 'manage_tasks' and action in {'create', 'add', 'update', 'edit'}:
return 'task_design'
if name == 'manage_skills' and action in {'add', 'edit', 'patch', 'publish', 'audit'}:
return 'skill_evaluation'
return ''
def visible_content_after_qwen_thinking(content):
"""Remove private Qwen reasoning when the server lacks a reasoning parser."""
@@ -1422,6 +1664,48 @@ def visible_content_after_qwen_thinking(content):
return text.strip()
def qwen_think_split_events(content, state):
"""Split a no-reasoning-parser thinking round into live deltas.
Some thinking servers (no --reasoning-parser) return the whole
``<think>…</think>`` trace as ordinary ``content``. Buffering that round
made the UI show nothing while the model thought and then dump the whole
answer at once. Stream the trace as ``{'delta': ..., 'thinking': True}``
events (the same shape a reasoning-parser server emits) and the text after
``</think>`` as normal answer deltas. ``state`` is a mutable per-round dict
carrying the emission bookkeeping; the stored answer is unchanged (see
``visible_content_after_qwen_thinking``).
"""
events = []
text = str(content or '')
if not text:
return events
lowered = text.casefold()
lead = len('<think>') if lowered.startswith('<think>') else 0
close = lowered.find('</think>')
if close < 0:
# The opening <think> is injected by the chat template, so a progressive
# trace can arrive with no marker until </think>. Emit the buffered text
# as thinking - holding back a possible partial close tag - so the trace
# streams live instead of dumping in one block at the end.
think_region = text[lead:max(lead, len(text) - len('</think>'))]
answer_region = ''
else:
think_region = text[lead:close]
answer_region = text[close + len('</think>'):]
emitted_think = int(state.get('think_emitted', 0) or 0)
if len(think_region) > emitted_think:
events.append({'delta': think_region[emitted_think:], 'thinking': True})
state['think_emitted'] = len(think_region)
if close >= 0:
state['closed'] = True
emitted_answer = int(state.get('answer_emitted', 0) or 0)
if len(answer_region) > emitted_answer:
events.append({'delta': answer_region[emitted_answer:]})
state['answer_emitted'] = len(answer_region)
return events
def prior_short_answer_for_no_tool_summary(user_text, history):
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
text = str(user_text or '')
@@ -2714,6 +2998,18 @@ def normalize_preview_entity_anchor_args(name, args):
value = args.get(field)
if isinstance(value, str) and value.startswith(prefix):
args[field] = value[len(prefix):]
if canonical(name) == 'manage_notes':
# Some models copy the rendered note href without its leading '#'.
# Decode only a complete UUID; partial selectors retain the existing
# owner-bound resolution policy instead of gaining a new alias.
value = args.get('note_id')
if isinstance(value, str):
match = re.fullmatch(
r'note-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
value, re.I,
)
if match:
args['note_id'] = match.group(1)
return args
@@ -2754,6 +3050,9 @@ def normalize_preview_function_args(name, args, *, user_text=''):
args.pop('filter', None)
if canonical(name) == 'manage_notes':
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
if action == 'read' and (args.get('id') or args.get('note_id')):
args['action'] = 'view'
action = 'view'
if action == 'list':
request = str(user_text or '').casefold()
# The notes backend interprets title/query/content on ``list`` as
@@ -2770,6 +3069,10 @@ def normalize_preview_function_args(name, args, *, user_text=''):
args.pop('archived', None)
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
args.pop('note_type', None)
query, _ = note_read_intent(user_text)
if query:
args['action'] = 'search'
args['query'] = query
if canonical(name) == 'manage_skills':
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
if action in {'update', 'change', 'revise'}:
@@ -2897,8 +3200,9 @@ def scope_preview_contract(preview_contract, routed_contract, active_capabilitie
requested_family_tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in active))
available_requested = routed_canonical & offered_canonical & requested_family_tools
routed_canonical.update(canonical(name) for name in extra_tools)
if 'image_generation' not in active:
# A past image request must not keep generation warm on email/doc turns.
if 'image_generation' not in active and 'photo_editor' not in active:
# A past image request must not keep generation warm on email/doc turns,
# unless the photo_editor family (which bundles generate_image) is active.
routed_canonical.discard('generate_image')
missing_active = {
f'capability:{family}' for family in active
@@ -3230,7 +3534,11 @@ def _successful_email_identifiers(history):
for message in history or ():
if not isinstance(message, dict):
continue
if message.get('role') == 'system' and 'Message UID:' in str(message.get('content') or ''):
active_reader = (
message.get('_agent_injected') == 'context'
and (message.get('metadata') or {}).get('source') == 'active email reader'
)
if (message.get('role') == 'system' or active_reader) and 'Message UID:' in str(message.get('content') or ''):
extracted = _email_identifiers_from_text(message.get('content'))
known['uid'].update(extracted['uid'])
known['message_id'].update(extracted['message_id'])
@@ -3518,6 +3826,20 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
runtime_tools = PREVIEW_TOOLS | (
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
) | ({canonical(tool) for tool in external_runtime_tools} if allow_native_workspace else set())
# Spec turns are driven by the router: the Spec contract (its offered set)
# IS the model runtime, so a Spec pick is runnable. Keyed off the same
# marker the spec hook sets (`routing_experiment == 'spec-router'`), never
# a new flag, and never applied to compact/native turns.
try:
from src.turn_contract import active_turn_contract as _atc
_spec_contract = _atc()
if _spec_contract is not None and getattr(
_spec_contract, 'routing_experiment', '') == 'spec-router':
runtime_tools = runtime_tools | {
canonical(tool) for tool in getattr(_spec_contract, 'offered', ()) or ()
}
except Exception:
pass
if bare not in runtime_tools:
return decision(False, 'tool_not_in_model_runtime')
if bare in {canonical(tool) for tool in external_runtime_tools}:
@@ -3559,6 +3881,9 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
or bare in BROKERED_JOB_TOOLS
or (contract_required and bare in {'edit_image', 'generate_image'})
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
# The editor command is conservatively classified as a write;
# document.info is a read even when the user requested no edit.
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
)
)
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
@@ -3619,6 +3944,18 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
# editor is offered only for an explicit image-editing turn.
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
if (
(contract_required or family in turn_authorized_families)
and bare in {'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image'}
):
# Scoped photo_editor agent tools. They are never in the general tool
# list; the immutable turn contract only selects them for an explicit
# editor request (Quick Edit popup / image-edit turn), so their UI and
# brokered-network effects are covered by the contract seal.
blocked_effects.discard(ToolEffect.UI_SIDE_EFFECT)
allowed_effects.add(ToolEffect.UI_SIDE_EFFECT)
blocked_effects.discard(ToolEffect.NETWORK_EGRESS)
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
if (
contract_required
and bare in {'download_model', 'serve_preset', 'stop_served_model'}
@@ -3849,6 +4186,29 @@ def conversation(history_session, messages, *, owner=None, diagnostics=None):
from src.turn_contract import result_reference_followup
source_context = (recent_source_reference_context(groups[-2])
if len(groups) > 1 and result_reference_followup(current_text) else None)
# Tool output can dwarf the dialogue (especially a web search). Keep the
# visible exchange before enforcing the history cap, so one search-heavy
# turn cannot erase the subject of the next follow-up.
for index, group in enumerate(groups[:-1]):
if len(json.dumps(group)) <= 5000 or len(group) < 2:
continue
# Keep non-web tool evidence intact: a note/list follow-up may need
# exact result identifiers, and shortening it would be misleading.
if not any(
canonical((call.get('function') or {}).get('name', '')) in {'web_search', 'web_fetch'}
for message in group if message.get('role') == 'assistant'
for call in message.get('tool_calls') or ()
):
continue
answer = next((message.get('content') for message in reversed(group)
if message.get('role') == 'assistant'
and str(message.get('content') or '').strip()), None)
if answer is None:
continue
answer_text = str(answer)
if len(answer_text) > 3500:
answer_text = answer_text[:3500] + '\n[Earlier answer shortened for context.]'
groups[index] = [group[0], {'role': 'assistant', 'content': answer_text}]
# Drop whole turns only, never orphan tool results from their native calls.
groups = groups[-8:]
while len(groups) > 1 and len(json.dumps(groups)) > 22000:
@@ -4207,6 +4567,26 @@ def denied_response():
return 'I can’t perform that operation in this preview. No changes were made.'
WORKSPACE_REQUIRED_REFUSAL = (
'Shell and Python commands only run inside a workspace. '
'Open the code workspace (or set a working directory) and try again.'
)
def workspace_process_denial(executions) -> bool:
"""True when a blocked call was refused because process execution needs a
sealed workspace (a plain chat turn carries none)."""
for entry in executions or ():
blob = ' '.join(
str(entry.get(key) or '') for key in ('error', 'output', 'failure_kind')
)
# Resource identity also covers missing note and document selectors.
# Those denials must not turn into instructions to open a code workspace.
if 'sealed workspace' in blob.casefold():
return True
return False
def execution_has_write_effect(tool_name, content, capability, *, native_workspace_enabled):
"""Recognize successful native code that visibly mutates the workspace."""
@@ -4226,7 +4606,7 @@ _MUTATION_REQUEST = re.compile(
r'\b(?:add|creat(?:e|ing)?|make|writ(?:e|ing)|sav(?:e|ing)|updat(?:e|ing)|edit(?:ing)?|chang(?:e|ing)|'
r'set|schedul(?:e|ing)|reschedul(?:e|ing)|paus(?:e|ing)|resum(?:e|ing)|toggle|mark|'
r'pin|archive|delet(?:e|ing)|remov(?:e|ing)|send|reply|draft|publish|run|launch|serve|start|stop|'
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|feedback|reserve|block)\b|'
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|reserve|block)\b|'
r'\bgo\s+deeper\b',
re.I,
)
@@ -4262,7 +4642,7 @@ def mutation_action_requested(user_text):
actionable = re.sub(
r"\b(?:do\s+not|don't|never|without)\s+"
r"(?:(?:chang(?:e|ing)|modif(?:y|ying)|edit(?:ing)?|delet(?:e|ing)|"
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing))\b)"
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing)|schedul(?:e|ing))\b)"
r"[^.;\n]{0,120}?(?=(?:[.;\n]|\bbut\b|\binstead\b|$))",
'', text, flags=re.I,
)
@@ -5242,6 +5622,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
@@ -5622,6 +6006,38 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'multimodal_image_count': image_context_count,
'attachment_reference_count': attachment_refs,
'image_rehydration': conversation_diagnostics.get('image_rehydration')})
_photo_editor_active = 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ())
_photo_editor_open = bool(
((client_runtime_context or {}).get('web_ui_state') or {}).get('photo_editor_open')
)
if _photo_editor_active and not _photo_editor_open:
_editor_guidance = (
"This image isn't open in the editor yet, so I can't apply edits to the "
"canvas from chat. Open the attached image in the editor (Quick Edit) and "
"send this request there \u2014 I'll apply it to the canvas."
)
history.append({'role': 'assistant', 'content': _editor_guidance})
yield event({'delta': _editor_guidance})
_action_prompt = str(direct_user_text or '')
for _annotation in ('\n\n[Image:', '\n[Image:', '\n\n[Attached', '\n[No vision model'):
_cut = _action_prompt.find(_annotation)
if _cut != -1:
_action_prompt = _action_prompt[:_cut]
_action_prompt = _action_prompt.strip() or str(direct_user_text or '').strip()
yield event({
'type': 'final_response',
'content': _editor_guidance,
'photo_editor_action': {
'kind': 'open_and_apply',
'prompt': _action_prompt,
},
})
yield event({'type': 'metrics', 'data': {
'model': model, 'rounds': 0, 'round_texts': [_editor_guidance],
'tool_events': [], 'tool_calls': 0, 'agent_rounds': 0,
}})
yield 'data: [DONE]\n\n'
return
try:
async with httpx.AsyncClient(
timeout=preview_http_timeout(
@@ -5703,6 +6119,40 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
usage_in += intent_accounting.get('input_tokens', 0)
usage_out += intent_accounting.get('output_tokens', 0)
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 = 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(
'spec think router: think=%s p_think=%s ms=%s',
_spec_route_think.get('think'),
_spec_route_think.get('p_think'),
_spec_route_think.get('ms'))
# Optional env-gated direct-answer override (spec surface only):
# when the spec router chose answer_directly with high confidence,
# 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.
# 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.
_driver_base_contract = turn_contract
# Per-round Spec pick (driver mode): 'answer_directly' | 'ask_user'
# | 'tool' | None (router fallback). Set inside the round loop.
_driver_round_kind = None
_driver_round_pick = None
# One extra iteration is available only when a provider emits raw
# tool markup during the normal final no-tools round. Ordinary
# turns still obey ``round_limit`` exactly.
@@ -5839,7 +6289,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'completion_reserve_seconds': artifact_completion_reserve_seconds,
})
request_messages = provider_request_messages(
prune_multimodal_images(history, max_images=3)
prune_multimodal_images(
keep_recent_image_turns(history, keep=2), max_images=3
)
)
if prior_summary_answer or force_no_tools_next_round or reserve_final_synthesis:
round_offered = []
@@ -6036,6 +6488,94 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
break
request['tools'] = source_tools
request['tool_choice'] = 'required'
# ODYSSEUS_SPEC_MODE=driver: the router is the tool driver.
# Ask it for this round's single next action (any confidence);
# the 9B then only writes the arguments (a named tool_choice) or
# the answer (tools key omitted; vLLM rejects an empty array).
# Router error/timeout keeps the normal menu for this round.
if _spec_driver_on and round_offered:
# The router chooses from the FULL Ajax Spec surface (~all
# built-ins minus disabled), not the compact round menu, so
# a Spec pick is never silently out of reach. The 9B then
# only writes the arguments (a named tool_choice) or the
# answer (tools key omitted; vLLM rejects an empty array).
# Router error/timeout keeps the normal menu this round.
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS as _SPEC_SURFACE
_drv = spec_driver_pick(history, disabled_tools, direct_user_text)
if _drv is not None:
_drv_choice = _drv.get('choice')
if _drv_choice == 'answer_directly':
_driver_round_kind = 'answer_directly'
request.pop('tools', None)
request.pop('tool_choice', None)
elif _drv_choice == 'ask_user':
_driver_round_kind = 'ask_user'
_ask = [s for s in _SPEC_SURFACE
if (s.get('function') or {}).get('name') == 'ask_user']
if _ask:
request['tools'] = [_ask[0]]
request['tool_choice'] = {
'type': 'function',
'function': {'name': 'ask_user'},
}
if _ask[0] not in round_offered:
round_offered = list(round_offered) + [_ask[0]]
elif _drv_choice == 'tool' and _drv.get('tool_name'):
_name = _drv['tool_name']
_sel = [s for s in list(round_offered) + list(_SPEC_SURFACE)
if (s.get('function') or {}).get('name') == _name]
if _sel:
_driver_round_kind = 'tool'
_driver_round_pick = _name
request['tools'] = [_sel[0]]
request['tool_choice'] = {
'type': 'function',
'function': {'name': _name},
}
# The per-call validator looks the schema up in
# `round_offered` (not `request['tools']`), so the
# Spec pick must be present there too.
round_offered = [_sel[0]]
# Driver mode narrows the whole contract to the
# Spec pick for this round: the router is the
# authority, and the executor must be able to run
# the picked tool. Build a valid contract (the
# TurnContract invariant requires
# required <= offered <= executable and
# schema_json names == offered).
try:
from src.turn_contract import (
_ACTIVE_CONTRACT as _TC,
canonical_tool as _ct,
)
_pick = _ct(_name)
turn_contract = replace(
_driver_base_contract,
required=frozenset(),
offered=frozenset({_pick}),
executable=frozenset(
set(_driver_base_contract.executable) | {_pick}),
schema_json=(json.dumps(_sel[0]),),
required_read_operation=None,
)
_TC.set(turn_contract)
except Exception:
turn_contract = _driver_base_contract
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s contract admit failed; '
'normal contract kept', round_number, _name)
else:
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s not executable; normal menu this round',
round_number, _name)
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s p=%s', round_number,
(_drv.get('tool_name') if _drv_choice == 'tool'
else _drv_choice),
_drv.get('p'))
else:
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=fallback p=None', round_number)
pending, content, round_reasoning = {}, '', ''
document_preview_index = None
document_preview_content = ''
@@ -6056,6 +6596,49 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
request = search_tool_choice_request(request)
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
request = provider_compatible_tool_choice_request(request, model)
# Log the tool surface actually sent to the provider: the
# metrics `tool_schema_names` reports the pre-narrowing
# `offered` set, which hid the fact that a forced choice had
# shrunk the provider tools array to one schema.
logging.getLogger(__name__).info(
"clean preview round %s sending %d tool(s): %s",
round_number,
len(request.get('tools') or []),
[
(schema.get('function') or {}).get('name')
for schema in (request.get('tools') or [])
][:40],
)
if round_number == 1 and _spec_direct_p is not None:
# The router decided to answer directly with high
# confidence.
if _spec_direct_empty_menu:
# Option B: an empty menu — send no tools at all, so
# nothing can be executed and no markup is recovered.
request['tools'] = []
request.pop('tool_choice', None)
tool_call_requested = False
# Keep the thinking router's decision; never let the
# no-tools progressive rule turn thinking on when the
# router said off.
if not (_spec_route_think or {}).get('think'):
ctk = dict(request.get('chat_template_kwargs') or {})
ctk['enable_thinking'] = False
request['chat_template_kwargs'] = ctk
logging.getLogger(__name__).info(
'[spec-harness] direct answer: empty menu p=%s',
_spec_direct_p)
else:
# Default: keep the same tools list (prefix-cache
# friendly) but forbid a tool call this round. Thinking
# is left to the thinking router; offered stays
# non-empty so this does not switch progressive
# thinking on.
request['tool_choice'] = 'none'
tool_call_requested = False
logging.getLogger(__name__).info(
'[spec-harness] direct answer: tool_choice none p=%s',
_spec_direct_p)
if active_editor_target and request.get('tools'):
# Edits share mutable document state. Await a saved result
# before asking for another call; one call may still batch
@@ -6144,6 +6727,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
and not progressive_thinking
and not source_lookup_pending
and not tool_call_requested
and not _spec_direct_empty_menu
):
text_event = {'delta': text}
if replace_streamed_draft_on_finish and not streamed_round_text:
@@ -6197,6 +6781,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if content and not prior_summary_answer and not source_lookup_pending:
yield event({'delta': content})
streamed_round_text = True
if _spec_direct_empty_menu and pending:
# Option B: nothing is offered, so never execute a tool
# call the model still produced (native shape).
logging.getLogger(__name__).info(
'[spec-harness] direct answer: dropped %d tool call(s)',
len(pending))
pending = {}
proposed = [pending[i] for i in sorted(pending)]
# A lead-in emitted before a tool call is live progress, not
# part of the terminal answer. Replace that draft when the
@@ -6204,7 +6795,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if proposed and streamed_round_text:
replace_streamed_draft_on_finish = True
unexecutable_dsml_completion = False
if not proposed and 'DSML' in content:
if (not proposed and not _spec_direct_empty_menu
and _driver_round_kind != 'answer_directly'
and 'DSML' in content):
offered_by_canonical = {
canonical(schema['function']['name']): schema['function']['name']
for schema in round_offered
@@ -6252,6 +6845,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
})
else:
unexecutable_dsml_completion = True
if _spec_direct_empty_menu and content:
# Option B: show the answer text without any tool-call
# markup the model may still have written (nothing offered).
_stripped = re.sub(
r'<tool_call\b[^>]*>.*?</tool_call>', '',
content, flags=re.DOTALL | re.IGNORECASE).strip()
if _stripped != content.strip():
content = _stripped
replace_streamed_draft_on_finish = True
if source_lookup_pending and not proposed:
if source_answer_retries < 1 and round_number < round_limit:
source_answer_retries += 1
@@ -6276,6 +6878,37 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
)
if active_editor_target:
proposed = drop_redundant_editor_noops(proposed)
# ODYSSEUS_SPEC_MODE=driver: Spec is the tool driver, so execute
# ONLY the call Spec picked this round. Drop (never execute) any
# other native call and any text/DSML-recovered call; when Spec
# said answer_directly, keep no calls and strip tool markup so
# the remaining text is the answer.
if _driver_round_kind == 'answer_directly':
if proposed:
for _c in proposed:
logging.getLogger(__name__).info(
'[spec-driver] dropped off-pick call name=%s',
(_c.get('function') or {}).get('name'))
proposed = []
if content:
_stripped = strip_tool_blocks(content, skip_fenced=True).strip()
_stripped = re.sub(
r'<tool_call\b[^>]*>.*?</tool_call>', '',
_stripped, flags=re.DOTALL | re.IGNORECASE).strip()
if _stripped != content:
content = _stripped
replace_streamed_draft_on_finish = True
elif _driver_round_kind in ('tool', 'ask_user'):
_want = 'ask_user' if _driver_round_kind == 'ask_user' else _driver_round_pick
_keep = []
for _c in proposed:
_cn = (_c.get('function') or {}).get('name')
if canonical(str(_cn or '')) == canonical(str(_want or '')):
_keep.append(_c)
else:
logging.getLogger(__name__).info(
'[spec-driver] dropped off-pick call name=%s', _cn)
proposed = _keep
if model_choice_experiment:
for proposal in proposed:
yield event({'type': 'model_tool_proposal', 'round': round_number,
@@ -6695,6 +7328,25 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
# that draft and persisted history with the evidence-backed result.
history.pop()
refusal = denied_response()
# A photo-editor turn with no canvas command actually executed
# (no browser editor attached) cannot claim the edit. Point the
# user at the editor instead of the generic no-op refusal.
if 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ()):
touched_canvas = any(
str(e.get('tool') or '') in {
'run_editor_command', 'get_editor_screenshot',
'ai_edit_image', 'generate_image',
}
and not e.get('error') and e.get('exit_code', 0) == 0
for e in executions
)
if not touched_canvas:
refusal = (
'This photo is not open on the editor canvas, so I cannot '
'apply edits to it from chat. Open the attached image in the '
'editor (Quick Edit) and send the request there, and I will '
'apply it to the canvas.'
)
history.append({'role': 'assistant', 'content': refusal})
yield event({'type': 'final_response', 'content': refusal})
break
@@ -7948,6 +8600,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
output, user_text=latest_user,
max_items=contract_item_limit(turn_contract, 20),
)
followup = note_locator_followup(output, user_text=latest_user)
if followup:
if round_number < round_limit:
structured_terminal_response = ''
round_recovery_messages.append(followup)
else:
structured_terminal_response = (
'I found a note, but could not read its content in this turn.'
)
action = str(args.get('action') or '').replace('-', '_').casefold()
if (
action in {'search', 'find'}
+79 -1
View File
@@ -92,6 +92,84 @@ def prune_multimodal_images(
return pruned
_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:
"""Best-effort '[Image: name]' label for a message whose image parts are dropped."""
name = ""
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
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
if not name:
text = _content_as_text(content)
match = _IMAGE_TURN_LABEL_RE.search(text)
if match:
name = match.group(1).strip()
return f"[Image: {name}]" if name else "[Image]"
def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict]:
"""Keep image parts only for the last `keep` messages that carry an image.
Older image blocks are dropped and replaced with a short ``[Image: name]``
text label so the model still knows a picture was shared (and its name)
without paying the image tokens on every later turn. Non-text, non-image
multimodal blocks (e.g. audio) are left untouched.
"""
limit = max(0, int(keep))
image_messages = [
index for index, message in enumerate(messages)
if isinstance(message, dict)
and isinstance(message.get("content"), list)
and any(
isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES
for item in message["content"]
)
]
if len(image_messages) <= limit:
return list(messages)
selected = set(image_messages[len(image_messages) - limit:]) if limit else set()
trimmed: List[Dict] = []
for message_index, message in enumerate(messages):
if message_index in selected or not isinstance(message, dict):
trimmed.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
trimmed.append(message)
continue
kept_blocks = [
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):
trimmed.append(message)
continue
label = _image_turn_label(message)
# Attach the label to the trailing text block when possible so we do not
# introduce an extra content block; otherwise append a fresh text block.
labelled = False
for item in reversed(kept_blocks):
if isinstance(item, dict) and item.get("type") == "text":
item["text"] = (item.get("text") or "").rstrip() + f"\n{label}"
labelled = True
break
if not labelled:
kept_blocks.append({"type": "text", "text": label})
cloned = dict(message)
cloned["content"] = kept_blocks
trimmed.append(cloned)
return trimmed
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
SUMMARY_MAX_TOKENS = 1024
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
@@ -106,7 +184,7 @@ def auto_compact_threshold_percent() -> int:
return max(50, min(95, value))
# Cursor-style self-summarization prompt — produces structured, dense summaries
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly.
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
Use this format:
+47 -4
View File
@@ -2,6 +2,7 @@
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
import os
import io
import logging
import mimetypes
import base64
@@ -496,6 +497,49 @@ def analyze_image_with_vl(image_path: str, owner: str | None = None) -> str:
return analyze_image_with_vl_result(image_path, owner=owner).get("text", "")
def _vision_max_side() -> int:
"""Longest-side cap for images inlined into the chat request (tokens)."""
try:
value = int(os.getenv("ODYSSEUS_VISION_MAX_SIDE", "1280") or 1280)
except (TypeError, ValueError):
value = 1280
return max(0, value)
def encode_image_data_url(path: str, fallback_format: str) -> tuple[str, str]:
"""Return (data_url, image_format) for an image, downscaled if large.
Downscale the longest side to ODYSSEUS_VISION_MAX_SIDE (default 1280) and
re-encode as JPEG so the chat model does not pay full-resolution image
tokens. Small/undecodable images fall back to the original bytes so a
real attachment is never dropped.
"""
max_side = _vision_max_side()
try:
from PIL import Image # local import: keep module import cost low
with Image.open(path) as img:
img = img.convert("RGB") if img.mode not in ("RGB", "L") else img
width, height = img.size
if max_side and max(width, height) > max_side:
scale = max_side / float(max(width, height))
img = img.resize(
(max(1, round(width * scale)), max(1, round(height * scale))),
Image.LANCZOS,
)
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=85, optimize=True)
return (
"data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("utf-8"),
"jpeg",
)
except Exception as exc:
logger.debug(f"Image downscale failed for {path}: {exc}; sending original bytes")
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
image_format = (fallback_format or "png").lower()
return f"data:image/{image_format};base64,{encoded_string}", image_format
def build_user_content(
text: str,
attachment_ids: list[str] | None,
@@ -542,15 +586,14 @@ def build_user_content(
if upload_handler.is_image_file(display_name, mime):
try:
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
# so fall back to the resolved MIME subtype rather than emitting
# an invalid "data:image/;base64," with an empty subtype.
image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
fallback_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
data_url, image_format = encode_image_data_url(path, fallback_format)
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
"image_url": {"url": data_url},
})
except Exception as e:
logger.error(f"Failed to encode image {fid}: {e}")
+20 -13
View File
@@ -382,24 +382,33 @@ def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional
}
# 2. Explicit hosted fallback selection
if target in ("default", "hosted"):
try:
from src.endpoint_resolver import resolve_endpoint
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model:
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model:
if target in ("default", "hosted"):
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
except Exception:
pass
if target and target.rstrip("/") == url.rstrip("/"):
if not requested_model or requested_model == model:
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
except Exception:
pass
if target in ("default", "hosted"):
return None
# 3. Bare model id without endpoint prefix
@@ -570,7 +579,6 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
break
except Exception:
pass
if override:
cand = _resolve_override_candidate(override, owner)
if cand is not None:
@@ -579,7 +587,6 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
# Unauthorized, invalid, foreign, disabled, or forged override:
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
return []
return cands
+7 -7
View File
@@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile(
)
_RECOLOR_TRAILING_ADJ = re.compile(
r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|"
r"pale|more|less|very|really|slightly|totally|completely)\b\s*$",
re.I,
)
_RECOLOR_TRAILING_ADJECTIVES = frozenset({
"bright", "brighter", "vivid", "vibrant", "dark", "darker", "light",
"lighter", "deep", "pale", "more", "less", "very", "really",
"slightly", "totally", "completely",
})
def _hex_rgb01(hex_color: str):
@@ -557,9 +557,9 @@ def _recolor_object_phrase(prompt: str, target_word: str) -> str:
words.pop(0)
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
words.pop()
while len(words) > 1 and words[-1].lower() in _RECOLOR_TRAILING_ADJECTIVES:
words.pop()
phrase = " ".join(words)
while phrase and _RECOLOR_TRAILING_ADJ.search(phrase):
phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip()
return phrase or "the subject"
+113 -6
View File
@@ -2,7 +2,8 @@
import json
import time
import copy
from dataclasses import dataclass
import re
from dataclasses import dataclass, replace
@dataclass(frozen=True)
@@ -19,7 +20,7 @@ _DEPENDENCIES = {
'web': {'web_search', 'web_fetch', 'private_browser'},
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
'download_attachment'},
'contacts': {'resolve_contact'},
'contacts': {'resolve_contact', 'manage_contact'},
'documents': {'search_documents', 'read_document'},
}
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
@@ -116,8 +117,9 @@ def parse_email_task_intent(value):
def scope_email_tools(schemas, intent, *, active_editor=False):
if intent.operation == 'read' and intent.dependencies:
allowed = set().union(*(_DEPENDENCIES[d] for d in intent.dependencies))
if active_editor:
allowed = {tool for dependency in intent.dependencies
for tool in _DEPENDENCIES[dependency]}
if active_editor and intent.dependencies:
allowed.update(_OPEN_EDITOR_TOOLS)
if intent.needs_clarification:
allowed.add('ask_user')
@@ -126,6 +128,9 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
if intent.operation not in {'draft', 'revise'}:
return list(schemas)
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
# manage_contact is a read-only runtime source for lookups, not a
# composition tool. Drafts still resolve recipients through resolve_contact.
allowed.discard('manage_contact')
if active_editor:
allowed.update(_OPEN_EDITOR_TOOLS)
if not intent.needs_clarification:
@@ -145,6 +150,62 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
# as though it were the whole task. This byte budget is deliberately conservative.
CLASSIFIER_CONTEXT_BYTES = 24000
# The model can produce a structurally valid classification while forgetting
# the source or mistaking a list of message headers for a request for bodies.
_OWN_MAILBOX = re.compile(
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
r'(?:e-?mails?|mail|inbox|mailbox)\b', re.IGNORECASE,
)
_MAILBOX_HEADER_LIST = re.compile(
r'^\s*(?:list|show|display|what(?:\'s| are))\s+(?:me\s+)?(?:my|our)\s+'
r'(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
r'(?:e-?mails|messages)\s*'
r'(?:with\s+(?:(?:sender|subject|date|time|recipient|status)\s*'
r'(?:(?:,|and)\s*)?)+)?[?.!]*\s*$', re.IGNORECASE,
)
_EMAIL_HEADER_FIELDS = re.compile(r'\b(?:subjects?|senders?|dates?)\b', re.IGNORECASE)
_EMAIL_HEADER_FOLLOWUP = re.compile(r'\b(?:those|these|them|their|same)\b', re.IGNORECASE)
_READ_REQUEST = re.compile(r'^\s*(?:please\s+)?(?:show|list|display|find|search|read|what|how many|are there|do i have)\b', re.IGNORECASE)
_EMAIL_COUNT = re.compile(r'^\s*how many\s+(?:(?:unread|read|new)\s+)?(?:e-?mails|messages)\b',
re.IGNORECASE)
_EMAIL_BODY_QUERY = re.compile(
r'\b(?:mention(?:s|ed|ing)?|body|contents?|amount|owe|says?|said|when|where|why|'
r'starts?|deadline|attachments?)\b|\bhow much\b|\bwhat time\b',
re.IGNORECASE)
_CONTACT_EMAIL_FIELD = re.compile(
r'\bcontacts?\b[^.!?\n]{0,100}\bemail\b', re.IGNORECASE,
)
_MAILBOX_SOURCE = re.compile(
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
r'(?:e-?mails?|inbox|mailbox)\b|\b(?:inbox|mailbox|email\s+history)\b|'
r'\b(?:e-?mails?|messages?)\s+(?:from|to|sent|received)\b|'
r'\bemailed?\s+(?:me|us)\b', re.IGNORECASE,
)
EMAIL_TASK_RESPONSE_FORMAT = {
'type': 'json_schema',
'json_schema': {
'name': 'email_task_intent',
'strict': True,
'schema': {
'type': 'object',
'additionalProperties': False,
'properties': {
'operation': {'type': 'string', 'enum': ['draft', 'revise', 'read', 'send', 'other']},
'requires_content': {'type': 'boolean'},
'dependencies': {'type': 'array', 'items': {
'type': 'string', 'enum': list(_DEPENDENCIES),
}},
'destination': {'type': 'string', 'enum': ['chat', 'mailbox']},
'needs_clarification': {'type': 'boolean'},
'summary': {'type': 'string'},
},
'required': ['operation', 'requires_content', 'dependencies', 'destination',
'needs_clarification', 'summary'],
},
},
}
async def classify_email_task(client, *, endpoint_url, headers, model, history,
supplied_context=None, accounting=None):
@@ -152,6 +213,8 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
# text from multimodal messages, so an attached image cannot hide the latest
# instruction and leave us classifying an earlier task instead.
dialogue = []
latest_user_text = ''
prior_user_text = ''
for row in history:
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
continue
@@ -166,6 +229,9 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
and isinstance(block.get('text'), str))
if isinstance(content, str):
dialogue.append({'role': row['role'], 'content': content})
if row['role'] == 'user':
prior_user_text = latest_user_text
latest_user_text = content
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
ensure_ascii=False)
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
@@ -174,7 +240,7 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
'chat_template_kwargs': {'enable_thinking': False},
'response_format': {'type': 'json_object'},
'response_format': EMAIL_TASK_RESPONSE_FORMAT,
'messages': [{'role': 'system', 'content': (
'Classify the current conversational task. Return JSON only with operation '
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
@@ -250,6 +316,47 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
'response_time': round(time.monotonic() - started, 3),
})
try:
return parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
intent = parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
if intent.operation == 'read' and not intent.needs_clarification:
# In a contact-record question, "email" can name an address field
# rather than the mailbox as a source. Keep both dependencies when
# the request also names a mailbox/history source.
contact_email_field = bool(
_CONTACT_EMAIL_FIELD.search(latest_user_text)
and not _MAILBOX_SOURCE.search(latest_user_text)
)
if contact_email_field:
intent = replace(intent, dependencies=tuple(dict.fromkeys(
(*[d for d in intent.dependencies if d != 'email'], 'contacts')
)))
# Reuse the product's existing source classifier rather than a
# second vocabulary of mailbox phrases. The classifier has a known
# gap for possessive single-message questions, so retain that
# narrow fallback only when it found no competing source family.
from src.turn_contract import requested_capabilities
capabilities = requested_capabilities(latest_user_text)
# A header-only reference can inherit the immediately preceding
# mailbox read, but an intervening request or a competing source
# breaks that link. Never infer a read from an earlier write.
header_followup = (
not capabilities
and bool(_EMAIL_HEADER_FIELDS.search(latest_user_text))
and bool(_EMAIL_HEADER_FOLLOWUP.search(latest_user_text))
and bool(_READ_REQUEST.match(prior_user_text))
and requested_capabilities(prior_user_text) == {'email'}
)
mailbox_read = not contact_email_field and (capabilities == {'email'} or (
not capabilities and bool(_OWN_MAILBOX.search(latest_user_text))
) or header_followup)
else:
mailbox_read = False
if mailbox_read:
intent = replace(intent, dependencies=tuple(dict.fromkeys((*intent.dependencies, 'email'))))
if (not _EMAIL_BODY_QUERY.search(latest_user_text)
and (_MAILBOX_HEADER_LIST.fullmatch(latest_user_text)
or _EMAIL_HEADER_FIELDS.search(latest_user_text)
or _EMAIL_COUNT.match(latest_user_text))):
intent = replace(intent, requires_content=False)
return intent
except (KeyError, IndexError, TypeError) as exc:
raise ValueError('Invalid classifier response') from exc
-6
View File
@@ -119,12 +119,6 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
bare = tool.removeprefix("mcp__email__")
if bare == "list_email_accounts":
accounts = {}
for index, message in enumerate(messages):
row = _row(index, message)
identity = (row["account_id"], row["account"], row["account_email"])
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
"email": row["account_email"], "default": not accounts})
return {
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
"output": "Fixture mailbox (default)",
+121
View File
@@ -0,0 +1,121 @@
"""Thin client for the local jeff-serve decision endpoint (POST /v1/systemone).
Trained decision adapters (triage, spam, guard-email, ...) answer a situation
with calibrated probabilities over named options in a single forward pass, with
no generated text to parse. Background jobs can prefer an adapter and fall back
to the normal LLM candidate chain whenever this client returns None.
Every call fails open (returns None) and is disabled per job unless the job's
flag is set, so importing/enabling this never changes behaviour on its own.
Env:
ODYSSEUS_JEFF_URL base URL of jeff-serve (default http://127.0.0.1:8765)
ODYSSEUS_JEFF_TIMEOUT per-request timeout in seconds (default 4.0)
ODYSSEUS_JEFF_<JOB> per-job enable flag, e.g. ODYSSEUS_JEFF_EMAIL_URGENCY=1
"""
from __future__ import annotations
import logging
import math
import os
from typing import Any, Dict, Optional
import httpx
logger = logging.getLogger(__name__)
_DEFAULT_URL = "http://127.0.0.1:8765"
_FALSEY = ("", "0", "false", "off", "no")
def jeff_url() -> str:
"""Base URL of the local jeff-serve (no trailing slash)."""
return os.getenv("ODYSSEUS_JEFF_URL", _DEFAULT_URL).strip().rstrip("/")
def jeff_job_enabled(job: str) -> bool:
"""True when ODYSSEUS_JEFF_<JOB> is set to a truthy value. Default off."""
slug = "".join(c if (c.isalnum() or c == "_") else "_" for c in (job or "")).upper()
return os.getenv("ODYSSEUS_JEFF_" + slug, "0").strip().lower() not in _FALSEY
def jeff_timeout(default: float = 4.0) -> float:
try:
return float(os.getenv("ODYSSEUS_JEFF_TIMEOUT", str(default)))
except Exception:
return default
async def jeff_systemone(
model: str,
state: Dict[str, Any],
questions: Dict[str, Any],
*,
timeout: Optional[float] = None,
orders: int = 1,
client: Optional[httpx.AsyncClient] = None,
) -> Optional[Dict[str, Any]]:
"""POST a decision request and return the ``answers`` dict, or None on error.
Each question is scored in its own forward pass, so send only questions the
adapter was trained on. Raises are swallowed and logged (fail open).
"""
url = jeff_url()
if not url:
return None
payload: Dict[str, Any] = {"model": model, "state": state, "questions": questions}
if orders and orders != 1:
payload["orders"] = orders
to = timeout if timeout is not None else jeff_timeout()
try:
if client is None:
async with httpx.AsyncClient(timeout=to) as _c:
resp = await _c.post(url + "/v1/systemone", json=payload)
else:
resp = await client.post(url + "/v1/systemone", json=payload)
resp.raise_for_status()
answers = (resp.json() or {}).get("answers")
return answers if isinstance(answers, dict) else None
except Exception as exc:
logger.warning("jeff decision (%s) failed: %s", model, exc)
return None
async def jeff_available(timeout: float = 1.5) -> bool:
"""Best-effort health probe for the local jeff-serve."""
url = jeff_url()
if not url:
return False
try:
async with httpx.AsyncClient(timeout=timeout) as _c:
resp = await _c.get(url + "/health")
return resp.status_code == 200
except Exception:
return False
def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
if not isinstance(answer, dict):
return None
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
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]:
"""The winning key of a jeff ``choice`` answer, or None."""
if isinstance(answer, dict) and answer.get("type") == "choice":
c = answer.get("choice")
return str(c) if c is not None else None
return None
+38
View File
@@ -0,0 +1,38 @@
"""Pre-Wave-3 private_browser schema (agent-browser wrapper), used when ODYSSEUS_PRIVATE_BROWSER_LEGACY is on."""
PRIVATE_BROWSER_SCHEMA = {
"type": "function",
"function": {
"name": "private_browser",
"description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]},
"url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"},
"selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"},
"target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"},
"key": {"type": "string", "description": "Key name for press action, e.g. Enter"},
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"},
"amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"},
"text": {"type": "string", "description": "Text for fill action"},
"value": {"type": "string", "description": "Alternative text/value for fill action"},
"find": {"type": "string", "description": "Visible text to locate for find action"},
"script": {"type": "string", "description": "JavaScript expression for evaluate action"},
"path": {"type": "string", "description": "Optional output path for screenshot"},
"commands": {
"type": "array",
"description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.",
"items": {
"oneOf": [
{"type": "array", "items": {"type": "string"}},
{"type": "object"},
]
},
},
"timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"}
},
"required": ["action"]
}
}
}
+7 -5
View File
@@ -67,7 +67,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
"""
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
from src.llm_core import llm_call_async
from src.task_endpoint import resolve_task_endpoint
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
db = SessionLocal()
try:
@@ -164,8 +164,6 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
url, model, headers = resolve_task_endpoint(owner=owner or None)
if not url:
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. No model endpoint available for sorting."
names_text = "\n".join(f' "{s["id"][:8]}": "{s["name"]}"' for s in session_list)
prompt = (
@@ -183,8 +181,12 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
try:
# 16384 (was 4096): large folder JSON + reasoning-model thinking
# overflowed 4096 and truncated the JSON, so it never parsed.
raw = await llm_call_async(url, model, [{"role": "user", "content": prompt}],
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
raw = await task_llm_call_async(
[{"role": "user", "content": prompt}],
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=owner or None,
temperature=0.3, max_tokens=16384, timeout=120,
)
except Exception as e:
logger.warning(f"Auto-sort LLM call failed: {e}")
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. Folder sort skipped (model unreachable)."
+2 -3
View File
@@ -47,9 +47,8 @@ DEFAULT_SETTINGS = {
"image_gen_enabled": False,
"image_model": "",
"image_quality": "medium",
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
# back to the hosted chat model. See src/editor_agent_model.py.
# Optional editor planner/caption endpoints. Empty uses configured chat
# fallback without probing a developer-specific host.
"editor_agent_base_url": "",
"editor_agent_model": "",
"editor_caption_base_url": "",
+108
View File
@@ -0,0 +1,108 @@
"""Helpers for the experimental Spec driver, whose execution is disabled."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
import httpx
from src import spec_router
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
from src.clean_agent_preview import (
event, preview_model_response, preview_tool_result_text,
)
from src.tool_capabilities import ToolRunSecurityContext
from src.tool_execution import execute_tool_block
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS, function_call_to_tool_block
logger = logging.getLogger(__name__)
def _text(content):
if isinstance(content, str):
return content
if isinstance(content, list):
return '\n'.join(str(p.get('text') or '') for p in content
if isinstance(p, dict) and p.get('type') == 'text')
return ''
def _name(schema):
return (schema.get('function') or {}).get('name') or ''
# JSON-schema keywords the provider's guided decoding (named tool_choice) cannot compile.
_GRAMMAR_UNSUPPORTED = frozenset({
'uniqueItems', 'contains', 'minContains', 'maxContains', 'patternProperties', 'propertyNames',
'dependentRequired', 'dependentSchemas', 'if', 'then', 'else',
'unevaluatedProperties', 'unevaluatedItems',
})
def grammar_safe(schema):
"""Copy of a schema without keywords guided decoding rejects (validation still runs at execution)."""
if isinstance(schema, dict):
return {k: grammar_safe(v) for k, v in schema.items() if k not in _GRAMMAR_UNSUPPORTED}
if isinstance(schema, list):
return [grammar_safe(v) for v in schema]
return schema
def spec_surface(disabled_tools):
disabled = set(disabled_tools or ())
return [s for s in FUNCTION_TOOL_SCHEMAS if _name(s) and _name(s) not in disabled]
async def _model_round(client, endpoint_url, headers, request, recovery):
"""Run one chat-model round; return (content, tool_calls, usage)."""
content, calls, usage, raw = '', {}, {}, []
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
async for line in response.aiter_lines():
if len(raw) < 4 and line.strip():
raw.append(line[:400])
if line.strip() and not line.startswith('data: '):
logger.warning('[spec-driver] unexpected stream line: %s', line[:500])
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
continue
try:
chunk = json.loads(line[6:])
except ValueError:
continue
if chunk.get('error') or chunk.get('object') == 'error':
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
usage = chunk.get('usage') or usage
for choice in chunk.get('choices') or []:
delta = choice.get('delta') or {}
text = delta.get('content') or ''
content += text
for frag in delta.get('tool_calls') or []:
call = calls.setdefault(frag.get('index', 0), {'id': '', 'name': '', 'arguments': ''})
call['id'] = frag.get('id') or call['id']
fn = frag.get('function') or {}
call['name'] += fn.get('name') or ''
call['arguments'] += fn.get('arguments') or ''
if not calls:
logger.warning('[spec-driver] no tool call; status=%s max_tokens=%s msgs=%s first lines=%s',
getattr(response, 'status_code', None), request.get('max_tokens'),
[(m.get('role'), type(m.get('content')).__name__,
[p.get('type') for p in m['content']] if isinstance(m.get('content'), list) else len(str(m.get('content') or '')))
for m in request.get('messages', [])], raw[-2:])
return content, [calls[i] for i in sorted(calls)], usage
@with_request_authority
async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id, owner,
disabled_tools, tool_policy, workspace=None, active_document=None,
client_runtime_context=None, external_untrusted_context_seen=False,
max_tokens=768, max_rounds=8, temperature=0.0,
request_authority=MISSING_AUTHORITY, **ignored):
# 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
+661
View File
@@ -0,0 +1,661 @@
"""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 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)
ODYSSEUS_SPEC_ROUTER_MODEL adapter name (default: odysseus-s60)
ODYSSEUS_SPEC_ROUTER_TIMEOUT seconds (default: 0.5)
ODYSSEUS_SPEC_ROUTER_MIN_CONF minimum top-1 probability to act (default: 0.3)
ODYSSEUS_SPEC_ROUTER_SECOND minimum 2nd-choice probability to also add it (default: 0.25)
"""
from __future__ import annotations
import json
import logging
import 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 = {
"answer_directly": "No tool is needed: answer the user directly from the conversation",
"ask_user": "A tool is needed but required information is missing: ask the user first",
}
_INSTRUCTIONS = (
"Which tool should the agent call next to handle the user's latest message? "
"Use the conversation for context. If no tool is needed, choose answer directly. "
"If a tool is needed but information it requires is missing, choose ask the user."
)
_AGENT = "Odysseus, a personal tool-using assistant."
_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",
"mcp__builtin_browser__browser_click",
"mcp__builtin_browser__browser_type",
"mcp__builtin_browser__browser_press_key",
"mcp__builtin_browser__browser_wait_for",
"mcp__builtin_browser__browser_take_screenshot",
)
def _fn(schema: Dict[str, Any]) -> Dict[str, Any]:
return schema.get("function") if isinstance(schema.get("function"), dict) else schema
def tool_line(schema: Dict[str, Any]) -> str:
fn = _fn(schema)
name = str(fn.get("name") or "")
props = ((fn.get("parameters") or {}).get("properties") or {})
if os.getenv("ODYSSEUS_SPEC_TOOL_FORMAT", "").strip().lower() == "names":
return name + "(" + ", ".join(props) + ")" # names + parameters only (spec_v1n adapters are trained on this)
desc = " ".join(str(fn.get("description") or "").split())
if len(desc) > 220:
desc = desc[:217] + "..."
return f"{name}({', '.join(props)}): {desc}"
def prior_tool_names(messages: Optional[Iterable[Dict[str, Any]]]) -> Set[str]:
"""Tools already called in this conversation (kept offered: no 'forgetting')."""
seen: Set[str] = set()
for m in messages or []:
if not isinstance(m, dict) or m.get("role") != "assistant":
continue
for tc in m.get("tool_calls") or []:
name = (tc.get("function") or tc).get("name") if isinstance(tc, dict) else None
if name:
seen.add(str(name))
return seen
def _conversation(messages: Optional[Iterable[Dict[str, Any]]], limit: int = 4) -> List[Dict[str, str]]:
out: List[Dict[str, str]] = []
for m in list(messages or []):
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip():
out.append({"role": m["role"], "content": m["content"][:600]})
# the last user message is passed separately as user_message
if out and out[-1]["role"] == "user":
out = out[:-1]
return out[-limit:]
def enabled() -> bool:
return bool(os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip())
def spec_route_tools(
user_message: str,
schemas: Iterable[Dict[str, Any]],
disabled_tools: Optional[Set[str]] = None,
messages: Optional[Iterable[Dict[str, Any]]] = None,
return_decision: bool = False,
):
"""Return tool names the router wants offered (plus tools already used).
With return_decision=True returns (tools, decided) where decided is True when
the router answered confidently (a tool pick, or answer_directly)."""
added: Set[str] = set(prior_tool_names(messages))
decided = False
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}},
}
http = urllib.request.Request(
url + "/v1/systemone", data=json.dumps(req).encode(), headers={"content-type": "application/json"}
)
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
deadline = time.time() + timeout
while True:
try:
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
break
except urllib.error.HTTPError as busy:
# 529 = router busy with another request; retry within budget.
if busy.code != 529 or time.time() + 0.15 >= deadline:
raise
decision["retries"] = decision.get("retries", 0) + 1
time.sleep(0.1)
probs = out.get("probabilities") or {}
choice = out.get("choice")
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:
# This build disables page execution in private_browser (see
# src/browser_identity.py). "mcp": use the connected Playwright MCP
# browser (no run_code/evaluate); "web": fetch/search only.
picks = [n for n in picks if n != "private_browser"]
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
for n in extra + ["web_search", "web_fetch"]:
if n not in picks:
picks.append(n)
added.update(picks)
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
except Exception as exc: # fail open
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
_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
# ---- Ajax Spec: system prompt trimmed to the tools actually offered ----------
_CORE_PROMPT = (
"You are Ajax, the assistant in Odysseus. Emit one native tool call, then stop. "
"After a successful tool result, answer from it; never repeat the same tool with the same arguments."
)
_CODING_PROMPT = (
"HARD CODING ORDER: for code changes call read_file, then edit_file or apply_patch, then host_shell for tests. "
"Never run tests before the edit succeeds; after a read, your next call is the edit. "
"edit_file JSON is {path,old_string,new_string}; write_file only for new files or full rewrites."
)
_TOOL_NOTES = {
"manage_notes": "manage_notes: notes/checklists.",
"manage_documents": "manage_documents: document library; actions list/read/delete/tidy, use list+search to find by title/content.",
"create_document": "create_document: create editor-panel documents with title/content.",
"edit_document": "edit_document: edit the active editor document using command=<<<FIND>>>old<<<REPLACE>>>new<<<END>>> or edits=[{find,replace}].",
"update_document": "update_document: full active-document rewrite only.",
"manage_calendar": "manage_calendar: calendar events; actions create_event, update_event, delete_event, list_events.",
"manage_tasks": "manage_tasks: scheduled/recurring tasks; create uses action=create, name, prompt, task_type, schedule, scheduled_time, output_target; pause/resume/delete use task_id, so list/search first when only the name is known. Recurring reminders use manage_tasks.",
"manage_memory": "manage_memory: saved memories.",
"manage_contact": "manage_contact: address-book contacts.",
"manage_research": "manage_research: saved research reports.",
"manage_settings": "manage_settings: app settings.",
"manage_endpoints": "manage_endpoints: configured API endpoints.",
"list_models": "list_models: available models.",
"manage_mcp": "manage_mcp: MCP servers.",
"manage_skills": "manage_skills: skills.",
"manage_webhooks": "manage_webhooks: webhooks.",
"manage_bg_jobs": "manage_bg_jobs: running/background jobs.",
"search_chats": "search_chats: past chats.",
"web_search": "web_search: public web lookup, URLs, links.",
"web_fetch": "web_fetch: fetch a concrete URL/domain.",
"private_browser": "private_browser: rendered site interaction. Make separate calls: first action=open with the url, then action=snapshot; read/scroll/find as needed. Do not use action=batch. Only click/fill element refs returned by snapshot (for example @e12), never guessed labels.",
"mcp__builtin_browser__browser_navigate": "Browser: browser_navigate {url} opens a page; then browser_snapshot returns the page with element refs; browser_click/browser_type take ref values from the latest snapshot, never guessed labels.",
"youtube_tool": "youtube_tool: YouTube comments, transcripts, metadata, and latest channel video.",
"read_file": "read_file: workspace text, PDF, or Office document path.",
"edit_file": "edit_file: exact replacement in an existing workspace file.",
"write_file": "write_file: create or fully rewrite a workspace file.",
"apply_patch": "apply_patch: related multi-file workspace edits.",
"host_shell": "host_shell: run tests, builds, and verification in the active workspace.",
}
_EMAIL_NOTES = (
"Email: latest email/emails/inbox -> mcp__email__list_emails {folder:INBOX,max_results:1,unread_only:false}; "
"account list -> list_email_accounts; subject find+read -> mcp__email__list_emails {folder:INBOX,max_results:20}; "
"sender/topic search -> mcp__email__search_emails {max_results:10}; known id -> mcp__email__read_email; "
"new draft -> mcp__email__draft_email; attachment -> mcp__email__download_attachment {uid,index,folder,account}; "
"reply draft -> mcp__email__draft_email_reply {uid,account,body}; only explicit send-now -> mcp__email__reply_to_email {uid,account,body}; "
"archive/delete/mark -> mcp__email__archive_email/mcp__email__delete_email/mcp__email__mark_email_read. Never emit manage_email."
)
_AJAX_PROMPT_START = "You are Ajax, the assistant in Odysseus."
def trimmed_system_prompt(offered: Iterable[str]) -> str:
offered = set(offered or ())
parts = [_CORE_PROMPT]
if offered & {"edit_file", "apply_patch", "host_shell"}:
parts.append(_CODING_PROMPT)
parts += [_TOOL_NOTES[n] for n in sorted(offered) if n in _TOOL_NOTES]
if any(n.startswith("mcp__email__") or n in {"list_email_accounts"} for n in offered):
parts.append(_EMAIL_NOTES)
return " ".join(parts)
def trim_system_prompt(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
"""Swap the generic Ajax tool manual for one covering only the offered tools."""
out: List[Dict[str, Any]] = []
for m in messages:
if (
isinstance(m, dict)
and m.get("role") == "system"
and isinstance(m.get("content"), str)
and m["content"].startswith(_AJAX_PROMPT_START)
):
new = dict(m)
new["content"] = trimmed_system_prompt(offered)
logger.info("[spec-harness] system prompt trimmed %d -> %d chars", len(m["content"]), len(new["content"]))
out.append(new)
else:
out.append(m)
return out
def drop_skills_index(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
"""Ajax Spec: omit the ~1000-token skills index unless the skills tool is offered."""
if "manage_skills" in set(offered or ()):
return messages
out = [
m for m in messages
if not (isinstance(m, dict) and (m.get("metadata") or {}).get("source") == "available skills index")
]
if len(out) != len(messages):
logger.info("[spec-harness] dropped skills index (%d chars)", sum(len(str(m.get("content") or "")) for m in messages if m not in out))
return out
# --- Router-decided thinking (progressive thinking trigger) -------------------------------------------------
# The router classifies the user's request as THINK (needs reasoning: math/logic, tricky planning, subtle explanation or
# weighing options, first-instinct-likely-wrong questions) or DIRECT (plain doing, lookup, chit-chat).
# Zero-shot on 143 DeepSeek-labelled app requests (2026-10-13): odysseus-agentic-v1f acc 0.916, think recall 0.985,
# think precision 0.859, ~33 ms. Off unless ODYSSEUS_SPEC_THINK_ROUTER=1. Never raises; returns None on any error.
_THINK_DEF = ("This request needs genuine reasoning or figuring something out before answering: math and logic puzzles; "
"planning a tricky multi-step approach; explaining something subtle or weighing options; or questions where "
"a quick first instinct is likely wrong.")
_DIRECT_DEF = ("This request is plain doing, lookup, or chit-chat that can be answered directly without reasoning: simple "
"tool actions (set a reminder, search email, open a page, rename a file), simple facts, or small talk.")
_THINK_INSTRUCTIONS = ("Classify whether this single user request needs the assistant to THINK (deep reasoning / figuring "
"something out) before answering, or can be answered DIRECTLY (plain doing, lookup, or chit-chat). "
"Answer with exactly one of the two options.")
# 5-way request kind, exactly as trained into adapter thinkclass5 (2026-10-13; held-out 143: think/direct 96.5%,
# think precision 98.8%, recall 95.2%). Enabled with ODYSSEUS_SPEC_THINK_QUESTION=kind5 + ODYSSEUS_SPEC_THINK_MODEL=thinkclass5.
_KIND5_QUESTION = {"type": "choice", "instructions": "Read the user's request and classify it into exactly one of five kinds: math_logic, tricky_planning, explain_or_weigh, trap_question, or direct. Pick the option whose description fits best.", "criteria": {"math_logic": "Mathematical or logical computation/reasoning: arithmetic, word problems, logic puzzles, counting, proofs.", "tricky_planning": "Planning a multi-step approach with real constraints: schedules, itineraries, sequencing, resource or budget allocation, multi-part procedures.", "explain_or_weigh": "Explaining something subtle or weighing options/tradeoffs: comparisons, 'should I', pros and cons, recommendations with reasoning, nuanced why-explanations.", "trap_question": "A question where the obvious or first-instinct answer is likely wrong: riddles, trick questions, common misconceptions, counterintuitive facts.", "direct": "Plain doing, lookup, or chit-chat: simple tool actions, simple facts, small talk — no deep reasoning needed."}}
def think_router_enabled() -> bool:
return os.getenv("ODYSSEUS_SPEC_THINK_ROUTER", "0").strip() not in ("", "0", "false", "off")
def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
"""Return {'think': bool, 'p_think': float, 'ms': float} or None (disabled / error / empty message)."""
if not think_router_enabled():
return None
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
text = (user_message or "").strip()
if not url or not text:
return None
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
req = {
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
"questions": {"thinking": question},
}
t0 = time.time()
try:
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
headers={"content-type": "application/json"})
ans = json.load(urllib.request.urlopen(http, timeout=timeout))["answers"]["thinking"]
probs = ans.get("probabilities") or {}
# kind5: think = any of the four reasoning kinds (trained adapter thinkclass5); binary: P(think).
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
if kind5:
out["kind"] = ans.get("choice")
logger.info("spec think router: %s", 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,
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
"""Top-1 next action at any confidence: {'kind': 'tool'|'answer_directly'|'ask_user', 'name', 'p'}.
Same request as training (build_agentic_rows.py): sorted tool lines, and mid-turn the latest tool
result appended to the user message as "\\n\\nLatest tool result:\\n...". Returns None on any error."""
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
if not url:
return None
t0 = time.time()
disabled = set(disabled_tools or ())
lines: Dict[str, str] = {}
for s in schemas:
n = str(_fn(s).get("name") or "")
if n and n not in disabled and n != "ask_user" and n not in lines:
lines[n] = tool_line(s)
names = sorted(lines)
criteria = dict(_HEAD)
for i, n in enumerate(names, 1):
criteria[f"t{i}"] = lines[n]
um = (user_message or "")[-3000:]
if latest_result:
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
req = {
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
}
try:
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
headers={"content-type": "application/json"})
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
deadline = time.time() + timeout
while True:
try:
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
break
except urllib.error.HTTPError as busy:
if busy.code != 529 or time.time() + 0.15 >= deadline:
raise
time.sleep(0.1)
probs = out.get("probabilities") or {}
choice = str(out.get("choice") or "")
if choice.startswith("t") and choice[1:].isdigit():
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
elif choice in ("answer_directly", "ask_user"):
action = {"kind": choice, "name": choice}
else:
return None
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
logger.info("spec next action: %s", 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)
return None
+10 -12
View File
@@ -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
View File
@@ -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."""
+10 -2
View File
@@ -227,8 +227,16 @@ _register(
ToolEffect.READ_PRIVATE,
)
_register(
{"plan_edit", "run_editor_command"},
{"plan_edit"},
ToolEffect.NETWORK_EGRESS,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"run_editor_command"},
ToolEffect.READ_PRIVATE,
ToolEffect.WRITE_PRIVATE,
ToolEffect.UI_SIDE_EFFECT,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{
@@ -402,7 +410,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
"manage_settings": frozenset(
{"set", "delete", "reset", "disable_tool", "enable_tool"}
{"set", "delete", "reset", "disable_tool"}
),
"manage_tokens": frozenset({"create", "delete"}),
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
+30 -1
View File
@@ -1558,8 +1558,29 @@ def _agent_subprocess_env() -> dict:
return base
def _legacy_private_browser_enabled() -> bool:
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
from src.agent_tools.web_tools_prewave3 import PrivateBrowserTool
ctx = {
"progress_cb": progress_cb,
"subproc_env": _agent_subprocess_env(),
"session_id": session_id,
"owner": owner,
}
try:
result = await PrivateBrowserTool().execute(operation.input, ctx)
except asyncio.CancelledError:
raise
except Exception as error:
logger.warning("legacy private_browser failed: %s", error)
return f"{transport}: ERROR", {"error": f"private_browser: {error}", "exit_code": 1}
if not isinstance(result, dict):
result = {"output": str(result), "exit_code": 0}
label = "ERROR" if result.get("error") or result.get("exit_code") not in (None, 0) else "EXECUTED"
return f"{transport}: {label}", result
async def _direct_fallback(
@@ -1735,6 +1756,14 @@ async def execute_tool_block(
}
transport = operation.transport_tool
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
# 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)
@@ -2603,4 +2632,4 @@ def format_tool_result(description: str, result: Dict) -> str:
# External execution bridges are allowed to return canonical result shapes
# without using the built-in tools' output cap. Bound the final model-facing
# text here so one verbose command cannot consume the next request window.
return _truncate("\n".join(parts))
return _truncate("\n".join(parts))
+1 -1
View File
@@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"manage_tokens": "API token management: list, create, or delete API access tokens.",
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also turns tools off globally (disable_tool) and lists disabled ones (list_tools); re-enabling is only done by the user in Settings > Agent Tools. Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"create_session": "Create a new chat with a name and model.",
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
"send_to_session": "Send a message to another chat. Cross-chat communication.",
+15 -7
View File
@@ -1292,14 +1292,14 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "manage_settings",
"description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.",
"description": "Manage user preferences and settings. Use `disable_tool`/`list_tools` to turn individual tools off globally or list what is disabled (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Disabled tools can only be re-enabled by the user in Settings > Agent Tools, never from chat. Use list/get/set/delete for free-form preferences.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]},
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "list_tools"]},
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
"tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
"tool": {"type": "string", "description": "Tool name to disable (for disable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
},
"required": ["action"]
}
@@ -1545,7 +1545,7 @@ FUNCTION_TOOL_SCHEMAS = [
"properties": {
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
"params": {"type": "object", "description": "Parameters for the op"},
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"},
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
},
"required": ["op"]
@@ -1560,7 +1560,7 @@ FUNCTION_TOOL_SCHEMAS = [
"parameters": {
"type": "object",
"properties": {
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
}
}
}
@@ -1583,7 +1583,7 @@ FUNCTION_TOOL_SCHEMAS = [
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
"session": {"type": "string", "description": "Editor session id"}
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
},
"required": ["op"]
}
@@ -2000,6 +2000,14 @@ FUNCTION_TOOL_SCHEMAS = [
},
]
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}:
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
FUNCTION_TOOL_SCHEMAS[:] = [
_PB_LEGACY if (schema.get("function") or {}).get("name") == "private_browser" else schema
for schema in FUNCTION_TOOL_SCHEMAS
]
# ---------------------------------------------------------------------------
@@ -2460,4 +2468,4 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
else:
content = json.dumps(args)
return ToolBlock(tool_type, content)
return ToolBlock(tool_type, content)
+6
View File
@@ -769,6 +769,8 @@ _APP_API_BLOCKLIST_PREFIXES = (
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
# Use dedicated tools or UI flows instead.
_APP_API_BLOCKLIST_METHOD_PATH = (
("POST", "/api/tools"), # global tool policy is changed by a human in Settings
("PATCH", "/api/mcp/servers"), # per-server MCP tool policy and server toggles, same reason
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
("DELETE", "/api/cookbook/state"),
@@ -893,6 +895,10 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
return {"error": f"Unsupported method: {method}", "exit_code": 1}
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
if path.startswith("/api/tools"):
return {"error": "Tool policy cannot be replaced from chat. Re-enable tools in Settings > Agent Tools; use manage_settings to list or disable them.", "exit_code": 1}
if path.startswith("/api/mcp/servers"):
return {"error": "MCP server and tool policy cannot be changed from chat. Ask the user to change it in Settings > Integrations.", "exit_code": 1}
if "/api/email/accounts" in path:
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
if "/api/cookbook/packages/install" in path: