mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
Integrate updated Ajax runtime review
This commit is contained in:
+23
-10
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
||||
"subject": subject,
|
||||
"from": sender_name or sender_addr or sender,
|
||||
"from_address": sender_addr,
|
||||
"to": str(row.get("to") or headers.get("To") or ""),
|
||||
"cc": str(row.get("cc") or headers.get("Cc") or ""),
|
||||
"references": str(row.get("references") or headers.get("References") or ""),
|
||||
"date": date_str,
|
||||
"date_epoch": date_epoch,
|
||||
"summary": body[:240],
|
||||
@@ -1557,6 +1560,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
||||
if not isinstance(rows, list):
|
||||
return False
|
||||
owner = _current_owner()
|
||||
matches = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
@@ -1568,14 +1572,19 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
||||
continue
|
||||
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
||||
continue
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
row.pop(key, None)
|
||||
else:
|
||||
row[key] = value
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
return False
|
||||
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
|
||||
continue
|
||||
matches.append(row)
|
||||
if len(matches) != 1:
|
||||
return False
|
||||
row = matches[0]
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
row.pop(key, None)
|
||||
else:
|
||||
row[key] = value
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
# ── Tool implementations ──
|
||||
@@ -2925,7 +2934,9 @@ def _create_email_draft_document(
|
||||
|
||||
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
|
||||
"""Create a threaded Odysseus reply draft document. Does not send."""
|
||||
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
|
||||
fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
|
||||
if _fixture_email_enabled() and (fixture is None or fixture.get("error")):
|
||||
return fixture or {"error": "No email fixture is available for the authenticated owner"}
|
||||
if fixture is not None:
|
||||
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
||||
_, sender_addr = email.utils.parseaddr(sender)
|
||||
@@ -3178,7 +3189,9 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
||||
|
||||
def _set_flag(uid, folder, flag, add=True, account=None):
|
||||
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
||||
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
||||
if _fixture_email_enabled():
|
||||
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is None:
|
||||
return False
|
||||
if flag == "\\Seen":
|
||||
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
||||
if flag == "\\Answered":
|
||||
|
||||
@@ -571,6 +571,50 @@ async def auto_name_session(session_manager, sess):
|
||||
|
||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||
model_name = str(getattr(sess, "model", "") or "")
|
||||
|
||||
# The spec ("Ajax") model names its own chats from the user's first
|
||||
# prompt. This function only runs after the first response stream has
|
||||
# settled (auto_name_session_after_stream), so titling does not
|
||||
# contend with the visible answer. Thinking is disabled and the
|
||||
# budget kept small so the model returns a bare title.
|
||||
try:
|
||||
from src.model_profiles import uses_odysseus_progressive_thinking
|
||||
_is_spec_model = bool(model_name) and uses_odysseus_progressive_thinking(model_name)
|
||||
except Exception:
|
||||
_is_spec_model = False
|
||||
if _is_spec_model and endpoint_url:
|
||||
_headers = getattr(sess, "headers", None)
|
||||
if isinstance(_headers, str):
|
||||
try:
|
||||
_headers = json.loads(_headers)
|
||||
except Exception:
|
||||
_headers = None
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
endpoint_url,
|
||||
model_name,
|
||||
[
|
||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||
{"role": "user", "content": first_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=32,
|
||||
headers=_headers or None,
|
||||
timeout=20,
|
||||
thinking_mode="off",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] spec title call failed for %s: %s", sess.id, e)
|
||||
title = ""
|
||||
title = str(title or "").strip().strip('"\'').strip()
|
||||
from src.text_helpers import strip_think
|
||||
title = strip_think(title, prose=False, prompt_echo=False)
|
||||
if not title or len(title) >= 80 or "\n" in title:
|
||||
title = fallback_session_title(first_msg)
|
||||
session_manager.update_session_name(sess.id, title)
|
||||
logger.info(f"Auto-named spec session {sess.id}: {title}")
|
||||
return
|
||||
|
||||
if (
|
||||
"ttft" in model_name.lower()
|
||||
or re.search(r":18\d{3}\b", endpoint_url)
|
||||
|
||||
+96
-10
@@ -27,11 +27,13 @@ from src.llm_core import (
|
||||
from src.agent_loop import (
|
||||
stream_agent_loop,
|
||||
_configured_model_tool_surface,
|
||||
_configured_model_tool_surface_raw,
|
||||
_local_media_needs_browser_render,
|
||||
_looks_like_workspace_coding_request,
|
||||
)
|
||||
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
||||
from src import agent_runs
|
||||
from src import shutdown as _shutdown
|
||||
from src.model_context import estimate_tokens
|
||||
from src.context_compactor import (
|
||||
apply_compaction_state,
|
||||
@@ -2688,6 +2690,19 @@ def setup_chat_routes(
|
||||
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||
# image-edit chat turn) may carry the open image-editor session id and a
|
||||
# canvas snapshot so the editor tool family can drive it in-process.
|
||||
editor_session = (
|
||||
form_data.get("editor_session")
|
||||
or form_data.get("editor_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
photo_editor_requested = (
|
||||
str(form_data.get("photo_editor") or "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
or bool(editor_session)
|
||||
)
|
||||
logger.info(
|
||||
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
||||
chat_mode, active_doc_id, active_doc_state,
|
||||
@@ -3270,7 +3285,34 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc), workspace=bool(workspace),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
) if _use_turn_contract else frozenset()
|
||||
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
# Scoped photo_editor family: offered ONLY for an explicit editor
|
||||
# request (Quick Edit popup / editor session) or an image-edit chat
|
||||
# turn carrying an image. When active the turn offers just the editor
|
||||
# tools plus ask_user, never the general tool list.
|
||||
_photo_editor_active = bool(_use_turn_contract) and (
|
||||
photo_editor_requested
|
||||
or (
|
||||
"image_editing" in _turn_capabilities
|
||||
and bool(ctx.preprocessed.attachment_meta)
|
||||
)
|
||||
)
|
||||
if _photo_editor_active:
|
||||
if editor_session:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
|
||||
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
|
||||
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
|
||||
'editor and confirm the result with a screenshot.'}
|
||||
else:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The user attached an image but no image editor is open, so the editor tools cannot '
|
||||
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
|
||||
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
|
||||
'tools will then apply it.'}
|
||||
ctx.messages.insert(0, editor_context)
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
|
||||
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
|
||||
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||
if image_refs:
|
||||
@@ -3307,6 +3349,11 @@ def setup_chat_routes(
|
||||
# pasted revision request. This only offers permitted schemas;
|
||||
# it never requires or performs a document mutation.
|
||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
||||
# Scoped photo_editor family (see above): when active the turn offers
|
||||
# just the editor tools plus ask_user, never the general tool list.
|
||||
if _photo_editor_active:
|
||||
_turn_capabilities = frozenset({"photo_editor"})
|
||||
_active_turn_capabilities = _turn_capabilities
|
||||
# Same decision that prepared the turn; it only stamps the contract
|
||||
# inside the agent-contract branch below.
|
||||
_clean_v3_preview = _compact_preview_turn
|
||||
@@ -3556,9 +3603,22 @@ def setup_chat_routes(
|
||||
"document_editor": not {
|
||||
"manage_documents", "create_document", "edit_document", "update_document",
|
||||
}.issubset(disabled_tools),
|
||||
# A photo-editing chat turn can only reach a canvas when the image
|
||||
# editor is actually open. The compact runtime uses this to reply
|
||||
# (open the image in the editor) instead of looping on editor tools
|
||||
# that have no canvas to drive.
|
||||
"photo_editor_open": bool(editor_session),
|
||||
}
|
||||
# Capture permission state before schema selection/reconciliation.
|
||||
# Only deterministic request intent supplies grants, never inventory.
|
||||
# The strict router (spec) surface picks its tools in src/spec_router.py
|
||||
# AFTER authority is built, so its admission is keyed off the surface.
|
||||
try:
|
||||
_spec_surface = _configured_model_tool_surface_raw(
|
||||
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
|
||||
) == "spec"
|
||||
except Exception:
|
||||
_spec_surface = False
|
||||
_request_authority = request_authority_for_http(
|
||||
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||
history=_turn_history, policy=tool_policy,
|
||||
@@ -3566,9 +3626,9 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||
for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
capabilities=({'search_browser'} if (
|
||||
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
|
||||
_explicit_browser_intent or _external_discovery_intent
|
||||
) else ()),
|
||||
) else set())),
|
||||
)
|
||||
if exact_tool_approval is not None:
|
||||
from src.agent_runtime.authority import RequestAuthority
|
||||
@@ -3758,7 +3818,9 @@ def setup_chat_routes(
|
||||
# personal manager; core shell/Web tools are not task
|
||||
# fallbacks. Other turns retain warm and workspace tools.
|
||||
extra_tools=(
|
||||
frozenset()
|
||||
frozenset({"ask_user"})
|
||||
if _active_turn_capabilities == frozenset({"photo_editor"})
|
||||
else frozenset()
|
||||
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
||||
_active_turn_capabilities in (
|
||||
frozenset({"transcription"}), frozenset({"ocr"}),
|
||||
@@ -3960,7 +4022,7 @@ def setup_chat_routes(
|
||||
_heartbeat_counter = 0
|
||||
_last_progress = {}
|
||||
_sent_avg = False
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session)
|
||||
if not status or status["status"] != "running":
|
||||
break
|
||||
@@ -4222,8 +4284,6 @@ def setup_chat_routes(
|
||||
# indicator, but don't fold them into the saved
|
||||
# reply (mirrors the rewrite path below).
|
||||
if data.get("thinking"):
|
||||
if thinking_mode == "off":
|
||||
continue
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response += data["delta"]
|
||||
@@ -4667,8 +4727,6 @@ def setup_chat_routes(
|
||||
# Forward them for the live indicator, but keep
|
||||
# them out of the saved reply (same as chat mode).
|
||||
if data.get("thinking"):
|
||||
if thinking_mode == "off":
|
||||
continue
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response = _render_state.content
|
||||
@@ -4852,6 +4910,25 @@ def setup_chat_routes(
|
||||
_terminal_saved = True
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
|
||||
run_post_response_tasks(
|
||||
sess, session_manager, session, message, _saveable_no_tool_response,
|
||||
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
agent_rounds=_agent_rounds,
|
||||
agent_tool_calls=_agent_tool_calls,
|
||||
skills_manager=skills_manager,
|
||||
owner=_user,
|
||||
extract_skills=(
|
||||
user_requested_agent
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
allow_background_extraction=_post_response_extraction_allowed(
|
||||
tools_blocked=tool_policy.block_all_tool_calls,
|
||||
tool_approval_continuation=tool_approval_continuation,
|
||||
client_runtime_context=client_runtime_context,
|
||||
),
|
||||
)
|
||||
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
yield chunk
|
||||
@@ -4962,6 +5039,8 @@ def setup_chat_routes(
|
||||
raises before reaching a mode-specific finally block."""
|
||||
try:
|
||||
async for chunk in stream_with_save():
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
_active_streams.pop(session, None)
|
||||
@@ -5056,7 +5135,12 @@ def setup_chat_routes(
|
||||
if rec is None:
|
||||
if agent_runs.is_active(session_id):
|
||||
return {"status": "streaming", "detached": True}
|
||||
raise HTTPException(404, "No active stream for this session")
|
||||
# No active stream: report an explicit idle state with HTTP 200.
|
||||
# A 404 here made every session load look like a failed request in
|
||||
# the browser console. The client distinguishes "no stream" by the
|
||||
# body (status != "streaming"), and still tolerates a 404 from an
|
||||
# older server, so this stays backward compatible.
|
||||
return {"status": "idle"}
|
||||
return rec
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -5155,6 +5239,8 @@ def setup_chat_routes(
|
||||
max_tokens=0,
|
||||
tools=None,
|
||||
):
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
data = json.loads(chunk[6:])
|
||||
|
||||
@@ -1185,7 +1185,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
|
||||
"""Use AI to judge if documents are junk/test/accidental, then delete them.
|
||||
Caches verdicts so previously-reviewed docs are skipped."""
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
@@ -1228,13 +1228,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
+ "\n".join(doc_list)
|
||||
)
|
||||
|
||||
response = await llm_call_async(
|
||||
url, model,
|
||||
response = await task_llm_call_async(
|
||||
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
|
||||
{"role": "user", "content": prompt}],
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
owner=user or None,
|
||||
temperature=0.1,
|
||||
max_tokens=200,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
@@ -285,11 +286,22 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
async def research_status(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
# No in-flight or persisted research for this session is a normal state
|
||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||
# explicit empty status so loading a session does not log a spurious
|
||||
# 404; every client treats a non-"running" status as idle anyway.
|
||||
# A session whose research exists but belongs to ANOTHER user stays a
|
||||
# 404, so we do not leak the existence of someone else's research.
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
exists = session_id in getattr(research_handler, "_active_tasks", {})
|
||||
if not exists:
|
||||
exists = _find_research_path(session_id) is not None
|
||||
if exists:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
try:
|
||||
source_state = research_handler.get_source_state(session_id)
|
||||
if isinstance(source_state, str) and source_state:
|
||||
@@ -657,7 +669,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
async def _generate():
|
||||
last_payload = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
|
||||
@@ -1320,7 +1320,7 @@ def setup_session_routes(
|
||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||
users can clean junk without spending tokens.
|
||||
"""
|
||||
from src.llm_core import llm_call
|
||||
from src.llm_core import llm_call, llm_call_with_fallback
|
||||
from src.utility_effort import effort_for_call
|
||||
user = effective_user(request)
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
@@ -1467,7 +1467,7 @@ def setup_session_routes(
|
||||
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
|
||||
|
||||
# Pick an endpoint — prefer admin-configured task endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint, resolve_task_candidates
|
||||
url, model, headers = resolve_task_endpoint(owner=user)
|
||||
if not url:
|
||||
url, model, headers = _pick_endpoint_for_sort(owner=user)
|
||||
@@ -1493,9 +1493,12 @@ def setup_session_routes(
|
||||
# 16384 (was 4096): with many chats the folder JSON is large, and a
|
||||
# reasoning model spends tokens thinking first — 4096 truncated the
|
||||
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
||||
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
|
||||
reasoning_effort=effort_for_call(url, model, user))
|
||||
raw = llm_call_with_fallback(
|
||||
resolve_task_candidates(fallback_url=url, fallback_model=model,
|
||||
fallback_headers=headers, owner=user),
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0.3, max_tokens=16384, timeout=120,
|
||||
reasoning_effort=effort_for_call(url, model, user))
|
||||
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
||||
# Extract JSON from response — handle markdown fences, leading text,
|
||||
# reasoning-model <think> blocks, and trailing commas.
|
||||
|
||||
@@ -27,6 +27,7 @@ from src.host_docker_access import (
|
||||
from src.optional_deps import prepare_optional_dependency_import
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src import process_lifecycle
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||
# on Windows, so importing them unconditionally crashed app startup there
|
||||
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
# Check client disconnect
|
||||
if await request.is_disconnected():
|
||||
# tmux keeps running — that's the whole point
|
||||
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
|
||||
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
if await request.is_disconnected():
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
||||
return
|
||||
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""Read chunks, split on \\n or \\r for progress bar support."""
|
||||
try:
|
||||
buf = b""
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
chunk = await stream.read(4096)
|
||||
if not chunk:
|
||||
if buf:
|
||||
|
||||
@@ -946,6 +946,43 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
||||
"Seconds the outpainting proxy waits for the image backend before giving "
|
||||
"up on a single request.",
|
||||
),
|
||||
"ODYSSEUS_JEFF_": ("Agent loop and tool execution", INTERNAL, "Prefix for per-job decision adapter opt-ins; each job is disabled by default."),
|
||||
"ODYSSEUS_JEFF_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Timeout in seconds for a decision adapter request."),
|
||||
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
||||
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
||||
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
||||
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
|
||||
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
||||
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
||||
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
||||
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
||||
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
||||
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
||||
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
|
||||
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
||||
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
||||
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
||||
"ODYSSEUS_SPEC_THINK_ROUTER": ("Agent loop and tool execution", INTERNAL, "Enable router thinking decisions for explicitly configured Spec models."),
|
||||
"ODYSSEUS_SPEC_TOOL_FORMAT": ("Agent loop and tool execution", INTERNAL, "Use names for compact router tool signatures, otherwise include descriptions."),
|
||||
"ODYSSEUS_SPEC_TRIM_PROMPT": ("Agent loop and tool execution", INTERNAL, "Enable prompt trimming for the selected Spec tool menu."),
|
||||
"ODYSSEUS_VISION_MAX_SIDE": ("Upload and media limits", USER, "Maximum pixel side for image attachments sent directly to a vision model."),
|
||||
"ODYSSEUS_VISION_MODELS": ("Model routing and providers", USER, "Comma-separated model-name fragments that explicitly support inline images."),
|
||||
"ODYSSEUS_VISION_PROBE": ("Model routing and providers", USER, "Allow a cached vision-capability probe against local model endpoints."),
|
||||
"ODYSSEUS_SPEC_DIRECT_MODE": (
|
||||
"Agent loop and tool execution", INTERNAL,
|
||||
"Controls how the spec router answers high-confidence direct-answer turns; "
|
||||
"either tool_choice_none (default) or empty_menu.",
|
||||
),
|
||||
"ODYSSEUS_SPEC_DIRECT_NOTOOLS": (
|
||||
"Agent loop and tool execution", INTERNAL,
|
||||
"Set to 1, true, yes or on to enable the spec router's direct-answer "
|
||||
"no-tools path for high-confidence answer_directly decisions.",
|
||||
),
|
||||
"ODYSSEUS_SPEC_DIRECT_MIN_P": (
|
||||
"Agent loop and tool execution", INTERNAL,
|
||||
"Minimum confidence probability a spec-router direct-answer decision must "
|
||||
"reach before the no-tools path is taken.",
|
||||
),
|
||||
"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT": (
|
||||
"Deployment and first run", INTERNAL,
|
||||
"Seconds the server waits for in-flight requests and background loops to "
|
||||
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from src.memory import MemoryStoreUnreadable
|
||||
@@ -78,11 +79,13 @@ EXTRACT_SYSTEM_PROMPT = (
|
||||
"things the assistant said, one-off tasks, opinions on the current topic.\n\n"
|
||||
"Rules:\n"
|
||||
"- MAX 2 facts per conversation — only the most important\n"
|
||||
"- Only extract facts the USER stated or clearly implied\n"
|
||||
"- Only extract facts the USER explicitly stated about themselves\n"
|
||||
"- Questions, requests to inspect records, and tool/assistant output are not evidence\n"
|
||||
"- Each fact must be a single short sentence (under 15 words)\n"
|
||||
"- If a fact is similar to something likely already known, skip it\n"
|
||||
"- If nothing durable was revealed, return []\n\n"
|
||||
"Return a JSON array of objects with 'text' and 'category' fields.\n"
|
||||
"Return a JSON array of objects with 'text', 'category', and 'evidence' fields. "
|
||||
"Evidence must be an exact quote from a user's own factual statement.\n"
|
||||
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
|
||||
"Return ONLY valid JSON, no markdown fences."
|
||||
)
|
||||
@@ -213,7 +216,10 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
m = re.search(r"\bmy name is\s+([A-Za-z][A-Za-z0-9 .'\-]{1,50})\b", text, re.I)
|
||||
m = re.search(
|
||||
r"\bmy name is\s+([A-Za-z][A-Za-z0-9 '\-]{0,49}?)"
|
||||
r"(?=\s+(?:and|but|please)\b|[.!?;,\n]|$)", text, re.I,
|
||||
)
|
||||
if m:
|
||||
name = _clean_memory_value(m.group(1), 50)
|
||||
if name:
|
||||
@@ -259,22 +265,183 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
||||
return candidates[:2]
|
||||
|
||||
|
||||
_USER_FACT_ASSERTION = re.compile(
|
||||
r"\b(?:i(?:'m| am| live| work| use| have| prefer| like| love| hate| own| study| grew up)\b"
|
||||
r"|my\s+[a-z][a-z -]{0,40}\s+(?:is|are|was|works|lives)\b"
|
||||
r"|we\s+(?:are|live|work|have|own)\b"
|
||||
r"|our\s+[a-z][a-z -]{0,40}\s+(?:is|are|was)\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _has_user_fact_evidence(fact: dict, messages) -> bool:
|
||||
"""Require an exact user assertion before persisting an LLM-inferred memory."""
|
||||
evidence = fact.get("evidence")
|
||||
if not isinstance(evidence, str):
|
||||
return False
|
||||
evidence = re.sub(r"\s+", " ", evidence).strip()
|
||||
if len(evidence.split()) < 3 or not _USER_FACT_ASSERTION.match(evidence):
|
||||
return False
|
||||
for message in messages:
|
||||
if _message_role(message) != "user":
|
||||
continue
|
||||
utterance = re.sub(r"\s+", " ", _message_text(message)).strip()
|
||||
position = utterance.casefold().find(evidence.casefold())
|
||||
if position < 0:
|
||||
continue
|
||||
# Judge the clause containing the quote, so a later question does not
|
||||
# erase an earlier factual statement in the same user turn.
|
||||
clause_start = max(
|
||||
utterance.rfind(mark, 0, position) for mark in (".", "?", "!", ";")
|
||||
) + 1
|
||||
clause_end = min(
|
||||
[end for mark in (".", "?", "!", ";")
|
||||
if (end := utterance.find(mark, position)) >= 0] or [len(utterance)]
|
||||
)
|
||||
clause = utterance[clause_start:clause_end].strip()
|
||||
# A question about one's records is not a statement of the answer.
|
||||
if utterance[clause_end:clause_end + 1] == "?" or re.match(
|
||||
r"^(?:who|what|where|when|why|how|do|does|did|can|could|would|"
|
||||
r"show|list|find|search|tell|summarize|open|read)\b",
|
||||
clause, re.IGNORECASE,
|
||||
):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
# Function words carry no distinguishing content. Dropping them before
|
||||
# comparing two facts keeps punctuation and harmless rephrasing from defeating
|
||||
# the duplicate check.
|
||||
_STOPWORDS = frozenset(
|
||||
"a an the and or but if then of to in on at for with by from as is are was "
|
||||
"were be been being do does did has have had it its this that these those "
|
||||
"there here user users someone somebody they them their he she his her him i "
|
||||
"we you your my our me us who whom whose what when where why how also "
|
||||
"currently now still about into over under again more most very can could "
|
||||
"may might must will would should".split()
|
||||
)
|
||||
|
||||
# Words that frame a name/username statement; excluded when reading the value
|
||||
# so the actual name tokens can be compared.
|
||||
_NAME_TEMPLATE_WORDS = frozenset(
|
||||
"name names named called call appears appear seems seem the user users his "
|
||||
"her their it is are was were be been being username usernames login "
|
||||
"account handle".split()
|
||||
)
|
||||
|
||||
_NAME_KEYWORDS = frozenset({"name", "named", "called"})
|
||||
_USERNAME_KEYWORDS = frozenset({"username", "login"})
|
||||
|
||||
|
||||
def _content_tokens(text: str) -> set:
|
||||
"""Lowercased content tokens with punctuation and stopwords removed."""
|
||||
toks = _TOKEN_RE.findall((text or "").lower())
|
||||
return {t for t in toks if t not in _STOPWORDS and len(t) >= 2}
|
||||
|
||||
|
||||
def _name_slot(text: str):
|
||||
"""Return (slot, value_tokens) when text states a name/username, else None.
|
||||
|
||||
Tokens are scanned so "username" is never mistaken for "name", and the
|
||||
first keyword decides the slot (a sentence about a server *named* X that
|
||||
also mentions a *username* Y is a 'name' fact, not a username fact)."""
|
||||
words = _TOKEN_RE.findall((text or "").lower())
|
||||
slot = None
|
||||
for w in words:
|
||||
if w in _NAME_KEYWORDS:
|
||||
slot = "name"
|
||||
break
|
||||
if w in _USERNAME_KEYWORDS:
|
||||
slot = "username"
|
||||
break
|
||||
if not slot:
|
||||
return None
|
||||
value = {
|
||||
w for w in words
|
||||
if w not in _NAME_TEMPLATE_WORDS and w not in _STOPWORDS and len(w) >= 2
|
||||
}
|
||||
return (slot, value) if value else None
|
||||
|
||||
|
||||
def _name_slot_tokens(text: str):
|
||||
slot = _name_slot(text)
|
||||
return slot[1] if slot else None
|
||||
|
||||
|
||||
def _name_vals_close(a: set, b: set) -> bool:
|
||||
"""True when two name values state the same name: identical, or one is a
|
||||
strict refinement of the other where the larger value is a clean name
|
||||
(<=4 alphabetic tokens). Guards against merging a username into a fact
|
||||
like 'a server named Odysseus ... with username pewds'."""
|
||||
if a == b:
|
||||
return True
|
||||
if not a or not b:
|
||||
return False
|
||||
bigger = a if len(a) >= len(b) else b
|
||||
clean = len(bigger) <= 4 and all(t.isalpha() for t in bigger)
|
||||
return clean and (a <= b or b <= a)
|
||||
|
||||
|
||||
def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) -> bool:
|
||||
"""Check if new_text is too similar to any existing memory (Jaccard similarity)."""
|
||||
new_tokens = set(new_text.lower().split())
|
||||
"""Check if new_text states the same fact as an existing memory.
|
||||
|
||||
Three tests, cheapest first: symmetric Jaccard over content tokens,
|
||||
containment (one fact is a strict refinement of the other), and a
|
||||
name/username-slot match. The naive whole-string comparison this replaces
|
||||
kept punctuation (so "Alex." != "alex") and missed short-vs-full names
|
||||
("Felix" vs "FELIX ARVID ULF KJELLBERG" scored 0.43).
|
||||
"""
|
||||
new_tokens = _content_tokens(new_text)
|
||||
if not new_tokens:
|
||||
return False
|
||||
new_slot = _name_slot(new_text)
|
||||
for entry in _memory_dicts(existing):
|
||||
old_tokens = set(entry.get("text", "").lower().split())
|
||||
old_text = entry.get("text", "")
|
||||
old_tokens = _content_tokens(old_text)
|
||||
if not old_tokens:
|
||||
continue
|
||||
intersection = new_tokens & old_tokens
|
||||
if not intersection:
|
||||
continue
|
||||
union = new_tokens | old_tokens
|
||||
if len(intersection) / len(union) >= threshold:
|
||||
return True
|
||||
# Containment: one fact is a strict refinement of the other. Guard on
|
||||
# the shorter side so two-token facts ("Likes Python") are not merged
|
||||
# into longer sentences that merely start the same way.
|
||||
if len(intersection) >= 3 and len(intersection) / min(len(new_tokens), len(old_tokens)) >= 0.85:
|
||||
return True
|
||||
# Name slot: both state the same kind of name and the values match
|
||||
# (short vs full name, or the same name behind different phrasing).
|
||||
old_slot = _name_slot(old_text)
|
||||
if new_slot and old_slot and new_slot[0] == old_slot[0]:
|
||||
if _name_vals_close(new_slot[1], old_slot[1]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _name_enrichment_target(new_text: str, existing: list):
|
||||
"""Return an existing entry whose name value is a strict subset of the
|
||||
new fact's, so the fuller name replaces it in place; else None."""
|
||||
ns = _name_slot(new_text)
|
||||
if not ns:
|
||||
return None
|
||||
slot, new_val = ns
|
||||
best = None
|
||||
for entry in _memory_dicts(existing):
|
||||
old_slot = _name_slot(entry.get("text", ""))
|
||||
if not old_slot or old_slot[0] != slot:
|
||||
continue
|
||||
old_val = old_slot[1]
|
||||
if old_val < new_val and _name_vals_close(old_val, new_val) and len(new_val) <= 4 and all(t.isalpha() for t in new_val):
|
||||
if best is None or len(entry.get("text", "")) < len(best.get("text", "")):
|
||||
best = entry
|
||||
return best
|
||||
|
||||
|
||||
def _parse_extraction_json(raw: str) -> list:
|
||||
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
||||
reasoning-model noise.
|
||||
@@ -420,8 +587,13 @@ async def extract_and_store(
|
||||
if not isinstance(facts, list):
|
||||
facts = []
|
||||
|
||||
# The extraction model sees assistant/tool text and can mistake a
|
||||
# failed lookup or a one-off request for a durable personal fact.
|
||||
# Only its claims with a quoted user assertion may enter memory.
|
||||
facts = [fact for fact in facts if isinstance(fact, dict)
|
||||
and _has_user_fact_evidence(fact, stripped_recent)]
|
||||
if fallback_facts:
|
||||
facts = list(facts) + fallback_facts
|
||||
facts.extend(fallback_facts)
|
||||
|
||||
if not facts:
|
||||
logger.info("Auto memory extraction ran: 0 candidates")
|
||||
@@ -483,6 +655,25 @@ async def extract_and_store(
|
||||
|
||||
# Text dedup fallback: exact match + fuzzy similarity
|
||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
||||
|
||||
# Name enrichment: if an existing name fact is a strict prefix of
|
||||
# this more complete one, upgrade it in place instead of adding a
|
||||
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
||||
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
||||
_enrich = _name_enrichment_target(fact_text, user_existing)
|
||||
if _enrich is not None:
|
||||
_enrich["text"] = fact_text
|
||||
_enrich["timestamp"] = int(time.time())
|
||||
if category:
|
||||
_enrich["category"] = category
|
||||
if memory_vector and memory_vector.healthy:
|
||||
try:
|
||||
memory_vector.add(_enrich["id"], fact_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory vector update failed for {_enrich['id']}: {e}")
|
||||
added += 1
|
||||
continue
|
||||
|
||||
if memory_manager.find_duplicates(fact_text, user_existing):
|
||||
continue
|
||||
# Fuzzy text similarity check (catches rephrased duplicates when vector index is unavailable)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+343
-9
@@ -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",
|
||||
@@ -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,142 @@ 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 _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.
|
||||
"""
|
||||
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
|
||||
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
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
disabled = set(disabled_tools or ())
|
||||
picks, decided = _spec_route_tools_early(
|
||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||
from src.spec_router import LAST_DECISIONS as _spec_decisions
|
||||
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
|
||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
and decided and _spec_choice == "answer_directly")
|
||||
try:
|
||||
_mgr = get_mcp_manager()
|
||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||
except Exception:
|
||||
mcp_schemas = []
|
||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||
if _spec_choice == "ask_user":
|
||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||
# can act when the detail is there or ask when it is not.
|
||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||
if _top:
|
||||
picks = set(picks) | {_top[0][0]}
|
||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||
base_offered = set(turn_contract.offered)
|
||||
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
# The router sees the full catalog, while this request's policy may
|
||||
# deny a tool (for example, a browser turn with Web access switched
|
||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||
# old empty menu instead lets the model invent a tool result in prose.
|
||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||
_primary = f"mcp__email__{_primary}"
|
||||
# `executable` belongs to the base contract. In subset=0, Spec may
|
||||
# legitimately add a policy-permitted tool that the base inventory did
|
||||
# not include. Only an explicit request-policy denial blocks the pick.
|
||||
def _denied(name):
|
||||
return (name in disabled
|
||||
or (tool_policy is not None and (
|
||||
tool_policy.blocks(name)
|
||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||
_unavailable_pick = ""
|
||||
if _primary in picks and _denied(_primary):
|
||||
_unavailable_pick = _primary
|
||||
elif picks and all(_denied(name) for name in picks):
|
||||
_unavailable_pick = sorted(picks)[0]
|
||||
if _unavailable_pick:
|
||||
blocked = replace(
|
||||
turn_contract, required=frozenset(), offered=frozenset(),
|
||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||
)
|
||||
_spec_active_contract.set(blocked)
|
||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||
return blocked
|
||||
want = set(picks) - disabled
|
||||
# Always-on basics only when the base turn offers them, so the Spec
|
||||
# arm never offers a tool the base arm would not.
|
||||
if not _spec_direct:
|
||||
want |= {"ask_user", "update_plan"} & base_offered
|
||||
if not (strict and decided):
|
||||
want |= base_offered
|
||||
if subset_of_base:
|
||||
# A/B control: Spec offered set is a subset of the base offered set
|
||||
# (measures narrowing only). Empty intersection keeps the base set.
|
||||
want &= base_offered
|
||||
if not want:
|
||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||
sorted(picks), sorted(base_offered))
|
||||
return turn_contract
|
||||
offer = want if strict else (want & set(turn_contract.executable))
|
||||
by_name = {}
|
||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer and nm not in by_name:
|
||||
by_name[nm] = sc
|
||||
for sc in turn_contract.schemas():
|
||||
nm = (sc.get("function") or {}).get("name")
|
||||
if nm in offer:
|
||||
by_name[nm] = sc
|
||||
if not by_name and not _spec_direct:
|
||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||
return turn_contract
|
||||
# Keep the contract's required tools and required read whenever they
|
||||
# are still offered: harness completion logic keys on them (e.g. a
|
||||
# calendar list is a read, not an effect that may end with "Done.").
|
||||
_rro = getattr(turn_contract, "required_read_operation", None)
|
||||
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
|
||||
new = replace(
|
||||
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
|
||||
offered=frozenset(by_name),
|
||||
executable=frozenset(set(turn_contract.executable) | set(by_name)),
|
||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
|
||||
_spec_active_contract.set(new)
|
||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||
return new
|
||||
except Exception as exc:
|
||||
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
|
||||
return turn_contract
|
||||
|
||||
|
||||
@with_request_authority
|
||||
@with_turn_contract
|
||||
@with_teacher_takeover
|
||||
@@ -21099,6 +21259,36 @@ 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"):
|
||||
from src.spec_driver import stream_spec_turn
|
||||
async for chunk in stream_spec_turn(
|
||||
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
|
||||
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
|
||||
client_runtime_context=client_runtime_context,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
|
||||
temperature=0.0, request_authority=request_authority,
|
||||
):
|
||||
yield chunk
|
||||
return
|
||||
|
||||
turn_contract = _spec_router_contract(
|
||||
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
|
||||
|
||||
if is_compact_preview_contract(turn_contract):
|
||||
from src.clean_agent_preview import stream_preview
|
||||
async for chunk in stream_preview(
|
||||
@@ -21124,9 +21314,26 @@ async def stream_agent_loop(
|
||||
yield chunk
|
||||
return
|
||||
|
||||
_spec_turn = False
|
||||
try:
|
||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||
except Exception:
|
||||
_spec_turn = False
|
||||
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
|
||||
# Ajax Spec: the router, not the regex turn contract, decides the tools.
|
||||
# Drop the contract here and in the dispatcher's context so neither the
|
||||
# route build nor execute_tool_block enforces its offered set.
|
||||
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
|
||||
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
|
||||
turn_contract = None
|
||||
try:
|
||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||
_spec_active_contract.set(None)
|
||||
except Exception as _exc:
|
||||
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
|
||||
if turn_contract is not None:
|
||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||
if _blocks_before_inference(turn_contract):
|
||||
if not _spec_turn and _blocks_before_inference(turn_contract):
|
||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||
clarification = (
|
||||
"I can’t perform this request with the currently permitted tools "
|
||||
@@ -24126,6 +24333,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 = _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 +24375,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(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 +24466,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 +24566,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 +24778,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 +24793,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 +25352,24 @@ 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
|
||||
]
|
||||
_have = {
|
||||
(schema.get("function", {}).get("name") or schema.get("name"))
|
||||
for schema in contract_schemas
|
||||
}
|
||||
contract_schemas += [
|
||||
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
|
||||
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
|
||||
and schema.get("function", {}).get("name") not in disabled_tools
|
||||
]
|
||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||
if route_state["is_api_model"]:
|
||||
if tool_surface == "full":
|
||||
@@ -25136,7 +25430,7 @@ async def stream_agent_loop(
|
||||
if schema.get("function", {}).get("name") not in disabled_tools
|
||||
and schema.get("name") not in disabled_tools
|
||||
]
|
||||
if _pure_web_turn and tool_surface != "full":
|
||||
if _pure_web_turn and tool_surface != "full" and not (route_state.get("spec_surface") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"):
|
||||
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
||||
schemas = [
|
||||
schema for schema in schemas
|
||||
@@ -25997,6 +26291,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 +26308,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 "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) 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 +27910,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 +32478,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 +33795,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(
|
||||
@@ -33589,6 +33922,7 @@ async def stream_agent_loop(
|
||||
"mutation_epoch": _workspace_mutation_epoch,
|
||||
"error": _failure_text[:600],
|
||||
}
|
||||
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
|
||||
logger.info(
|
||||
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
||||
_call_signature,
|
||||
|
||||
@@ -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}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = (
|
||||
|
||||
+90
-2
@@ -81,6 +81,16 @@ def is_vision_model(model_name: str) -> bool:
|
||||
drops the image entirely. See issue #124.
|
||||
"""
|
||||
m = (model_name or "").lower()
|
||||
if not m:
|
||||
return False
|
||||
# Per-model override (comma-separated substrings), e.g. a local model whose
|
||||
# name carries no vision/VL hint but answers images natively.
|
||||
override = os.getenv("ODYSSEUS_VISION_MODELS", "")
|
||||
if override:
|
||||
for token in override.split(","):
|
||||
token = token.strip().lower()
|
||||
if token and token in m:
|
||||
return True
|
||||
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
|
||||
return True
|
||||
return bool(_VISION_VL_RE.search(m))
|
||||
@@ -164,8 +174,10 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||
|
||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||
"""Whether a model accepts images, using the endpoint's reported
|
||||
capability when available (LM Studio) and falling back to name-based
|
||||
detection otherwise."""
|
||||
capability when available (LM Studio), a cached one-time probe for local
|
||||
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||
if is_vision_model(model_name):
|
||||
return True
|
||||
if endpoint_url:
|
||||
try:
|
||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
||||
@@ -173,9 +185,85 @@ def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||
advertised = None
|
||||
if advertised is not None:
|
||||
return advertised
|
||||
try:
|
||||
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
||||
except Exception:
|
||||
probed = None
|
||||
if probed is not None:
|
||||
return probed
|
||||
return is_vision_model(model_name)
|
||||
|
||||
|
||||
# 1x1 red PNG — the cheapest possible image probe payload.
|
||||
_PROBE_IMAGE_DATA_URL = (
|
||||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
_VISION_PROBE_TTL = 3600.0
|
||||
# (host, model) -> (bool | None, expiry); None = undecidable (auth/network).
|
||||
_vision_probe_cache: dict = {}
|
||||
|
||||
|
||||
def _vision_probe_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_VISION_PROBE", "1").strip().lower() not in ("0", "false", "off", "no")
|
||||
|
||||
|
||||
def _chat_completions_url(endpoint_url: str) -> str:
|
||||
base = (endpoint_url or "").rstrip("/")
|
||||
if base.endswith("/chat/completions"):
|
||||
return base
|
||||
return base + "/chat/completions"
|
||||
|
||||
|
||||
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||
an image_url content part for `model_name`?
|
||||
|
||||
Returns True on a 200, False when the endpoint rejects the image (400/413/
|
||||
415/422), and None when we cannot decide (remote host, auth required,
|
||||
unreachable, or probing disabled) so the caller falls back to name rules.
|
||||
"""
|
||||
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||
return None
|
||||
parsed = urlparse(endpoint_url)
|
||||
if not _is_local_host(parsed.hostname):
|
||||
return None # never probe a public provider
|
||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||
now = time.time()
|
||||
cached = _vision_probe_cache.get(key)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"max_tokens": 1,
|
||||
"stream": False,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Reply with one word."},
|
||||
{"type": "image_url", "image_url": {"url": _PROBE_IMAGE_DATA_URL}},
|
||||
],
|
||||
}],
|
||||
}
|
||||
verdict: Optional[bool] = None
|
||||
try:
|
||||
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
||||
if r.status_code == 200:
|
||||
verdict = True
|
||||
elif r.status_code in (400, 413, 415, 422):
|
||||
body = (r.text or "").lower()
|
||||
if any(tok in body for tok in ("image", "vision", "multimodal", "content type")):
|
||||
verdict = False
|
||||
else:
|
||||
verdict = False
|
||||
except Exception:
|
||||
verdict = None
|
||||
_vision_probe_cache[key] = (verdict, now + _VISION_PROBE_TTL)
|
||||
if verdict is not None:
|
||||
logger.info("vision probe %s@%s -> %s", model_name, parsed.hostname, verdict)
|
||||
return verdict
|
||||
|
||||
|
||||
def validate_message(message: str) -> str:
|
||||
"""Validate message input."""
|
||||
if not message:
|
||||
|
||||
+644
-14
@@ -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,191 @@ 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
|
||||
|
||||
|
||||
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
|
||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||
if not decisions:
|
||||
return None
|
||||
decision = decisions[-1]
|
||||
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,
|
||||
)
|
||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||
decision = decisions[-1] if decisions else {}
|
||||
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 +1645,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 +2979,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 +3031,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 +3050,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 +3181,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 +3515,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 +3807,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}:
|
||||
@@ -3619,6 +3922,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 +4164,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 +4545,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 +4584,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 +4620,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,
|
||||
)
|
||||
@@ -5622,6 +5980,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 +6093,35 @@ 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 = spec_router_thinking_decision(
|
||||
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)
|
||||
# Option B: send round 1 with no tools at all for a direct answer.
|
||||
_spec_direct_empty_menu = (
|
||||
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||
# every round; the 9B only writes the arguments or the answer.
|
||||
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
|
||||
# Driver 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 +6258,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 +6457,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 +6565,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 +6696,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 +6750,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 +6764,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 +6814,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 +6847,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 +7297,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 +8569,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'}
|
||||
|
||||
+80
-11
@@ -92,6 +92,83 @@ def prune_multimodal_images(
|
||||
return pruned
|
||||
|
||||
|
||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _image_turn_label(message: Dict) -> str:
|
||||
"""Best-effort '[Image: name]' label for a message whose image parts are dropped."""
|
||||
name = ""
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
|
||||
if candidate:
|
||||
name = str(candidate)
|
||||
break
|
||||
if not name:
|
||||
text = _content_as_text(content)
|
||||
match = _IMAGE_TURN_LABEL_RE.search(text)
|
||||
if match:
|
||||
name = match.group(1).strip()
|
||||
return f"[Image: {name}]" if name else "[Image]"
|
||||
|
||||
|
||||
def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict]:
|
||||
"""Keep image parts only for the last `keep` messages that carry an image.
|
||||
|
||||
Older image blocks are dropped and replaced with a short ``[Image: name]``
|
||||
text label so the model still knows a picture was shared (and its name)
|
||||
without paying the image tokens on every later turn. Non-text, non-image
|
||||
multimodal blocks (e.g. audio) are left untouched.
|
||||
"""
|
||||
limit = max(0, int(keep))
|
||||
image_messages = [
|
||||
index for index, message in enumerate(messages)
|
||||
if isinstance(message, dict)
|
||||
and isinstance(message.get("content"), list)
|
||||
and any(
|
||||
isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES
|
||||
for item in message["content"]
|
||||
)
|
||||
]
|
||||
if len(image_messages) <= limit:
|
||||
return list(messages)
|
||||
selected = set(image_messages[len(image_messages) - limit:]) if limit else set()
|
||||
|
||||
trimmed: List[Dict] = []
|
||||
for message_index, message in enumerate(messages):
|
||||
if message_index in selected or not isinstance(message, dict):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
kept_blocks = [
|
||||
item for item in content
|
||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||
]
|
||||
if len(kept_blocks) == len(content):
|
||||
trimmed.append(message)
|
||||
continue
|
||||
label = _image_turn_label(message)
|
||||
# Attach the label to the trailing text block when possible so we do not
|
||||
# introduce an extra content block; otherwise append a fresh text block.
|
||||
labelled = False
|
||||
for item in reversed(kept_blocks):
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
item["text"] = (item.get("text") or "").rstrip() + f"\n{label}"
|
||||
labelled = True
|
||||
break
|
||||
if not labelled:
|
||||
kept_blocks.append({"type": "text", "text": label})
|
||||
cloned = dict(message)
|
||||
cloned["content"] = kept_blocks
|
||||
trimmed.append(cloned)
|
||||
return trimmed
|
||||
|
||||
|
||||
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
|
||||
SUMMARY_MAX_TOKENS = 1024
|
||||
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
|
||||
@@ -106,7 +183,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:
|
||||
|
||||
@@ -557,16 +634,8 @@ async def maybe_compact(
|
||||
)
|
||||
|
||||
# Use utility model if configured, otherwise fall back to session model
|
||||
util_url, util_model, util_headers = resolve_endpoint(
|
||||
"utility",
|
||||
fallback_url=endpoint_url,
|
||||
fallback_model=model,
|
||||
fallback_headers=headers,
|
||||
owner=owner,
|
||||
)
|
||||
compact_url = util_url or endpoint_url
|
||||
compact_model = util_model or model
|
||||
compact_headers = util_headers if util_url else headers
|
||||
# Compact with the chat model itself (the one being evaluated), never a separate utility model.
|
||||
compact_url, compact_model, compact_headers = endpoint_url, model, headers
|
||||
|
||||
prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace(
|
||||
"{count}", str(len(older))
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+113
-6
@@ -2,7 +2,8 @@
|
||||
import json
|
||||
import time
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,7 +20,7 @@ _DEPENDENCIES = {
|
||||
'web': {'web_search', 'web_fetch', 'private_browser'},
|
||||
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
|
||||
'download_attachment'},
|
||||
'contacts': {'resolve_contact'},
|
||||
'contacts': {'resolve_contact', 'manage_contact'},
|
||||
'documents': {'search_documents', 'read_document'},
|
||||
}
|
||||
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
|
||||
@@ -116,8 +117,9 @@ def parse_email_task_intent(value):
|
||||
|
||||
def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
if intent.operation == 'read' and intent.dependencies:
|
||||
allowed = set().union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
||||
if active_editor:
|
||||
allowed = {tool for dependency in intent.dependencies
|
||||
for tool in _DEPENDENCIES[dependency]}
|
||||
if active_editor and intent.dependencies:
|
||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||
if intent.needs_clarification:
|
||||
allowed.add('ask_user')
|
||||
@@ -126,6 +128,9 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
if intent.operation not in {'draft', 'revise'}:
|
||||
return list(schemas)
|
||||
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
||||
# manage_contact is a read-only runtime source for lookups, not a
|
||||
# composition tool. Drafts still resolve recipients through resolve_contact.
|
||||
allowed.discard('manage_contact')
|
||||
if active_editor:
|
||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||
if not intent.needs_clarification:
|
||||
@@ -145,6 +150,62 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||
# as though it were the whole task. This byte budget is deliberately conservative.
|
||||
CLASSIFIER_CONTEXT_BYTES = 24000
|
||||
|
||||
# The model can produce a structurally valid classification while forgetting
|
||||
# the source or mistaking a list of message headers for a request for bodies.
|
||||
_OWN_MAILBOX = re.compile(
|
||||
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails?|mail|inbox|mailbox)\b', re.IGNORECASE,
|
||||
)
|
||||
_MAILBOX_HEADER_LIST = re.compile(
|
||||
r'^\s*(?:list|show|display|what(?:\'s| are))\s+(?:me\s+)?(?:my|our)\s+'
|
||||
r'(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails|messages)\s*'
|
||||
r'(?:with\s+(?:(?:sender|subject|date|time|recipient|status)\s*'
|
||||
r'(?:(?:,|and)\s*)?)+)?[?.!]*\s*$', re.IGNORECASE,
|
||||
)
|
||||
_EMAIL_HEADER_FIELDS = re.compile(r'\b(?:subjects?|senders?|dates?)\b', re.IGNORECASE)
|
||||
_EMAIL_HEADER_FOLLOWUP = re.compile(r'\b(?:those|these|them|their|same)\b', re.IGNORECASE)
|
||||
_READ_REQUEST = re.compile(r'^\s*(?:please\s+)?(?:show|list|display|find|search|read|what|how many|are there|do i have)\b', re.IGNORECASE)
|
||||
_EMAIL_COUNT = re.compile(r'^\s*how many\s+(?:(?:unread|read|new)\s+)?(?:e-?mails|messages)\b',
|
||||
re.IGNORECASE)
|
||||
_EMAIL_BODY_QUERY = re.compile(
|
||||
r'\b(?:mention(?:s|ed|ing)?|body|contents?|amount|owe|says?|said|when|where|why|'
|
||||
r'starts?|deadline|attachments?)\b|\bhow much\b|\bwhat time\b',
|
||||
re.IGNORECASE)
|
||||
_CONTACT_EMAIL_FIELD = re.compile(
|
||||
r'\bcontacts?\b[^.!?\n]{0,100}\bemail\b', re.IGNORECASE,
|
||||
)
|
||||
_MAILBOX_SOURCE = re.compile(
|
||||
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||
r'(?:e-?mails?|inbox|mailbox)\b|\b(?:inbox|mailbox|email\s+history)\b|'
|
||||
r'\b(?:e-?mails?|messages?)\s+(?:from|to|sent|received)\b|'
|
||||
r'\bemailed?\s+(?:me|us)\b', re.IGNORECASE,
|
||||
)
|
||||
|
||||
EMAIL_TASK_RESPONSE_FORMAT = {
|
||||
'type': 'json_schema',
|
||||
'json_schema': {
|
||||
'name': 'email_task_intent',
|
||||
'strict': True,
|
||||
'schema': {
|
||||
'type': 'object',
|
||||
'additionalProperties': False,
|
||||
'properties': {
|
||||
'operation': {'type': 'string', 'enum': ['draft', 'revise', 'read', 'send', 'other']},
|
||||
'requires_content': {'type': 'boolean'},
|
||||
'dependencies': {'type': 'array', 'items': {
|
||||
'type': 'string', 'enum': list(_DEPENDENCIES),
|
||||
}},
|
||||
'destination': {'type': 'string', 'enum': ['chat', 'mailbox']},
|
||||
'needs_clarification': {'type': 'boolean'},
|
||||
'summary': {'type': 'string'},
|
||||
},
|
||||
'required': ['operation', 'requires_content', 'dependencies', 'destination',
|
||||
'needs_clarification', 'summary'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
supplied_context=None, accounting=None):
|
||||
@@ -152,6 +213,8 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
# text from multimodal messages, so an attached image cannot hide the latest
|
||||
# instruction and leave us classifying an earlier task instead.
|
||||
dialogue = []
|
||||
latest_user_text = ''
|
||||
prior_user_text = ''
|
||||
for row in history:
|
||||
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
|
||||
continue
|
||||
@@ -166,6 +229,9 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
and isinstance(block.get('text'), str))
|
||||
if isinstance(content, str):
|
||||
dialogue.append({'role': row['role'], 'content': content})
|
||||
if row['role'] == 'user':
|
||||
prior_user_text = latest_user_text
|
||||
latest_user_text = content
|
||||
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
|
||||
ensure_ascii=False)
|
||||
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
|
||||
@@ -174,7 +240,7 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
|
||||
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
|
||||
'chat_template_kwargs': {'enable_thinking': False},
|
||||
'response_format': {'type': 'json_object'},
|
||||
'response_format': EMAIL_TASK_RESPONSE_FORMAT,
|
||||
'messages': [{'role': 'system', 'content': (
|
||||
'Classify the current conversational task. Return JSON only with operation '
|
||||
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
|
||||
@@ -250,6 +316,47 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||
'response_time': round(time.monotonic() - started, 3),
|
||||
})
|
||||
try:
|
||||
return parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
||||
intent = parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
||||
if intent.operation == 'read' and not intent.needs_clarification:
|
||||
# In a contact-record question, "email" can name an address field
|
||||
# rather than the mailbox as a source. Keep both dependencies when
|
||||
# the request also names a mailbox/history source.
|
||||
contact_email_field = bool(
|
||||
_CONTACT_EMAIL_FIELD.search(latest_user_text)
|
||||
and not _MAILBOX_SOURCE.search(latest_user_text)
|
||||
)
|
||||
if contact_email_field:
|
||||
intent = replace(intent, dependencies=tuple(dict.fromkeys(
|
||||
(*[d for d in intent.dependencies if d != 'email'], 'contacts')
|
||||
)))
|
||||
# Reuse the product's existing source classifier rather than a
|
||||
# second vocabulary of mailbox phrases. The classifier has a known
|
||||
# gap for possessive single-message questions, so retain that
|
||||
# narrow fallback only when it found no competing source family.
|
||||
from src.turn_contract import requested_capabilities
|
||||
capabilities = requested_capabilities(latest_user_text)
|
||||
# A header-only reference can inherit the immediately preceding
|
||||
# mailbox read, but an intervening request or a competing source
|
||||
# breaks that link. Never infer a read from an earlier write.
|
||||
header_followup = (
|
||||
not capabilities
|
||||
and bool(_EMAIL_HEADER_FIELDS.search(latest_user_text))
|
||||
and bool(_EMAIL_HEADER_FOLLOWUP.search(latest_user_text))
|
||||
and bool(_READ_REQUEST.match(prior_user_text))
|
||||
and requested_capabilities(prior_user_text) == {'email'}
|
||||
)
|
||||
mailbox_read = not contact_email_field and (capabilities == {'email'} or (
|
||||
not capabilities and bool(_OWN_MAILBOX.search(latest_user_text))
|
||||
) or header_followup)
|
||||
else:
|
||||
mailbox_read = False
|
||||
if mailbox_read:
|
||||
intent = replace(intent, dependencies=tuple(dict.fromkeys((*intent.dependencies, 'email'))))
|
||||
if (not _EMAIL_BODY_QUERY.search(latest_user_text)
|
||||
and (_MAILBOX_HEADER_LIST.fullmatch(latest_user_text)
|
||||
or _EMAIL_HEADER_FIELDS.search(latest_user_text)
|
||||
or _EMAIL_COUNT.match(latest_user_text))):
|
||||
intent = replace(intent, requires_content=False)
|
||||
return intent
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise ValueError('Invalid classifier response') from exc
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Thin client for the local jeff-serve decision endpoint (POST /v1/systemone).
|
||||
|
||||
Trained decision adapters (triage, spam, guard-email, ...) answer a situation
|
||||
with calibrated probabilities over named options in a single forward pass, with
|
||||
no generated text to parse. Background jobs can prefer an adapter and fall back
|
||||
to the normal LLM candidate chain whenever this client returns None.
|
||||
|
||||
Every call fails open (returns None) and is disabled per job unless the job's
|
||||
flag is set, so importing/enabling this never changes behaviour on its own.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_JEFF_URL base URL of jeff-serve (default http://127.0.0.1:8765)
|
||||
ODYSSEUS_JEFF_TIMEOUT per-request timeout in seconds (default 4.0)
|
||||
ODYSSEUS_JEFF_<JOB> per-job enable flag, e.g. ODYSSEUS_JEFF_EMAIL_URGENCY=1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_URL = "http://127.0.0.1:8765"
|
||||
_FALSEY = ("", "0", "false", "off", "no")
|
||||
|
||||
|
||||
def jeff_url() -> str:
|
||||
"""Base URL of the local jeff-serve (no trailing slash)."""
|
||||
return os.getenv("ODYSSEUS_JEFF_URL", _DEFAULT_URL).strip().rstrip("/")
|
||||
|
||||
|
||||
def jeff_job_enabled(job: str) -> bool:
|
||||
"""True when ODYSSEUS_JEFF_<JOB> is set to a truthy value. Default off."""
|
||||
slug = "".join(c if (c.isalnum() or c == "_") else "_" for c in (job or "")).upper()
|
||||
return os.getenv("ODYSSEUS_JEFF_" + slug, "0").strip().lower() not in _FALSEY
|
||||
|
||||
|
||||
def jeff_timeout(default: float = 4.0) -> float:
|
||||
try:
|
||||
return float(os.getenv("ODYSSEUS_JEFF_TIMEOUT", str(default)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
async def jeff_systemone(
|
||||
model: str,
|
||||
state: Dict[str, Any],
|
||||
questions: Dict[str, Any],
|
||||
*,
|
||||
timeout: Optional[float] = None,
|
||||
orders: int = 1,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""POST a decision request and return the ``answers`` dict, or None on error.
|
||||
|
||||
Each question is scored in its own forward pass, so send only questions the
|
||||
adapter was trained on. Raises are swallowed and logged (fail open).
|
||||
"""
|
||||
url = jeff_url()
|
||||
if not url:
|
||||
return None
|
||||
payload: Dict[str, Any] = {"model": model, "state": state, "questions": questions}
|
||||
if orders and orders != 1:
|
||||
payload["orders"] = orders
|
||||
to = timeout if timeout is not None else jeff_timeout()
|
||||
try:
|
||||
if client is None:
|
||||
async with httpx.AsyncClient(timeout=to) as _c:
|
||||
resp = await _c.post(url + "/v1/systemone", json=payload)
|
||||
else:
|
||||
resp = await client.post(url + "/v1/systemone", json=payload)
|
||||
resp.raise_for_status()
|
||||
answers = (resp.json() or {}).get("answers")
|
||||
return answers if isinstance(answers, dict) else None
|
||||
except Exception as exc:
|
||||
logger.warning("jeff decision (%s) failed: %s", model, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def jeff_available(timeout: float = 1.5) -> bool:
|
||||
"""Best-effort health probe for the local jeff-serve."""
|
||||
url = jeff_url()
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as _c:
|
||||
resp = await _c.get(url + "/health")
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||
if not isinstance(answer, dict):
|
||||
return None
|
||||
if answer.get("type") == "score":
|
||||
try:
|
||||
return float(answer.get("score"))
|
||||
except Exception:
|
||||
return None
|
||||
if answer.get("type") == "choice":
|
||||
probs = answer.get("probabilities") or {}
|
||||
try:
|
||||
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""The winning key of a jeff ``choice`` answer, or None."""
|
||||
if isinstance(answer, dict) and answer.get("type") == "choice":
|
||||
c = answer.get("choice")
|
||||
return str(c) if c is not None else None
|
||||
return None
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Pre-Wave-3 private_browser schema (agent-browser wrapper), used when ODYSSEUS_PRIVATE_BROWSER_LEGACY is on."""
|
||||
|
||||
PRIVATE_BROWSER_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "private_browser",
|
||||
"description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]},
|
||||
"url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"},
|
||||
"selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"},
|
||||
"target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"},
|
||||
"key": {"type": "string", "description": "Key name for press action, e.g. Enter"},
|
||||
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"},
|
||||
"amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"},
|
||||
"text": {"type": "string", "description": "Text for fill action"},
|
||||
"value": {"type": "string", "description": "Alternative text/value for fill action"},
|
||||
"find": {"type": "string", "description": "Visible text to locate for find action"},
|
||||
"script": {"type": "string", "description": "JavaScript expression for evaluate action"},
|
||||
"path": {"type": "string", "description": "Optional output path for screenshot"},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{"type": "object"},
|
||||
]
|
||||
},
|
||||
},
|
||||
"timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ def untrusted_context_message(
|
||||
"""
|
||||
safe_label = _sanitize_label(label)
|
||||
text = "" if content is None else str(content)
|
||||
import logging as _lg
|
||||
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
|
||||
text = _escape_guard_markers(text)
|
||||
metadata: Dict[str, Any] = {
|
||||
"trusted": False,
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
|
||||
|
||||
Each round the router (spec_router.next_action) picks one action from the full tool surface:
|
||||
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
|
||||
the executor runs it; the result goes back to the router for the next pick.
|
||||
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
|
||||
ask_user -> the chat model writes the question through the ask_user tool.
|
||||
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
|
||||
authority and tool policy (execute_tool_block); nothing else is layered on top.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from src import spec_router
|
||||
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
|
||||
from src.clean_agent_preview import (
|
||||
event, preview_model_response, preview_tool_result_text,
|
||||
)
|
||||
from src.tool_capabilities import ToolRunSecurityContext
|
||||
from src.tool_execution import execute_tool_block
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS, function_call_to_tool_block
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _text(content):
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return '\n'.join(str(p.get('text') or '') for p in content
|
||||
if isinstance(p, dict) and p.get('type') == 'text')
|
||||
return ''
|
||||
|
||||
|
||||
def _name(schema):
|
||||
return (schema.get('function') or {}).get('name') or ''
|
||||
|
||||
|
||||
# JSON-schema keywords the provider's guided decoding (named tool_choice) cannot compile.
|
||||
_GRAMMAR_UNSUPPORTED = frozenset({
|
||||
'uniqueItems', 'contains', 'minContains', 'maxContains', 'patternProperties', 'propertyNames',
|
||||
'dependentRequired', 'dependentSchemas', 'if', 'then', 'else',
|
||||
'unevaluatedProperties', 'unevaluatedItems',
|
||||
})
|
||||
|
||||
|
||||
def grammar_safe(schema):
|
||||
"""Copy of a schema without keywords guided decoding rejects (validation still runs at execution)."""
|
||||
if isinstance(schema, dict):
|
||||
return {k: grammar_safe(v) for k, v in schema.items() if k not in _GRAMMAR_UNSUPPORTED}
|
||||
if isinstance(schema, list):
|
||||
return [grammar_safe(v) for v in schema]
|
||||
return schema
|
||||
|
||||
|
||||
def spec_surface(disabled_tools):
|
||||
disabled = set(disabled_tools or ())
|
||||
return [s for s in FUNCTION_TOOL_SCHEMAS if _name(s) and _name(s) not in disabled]
|
||||
|
||||
|
||||
async def _model_round(client, endpoint_url, headers, request, recovery):
|
||||
"""Run one chat-model round; return (content, tool_calls, usage)."""
|
||||
content, calls, usage, raw = '', {}, {}, []
|
||||
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if len(raw) < 4 and line.strip():
|
||||
raw.append(line[:400])
|
||||
if line.strip() and not line.startswith('data: '):
|
||||
logger.warning('[spec-driver] unexpected stream line: %s', line[:500])
|
||||
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
except ValueError:
|
||||
continue
|
||||
if chunk.get('error') or chunk.get('object') == 'error':
|
||||
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||
usage = chunk.get('usage') or usage
|
||||
for choice in chunk.get('choices') or []:
|
||||
delta = choice.get('delta') or {}
|
||||
text = delta.get('content') or ''
|
||||
content += text
|
||||
for frag in delta.get('tool_calls') or []:
|
||||
call = calls.setdefault(frag.get('index', 0), {'id': '', 'name': '', 'arguments': ''})
|
||||
call['id'] = frag.get('id') or call['id']
|
||||
fn = frag.get('function') or {}
|
||||
call['name'] += fn.get('name') or ''
|
||||
call['arguments'] += fn.get('arguments') or ''
|
||||
if not calls:
|
||||
logger.warning('[spec-driver] no tool call; status=%s max_tokens=%s msgs=%s first lines=%s',
|
||||
getattr(response, 'status_code', None), request.get('max_tokens'),
|
||||
[(m.get('role'), type(m.get('content')).__name__,
|
||||
[p.get('type') for p in m['content']] if isinstance(m.get('content'), list) else len(str(m.get('content') or '')))
|
||||
for m in request.get('messages', [])], raw[-2:])
|
||||
return content, [calls[i] for i in sorted(calls)], usage
|
||||
|
||||
|
||||
@with_request_authority
|
||||
async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id, owner,
|
||||
disabled_tools, tool_policy, workspace=None, active_document=None,
|
||||
client_runtime_context=None, external_untrusted_context_seen=False,
|
||||
max_tokens=768, max_rounds=8, temperature=0.0,
|
||||
request_authority=MISSING_AUTHORITY, **ignored):
|
||||
from src.turn_contract import _ACTIVE_CONTRACT
|
||||
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
|
||||
started = time.monotonic()
|
||||
surface = spec_surface(disabled_tools)
|
||||
by_name = {_name(s): s for s in surface}
|
||||
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
|
||||
history = list(messages)
|
||||
security = ToolRunSecurityContext(
|
||||
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
|
||||
security.observe_messages(messages)
|
||||
picks, executions, done_calls = [], [], set()
|
||||
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
|
||||
recovery = {}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||
for round_number in range(1, max_rounds + 1):
|
||||
rounds = round_number
|
||||
action = await asyncio.to_thread(
|
||||
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
|
||||
if action is None:
|
||||
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
|
||||
if round_number == max_rounds and action['kind'] == 'tool':
|
||||
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
|
||||
picks.append({'round': round_number, **action})
|
||||
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
|
||||
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
|
||||
'temperature': temperature, 'stream': True,
|
||||
'stream_options': {'include_usage': True},
|
||||
'chat_template_kwargs': {'enable_thinking': False}}
|
||||
|
||||
if action['kind'] == 'answer_directly':
|
||||
answer_parts = []
|
||||
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
except ValueError:
|
||||
continue
|
||||
if chunk.get('error') or chunk.get('object') == 'error':
|
||||
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||
u = chunk.get('usage') or {}
|
||||
usage_in += u.get('prompt_tokens') or 0
|
||||
usage_out += u.get('completion_tokens') or 0
|
||||
for choice in chunk.get('choices') or []:
|
||||
text = (choice.get('delta') or {}).get('content') or ''
|
||||
if text:
|
||||
first_token = first_token or time.monotonic()
|
||||
answer_parts.append(text)
|
||||
yield event({'delta': text})
|
||||
answer = ''.join(answer_parts).strip()
|
||||
if not answer:
|
||||
yield event({'delta': 'The model returned no answer.'})
|
||||
history.append({'role': 'assistant', 'content': answer})
|
||||
break
|
||||
|
||||
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
|
||||
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
|
||||
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
|
||||
options = [name]
|
||||
if action['kind'] == 'tool' and topk > 1:
|
||||
options = [n for n, _ in action.get('top') or []][:topk] or [name]
|
||||
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
|
||||
for n in options]
|
||||
options = [n for n, sc in zip(options, schemas) if sc is not None]
|
||||
schemas = [sc for sc in schemas if sc is not None]
|
||||
if not schemas:
|
||||
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
|
||||
latest = f'Tool {name} is not available.'
|
||||
continue
|
||||
request['tools'] = [grammar_safe(sc) for sc in schemas]
|
||||
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
|
||||
if len(options) == 1 else 'required')
|
||||
if len(options) > 1:
|
||||
logger.info('[spec-driver] round=%s options=%s', round_number, options)
|
||||
try:
|
||||
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
|
||||
except (RuntimeError, httpx.HTTPError) as exc:
|
||||
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
|
||||
latest = f'The {name} call could not be produced: {exc}'
|
||||
continue
|
||||
first_token = first_token or time.monotonic()
|
||||
usage_in += usage.get('prompt_tokens') or 0
|
||||
usage_out += usage.get('completion_tokens') or 0
|
||||
call = next((c for c in calls if c['name'] in options), None)
|
||||
if call is not None and len(options) > 1:
|
||||
name = call['name']
|
||||
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
|
||||
round_number, name, options.index(name) + 1, options)
|
||||
picks[-1]['chosen'] = name
|
||||
if call is None:
|
||||
latest = f'The {name} call was not produced.'
|
||||
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
|
||||
continue
|
||||
arguments = call['arguments'] or '{}'
|
||||
signature = (name, arguments)
|
||||
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
|
||||
history.append({'role': 'assistant', 'content': content or '',
|
||||
'tool_calls': [{'id': call_id, 'type': 'function',
|
||||
'function': {'name': name, 'arguments': arguments}}]})
|
||||
if signature in done_calls:
|
||||
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
|
||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||
latest = output
|
||||
continue
|
||||
try:
|
||||
args = json.loads(arguments)
|
||||
except ValueError:
|
||||
args = {}
|
||||
block = function_call_to_tool_block(name, arguments)
|
||||
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
|
||||
'full_command': arguments, 'round': round_number})
|
||||
tool_started = time.monotonic()
|
||||
if block is None:
|
||||
desc, result = f'{name}: invalid arguments', {
|
||||
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
|
||||
else:
|
||||
desc, result = await execute_tool_block(
|
||||
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||
tool_policy=tool_policy, security_context=security,
|
||||
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
|
||||
client_runtime_context=client_runtime_context, request_authority=request_authority)
|
||||
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
|
||||
output = preview_tool_result_text(result, name, args)
|
||||
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
|
||||
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
|
||||
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
|
||||
'desc': desc, 'round': round_number,
|
||||
'seconds': round(time.monotonic() - tool_started, 3)}
|
||||
if result.get('image_url'):
|
||||
yield event({'type': 'generated_image', 'url': result['image_url'],
|
||||
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
|
||||
if result.get('ui_event'):
|
||||
yield event({'type': 'ui_control', 'data': result})
|
||||
executions.append(tool_event)
|
||||
yield event(tool_event)
|
||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||
if not failed:
|
||||
done_calls.add(signature)
|
||||
latest = output
|
||||
ask = result.get('ask_user')
|
||||
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
|
||||
question = str(ask['question']).strip()
|
||||
history.append({'role': 'assistant', 'content': question})
|
||||
yield event({'type': 'final_response', 'content': question})
|
||||
yield event({'type': 'ask_user', 'data': ask})
|
||||
break
|
||||
elapsed = time.monotonic() - started
|
||||
yield event({'type': 'metrics', 'data': {
|
||||
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
|
||||
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
|
||||
'time_to_first_token': round(first_token - started, 3) if first_token else None,
|
||||
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
|
||||
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
|
||||
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
|
||||
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
|
||||
}})
|
||||
yield 'data: [DONE]\n\n'
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
||||
|
||||
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
||||
message and scores every offered tool in one forward pass. The harness only
|
||||
ever ADDS tools from its answer, never removes any, and fails open: on any
|
||||
error, timeout or low-confidence answer the compact tool set is unchanged.
|
||||
|
||||
Env:
|
||||
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||
ODYSSEUS_SPEC_ROUTER_MODEL adapter name (default: odysseus-s60)
|
||||
ODYSSEUS_SPEC_ROUTER_TIMEOUT seconds (default: 0.5)
|
||||
ODYSSEUS_SPEC_ROUTER_MIN_CONF minimum top-1 probability to act (default: 0.3)
|
||||
ODYSSEUS_SPEC_ROUTER_SECOND minimum 2nd-choice probability to also add it (default: 0.25)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEAD = {
|
||||
"answer_directly": "No tool is needed: answer the user directly from the conversation",
|
||||
"ask_user": "A tool is needed but required information is missing: ask the user first",
|
||||
}
|
||||
_INSTRUCTIONS = (
|
||||
"Which tool should the agent call next to handle the user's latest message? "
|
||||
"Use the conversation for context. If no tool is needed, choose answer directly. "
|
||||
"If a tool is needed but information it requires is missing, choose ask the user."
|
||||
)
|
||||
_AGENT = "Odysseus, a personal tool-using assistant."
|
||||
# Recent decisions, for debugging in the UI/logs: most recent last.
|
||||
LAST_DECISIONS: List[Dict[str, Any]] = []
|
||||
_MCP_BROWSER_TOOLS = (
|
||||
"mcp__builtin_browser__browser_navigate",
|
||||
"mcp__builtin_browser__browser_snapshot",
|
||||
"mcp__builtin_browser__browser_click",
|
||||
"mcp__builtin_browser__browser_type",
|
||||
"mcp__builtin_browser__browser_press_key",
|
||||
"mcp__builtin_browser__browser_wait_for",
|
||||
"mcp__builtin_browser__browser_take_screenshot",
|
||||
)
|
||||
|
||||
|
||||
def _fn(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return schema.get("function") if isinstance(schema.get("function"), dict) else schema
|
||||
|
||||
|
||||
def tool_line(schema: Dict[str, Any]) -> str:
|
||||
fn = _fn(schema)
|
||||
name = str(fn.get("name") or "")
|
||||
props = ((fn.get("parameters") or {}).get("properties") or {})
|
||||
if os.getenv("ODYSSEUS_SPEC_TOOL_FORMAT", "").strip().lower() == "names":
|
||||
return name + "(" + ", ".join(props) + ")" # names + parameters only (spec_v1n adapters are trained on this)
|
||||
desc = " ".join(str(fn.get("description") or "").split())
|
||||
if len(desc) > 220:
|
||||
desc = desc[:217] + "..."
|
||||
return f"{name}({', '.join(props)}): {desc}"
|
||||
|
||||
|
||||
def prior_tool_names(messages: Optional[Iterable[Dict[str, Any]]]) -> Set[str]:
|
||||
"""Tools already called in this conversation (kept offered: no 'forgetting')."""
|
||||
seen: Set[str] = set()
|
||||
for m in messages or []:
|
||||
if not isinstance(m, dict) or m.get("role") != "assistant":
|
||||
continue
|
||||
for tc in m.get("tool_calls") or []:
|
||||
name = (tc.get("function") or tc).get("name") if isinstance(tc, dict) else None
|
||||
if name:
|
||||
seen.add(str(name))
|
||||
return seen
|
||||
|
||||
|
||||
def _conversation(messages: Optional[Iterable[Dict[str, Any]]], limit: int = 4) -> List[Dict[str, str]]:
|
||||
out: List[Dict[str, str]] = []
|
||||
for m in list(messages or []):
|
||||
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip():
|
||||
out.append({"role": m["role"], "content": m["content"][:600]})
|
||||
# the last user message is passed separately as user_message
|
||||
if out and out[-1]["role"] == "user":
|
||||
out = out[:-1]
|
||||
return out[-limit:]
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip())
|
||||
|
||||
|
||||
def spec_route_tools(
|
||||
user_message: str,
|
||||
schemas: Iterable[Dict[str, Any]],
|
||||
disabled_tools: Optional[Set[str]] = None,
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
return_decision: bool = False,
|
||||
):
|
||||
"""Return tool names the router wants offered (plus tools already used).
|
||||
|
||||
With return_decision=True returns (tools, decided) where decided is True when
|
||||
the router answered confidently (a tool pick, or answer_directly)."""
|
||||
added: Set[str] = set(prior_tool_names(messages))
|
||||
decided = False
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url or not (user_message or "").strip():
|
||||
return (added, False) if return_decision else added
|
||||
t0 = time.time()
|
||||
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
||||
try:
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
http = urllib.request.Request(
|
||||
url + "/v1/systemone", data=json.dumps(req).encode(), headers={"content-type": "application/json"}
|
||||
)
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||
break
|
||||
except urllib.error.HTTPError as busy:
|
||||
# 529 = router busy with another request; retry within budget.
|
||||
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
decision["retries"] = decision.get("retries", 0) + 1
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = out.get("choice")
|
||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||
ranked = sorted(
|
||||
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
||||
)
|
||||
picks: List[str] = []
|
||||
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
||||
picks.append(names[int(choice[1:]) - 1])
|
||||
for p, k in ranked[:3]:
|
||||
nm = names[int(k[1:]) - 1]
|
||||
if nm not in picks and p >= second_min:
|
||||
picks.append(nm)
|
||||
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
||||
if _bmode != "0" and "private_browser" in picks:
|
||||
# This build disables page execution in private_browser (see
|
||||
# src/browser_identity.py). "mcp": use the connected Playwright MCP
|
||||
# browser (no run_code/evaluate); "web": fetch/search only.
|
||||
picks = [n for n in picks if n != "private_browser"]
|
||||
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
||||
for n in extra + ["web_search", "web_fetch"]:
|
||||
if n not in picks:
|
||||
picks.append(n)
|
||||
added.update(picks)
|
||||
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
||||
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||
except Exception as exc: # fail open
|
||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||
logger.warning("spec router failed open: %s", exc)
|
||||
LAST_DECISIONS.append(decision)
|
||||
del LAST_DECISIONS[:-50]
|
||||
decision["decided"] = decided
|
||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||
return (added, decided) if return_decision else added
|
||||
|
||||
|
||||
# ---- Ajax Spec: system prompt trimmed to the tools actually offered ----------
|
||||
_CORE_PROMPT = (
|
||||
"You are Ajax, the assistant in Odysseus. Emit one native tool call, then stop. "
|
||||
"After a successful tool result, answer from it; never repeat the same tool with the same arguments."
|
||||
)
|
||||
_CODING_PROMPT = (
|
||||
"HARD CODING ORDER: for code changes call read_file, then edit_file or apply_patch, then host_shell for tests. "
|
||||
"Never run tests before the edit succeeds; after a read, your next call is the edit. "
|
||||
"edit_file JSON is {path,old_string,new_string}; write_file only for new files or full rewrites."
|
||||
)
|
||||
_TOOL_NOTES = {
|
||||
"manage_notes": "manage_notes: notes/checklists.",
|
||||
"manage_documents": "manage_documents: document library; actions list/read/delete/tidy, use list+search to find by title/content.",
|
||||
"create_document": "create_document: create editor-panel documents with title/content.",
|
||||
"edit_document": "edit_document: edit the active editor document using command=<<<FIND>>>old<<<REPLACE>>>new<<<END>>> or edits=[{find,replace}].",
|
||||
"update_document": "update_document: full active-document rewrite only.",
|
||||
"manage_calendar": "manage_calendar: calendar events; actions create_event, update_event, delete_event, list_events.",
|
||||
"manage_tasks": "manage_tasks: scheduled/recurring tasks; create uses action=create, name, prompt, task_type, schedule, scheduled_time, output_target; pause/resume/delete use task_id, so list/search first when only the name is known. Recurring reminders use manage_tasks.",
|
||||
"manage_memory": "manage_memory: saved memories.",
|
||||
"manage_contact": "manage_contact: address-book contacts.",
|
||||
"manage_research": "manage_research: saved research reports.",
|
||||
"manage_settings": "manage_settings: app settings.",
|
||||
"manage_endpoints": "manage_endpoints: configured API endpoints.",
|
||||
"list_models": "list_models: available models.",
|
||||
"manage_mcp": "manage_mcp: MCP servers.",
|
||||
"manage_skills": "manage_skills: skills.",
|
||||
"manage_webhooks": "manage_webhooks: webhooks.",
|
||||
"manage_bg_jobs": "manage_bg_jobs: running/background jobs.",
|
||||
"search_chats": "search_chats: past chats.",
|
||||
"web_search": "web_search: public web lookup, URLs, links.",
|
||||
"web_fetch": "web_fetch: fetch a concrete URL/domain.",
|
||||
"private_browser": "private_browser: rendered site interaction. Make separate calls: first action=open with the url, then action=snapshot; read/scroll/find as needed. Do not use action=batch. Only click/fill element refs returned by snapshot (for example @e12), never guessed labels.",
|
||||
"mcp__builtin_browser__browser_navigate": "Browser: browser_navigate {url} opens a page; then browser_snapshot returns the page with element refs; browser_click/browser_type take ref values from the latest snapshot, never guessed labels.",
|
||||
"youtube_tool": "youtube_tool: YouTube comments, transcripts, metadata, and latest channel video.",
|
||||
"read_file": "read_file: workspace text, PDF, or Office document path.",
|
||||
"edit_file": "edit_file: exact replacement in an existing workspace file.",
|
||||
"write_file": "write_file: create or fully rewrite a workspace file.",
|
||||
"apply_patch": "apply_patch: related multi-file workspace edits.",
|
||||
"host_shell": "host_shell: run tests, builds, and verification in the active workspace.",
|
||||
}
|
||||
_EMAIL_NOTES = (
|
||||
"Email: latest email/emails/inbox -> mcp__email__list_emails {folder:INBOX,max_results:1,unread_only:false}; "
|
||||
"account list -> list_email_accounts; subject find+read -> mcp__email__list_emails {folder:INBOX,max_results:20}; "
|
||||
"sender/topic search -> mcp__email__search_emails {max_results:10}; known id -> mcp__email__read_email; "
|
||||
"new draft -> mcp__email__draft_email; attachment -> mcp__email__download_attachment {uid,index,folder,account}; "
|
||||
"reply draft -> mcp__email__draft_email_reply {uid,account,body}; only explicit send-now -> mcp__email__reply_to_email {uid,account,body}; "
|
||||
"archive/delete/mark -> mcp__email__archive_email/mcp__email__delete_email/mcp__email__mark_email_read. Never emit manage_email."
|
||||
)
|
||||
_AJAX_PROMPT_START = "You are Ajax, the assistant in Odysseus."
|
||||
|
||||
|
||||
def trimmed_system_prompt(offered: Iterable[str]) -> str:
|
||||
offered = set(offered or ())
|
||||
parts = [_CORE_PROMPT]
|
||||
if offered & {"edit_file", "apply_patch", "host_shell"}:
|
||||
parts.append(_CODING_PROMPT)
|
||||
parts += [_TOOL_NOTES[n] for n in sorted(offered) if n in _TOOL_NOTES]
|
||||
if any(n.startswith("mcp__email__") or n in {"list_email_accounts"} for n in offered):
|
||||
parts.append(_EMAIL_NOTES)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def trim_system_prompt(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||
"""Swap the generic Ajax tool manual for one covering only the offered tools."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for m in messages:
|
||||
if (
|
||||
isinstance(m, dict)
|
||||
and m.get("role") == "system"
|
||||
and isinstance(m.get("content"), str)
|
||||
and m["content"].startswith(_AJAX_PROMPT_START)
|
||||
):
|
||||
new = dict(m)
|
||||
new["content"] = trimmed_system_prompt(offered)
|
||||
logger.info("[spec-harness] system prompt trimmed %d -> %d chars", len(m["content"]), len(new["content"]))
|
||||
out.append(new)
|
||||
else:
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def drop_skills_index(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||
"""Ajax Spec: omit the ~1000-token skills index unless the skills tool is offered."""
|
||||
if "manage_skills" in set(offered or ()):
|
||||
return messages
|
||||
out = [
|
||||
m for m in messages
|
||||
if not (isinstance(m, dict) and (m.get("metadata") or {}).get("source") == "available skills index")
|
||||
]
|
||||
if len(out) != len(messages):
|
||||
logger.info("[spec-harness] dropped skills index (%d chars)", sum(len(str(m.get("content") or "")) for m in messages if m not in out))
|
||||
return out
|
||||
|
||||
|
||||
# --- Router-decided thinking (progressive thinking trigger) -------------------------------------------------
|
||||
# The router classifies the user's request as THINK (needs reasoning: math/logic, tricky planning, subtle explanation or
|
||||
# weighing options, first-instinct-likely-wrong questions) or DIRECT (plain doing, lookup, chit-chat).
|
||||
# Zero-shot on 143 DeepSeek-labelled app requests (2026-10-13): odysseus-agentic-v1f acc 0.916, think recall 0.985,
|
||||
# think precision 0.859, ~33 ms. Off unless ODYSSEUS_SPEC_THINK_ROUTER=1. Never raises; returns None on any error.
|
||||
_THINK_DEF = ("This request needs genuine reasoning or figuring something out before answering: math and logic puzzles; "
|
||||
"planning a tricky multi-step approach; explaining something subtle or weighing options; or questions where "
|
||||
"a quick first instinct is likely wrong.")
|
||||
_DIRECT_DEF = ("This request is plain doing, lookup, or chit-chat that can be answered directly without reasoning: simple "
|
||||
"tool actions (set a reminder, search email, open a page, rename a file), simple facts, or small talk.")
|
||||
_THINK_INSTRUCTIONS = ("Classify whether this single user request needs the assistant to THINK (deep reasoning / figuring "
|
||||
"something out) before answering, or can be answered DIRECTLY (plain doing, lookup, or chit-chat). "
|
||||
"Answer with exactly one of the two options.")
|
||||
|
||||
|
||||
# 5-way request kind, exactly as trained into adapter thinkclass5 (2026-10-13; held-out 143: think/direct 96.5%,
|
||||
# think precision 98.8%, recall 95.2%). Enabled with ODYSSEUS_SPEC_THINK_QUESTION=kind5 + ODYSSEUS_SPEC_THINK_MODEL=thinkclass5.
|
||||
_KIND5_QUESTION = {"type": "choice", "instructions": "Read the user's request and classify it into exactly one of five kinds: math_logic, tricky_planning, explain_or_weigh, trap_question, or direct. Pick the option whose description fits best.", "criteria": {"math_logic": "Mathematical or logical computation/reasoning: arithmetic, word problems, logic puzzles, counting, proofs.", "tricky_planning": "Planning a multi-step approach with real constraints: schedules, itineraries, sequencing, resource or budget allocation, multi-part procedures.", "explain_or_weigh": "Explaining something subtle or weighing options/tradeoffs: comparisons, 'should I', pros and cons, recommendations with reasoning, nuanced why-explanations.", "trap_question": "A question where the obvious or first-instinct answer is likely wrong: riddles, trick questions, common misconceptions, counterintuitive facts.", "direct": "Plain doing, lookup, or chit-chat: simple tool actions, simple facts, small talk — no deep reasoning needed."}}
|
||||
|
||||
|
||||
def think_router_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_SPEC_THINK_ROUTER", "0").strip() not in ("", "0", "false", "off")
|
||||
|
||||
|
||||
def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return {'think': bool, 'p_think': float, 'ms': float} or None (disabled / error / empty message)."""
|
||||
if not think_router_enabled():
|
||||
return None
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
text = (user_message or "").strip()
|
||||
if not url or not text:
|
||||
return None
|
||||
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
||||
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
||||
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
||||
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
||||
"questions": {"thinking": question},
|
||||
}
|
||||
t0 = time.time()
|
||||
try:
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
ans = json.load(urllib.request.urlopen(http, timeout=timeout))["answers"]["thinking"]
|
||||
probs = ans.get("probabilities") or {}
|
||||
# kind5: think = any of the four reasoning kinds (trained adapter thinkclass5); binary: P(think).
|
||||
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
||||
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||
if kind5:
|
||||
out["kind"] = ans.get("choice")
|
||||
logger.info("spec think router: %s msg=%r", out, text[:120])
|
||||
return out
|
||||
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
||||
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Top-1 next action at any confidence: {'kind': 'tool'|'answer_directly'|'ask_user', 'name', 'p'}.
|
||||
|
||||
Same request as training (build_agentic_rows.py): sorted tool lines, and mid-turn the latest tool
|
||||
result appended to the user message as "\\n\\nLatest tool result:\\n...". Returns None on any error."""
|
||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||
if not url:
|
||||
return None
|
||||
t0 = time.time()
|
||||
disabled = set(disabled_tools or ())
|
||||
lines: Dict[str, str] = {}
|
||||
for s in schemas:
|
||||
n = str(_fn(s).get("name") or "")
|
||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||
lines[n] = tool_line(s)
|
||||
names = sorted(lines)
|
||||
criteria = dict(_HEAD)
|
||||
for i, n in enumerate(names, 1):
|
||||
criteria[f"t{i}"] = lines[n]
|
||||
um = (user_message or "")[-3000:]
|
||||
if latest_result:
|
||||
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
||||
req = {
|
||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||
}
|
||||
try:
|
||||
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||
break
|
||||
except urllib.error.HTTPError as busy:
|
||||
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||
raise
|
||||
time.sleep(0.1)
|
||||
probs = out.get("probabilities") or {}
|
||||
choice = str(out.get("choice") or "")
|
||||
if choice.startswith("t") and choice[1:].isdigit():
|
||||
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
||||
elif choice in ("answer_directly", "ask_user"):
|
||||
action = {"kind": choice, "name": choice}
|
||||
else:
|
||||
return None
|
||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
|
||||
return action
|
||||
except Exception as exc:
|
||||
logger.warning("spec next action failed: %s", exc)
|
||||
return None
|
||||
+28
-1
@@ -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,12 @@ async def execute_tool_block(
|
||||
}
|
||||
|
||||
transport = operation.transport_tool
|
||||
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
||||
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
|
||||
# producer in src.browser_identity refuses every page action in this
|
||||
# build. This compatibility transport requires an explicit opt-in.
|
||||
return await _legacy_private_browser(
|
||||
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
|
||||
if operation.tool == "private_browser":
|
||||
try:
|
||||
_, browser_args = parse_browser_operation(operation.input)
|
||||
@@ -2603,4 +2630,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))
|
||||
|
||||
+9
-1
@@ -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)
|
||||
|
||||
+9
-1
@@ -6149,7 +6149,15 @@ import { invalidateSettings } from './appConfig.js';
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
|
||||
if (res.status !== 404) return;
|
||||
// The server now reports "no active stream" as 200 {status:"idle"} (an
|
||||
// older server used 404); a body with status "streaming" means it is NOT
|
||||
// stale. Accept both so the watchdog keeps working across deploys.
|
||||
if (res.status !== 404 && !res.ok) return;
|
||||
let _probe = null;
|
||||
try { _probe = await res.json(); } catch (_) { _probe = null; }
|
||||
const _noActiveStream = res.status === 404 || (res.ok && _probe &&
|
||||
typeof _probe.status === 'string' && _probe.status !== 'streaming');
|
||||
if (!_noActiveStream) return;
|
||||
|
||||
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
|
||||
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""A valid login session cookie must survive a deploy restart of the server.
|
||||
|
||||
AuthManager keeps tokens in memory but persists them to sessions.json on create
|
||||
and reloads them at startup, so a restart must NOT log the user out (the 302 the
|
||||
bench saw was the restart gap / a client-side artifact, not session loss).
|
||||
"""
|
||||
from core import auth
|
||||
|
||||
|
||||
def test_session_token_survives_a_restart(tmp_path):
|
||||
auth_path = str(tmp_path / "auth.json")
|
||||
first = auth.AuthManager(auth_path)
|
||||
assert first.create_user("pewds", "hunter2", is_admin=True)
|
||||
token = first.create_session_trusted("pewds")
|
||||
assert token and first.validate_token(token)
|
||||
|
||||
# Simulate a process restart: a brand-new AuthManager on the same data dir.
|
||||
second = auth.AuthManager(auth_path)
|
||||
assert second.validate_token(token) is True
|
||||
assert second.get_username_for_token(token) == "pewds"
|
||||
|
||||
|
||||
def test_revoked_token_does_not_survive_restart(tmp_path):
|
||||
auth_path = str(tmp_path / "auth.json")
|
||||
first = auth.AuthManager(auth_path)
|
||||
first.create_user("pewds", "hunter2", is_admin=True)
|
||||
token = first.create_session_trusted("pewds")
|
||||
first.revoke_token(token)
|
||||
|
||||
second = auth.AuthManager(auth_path)
|
||||
assert second.validate_token(token) is False
|
||||
@@ -3511,6 +3511,10 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
|
||||
'web_fetch': ({'url': 'https://example.com'}, 'read this page'),
|
||||
'web_search': ({'query': 'current AI news'}, 'search the web'),
|
||||
'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'),
|
||||
'plan_edit': ({'prompt': 'make it black and white'}, 'make it black and white'),
|
||||
'run_editor_command': ({'op': 'document.info'}, 'inspect the document'),
|
||||
'get_editor_screenshot': ({}, 'take a screenshot of the editor'),
|
||||
'ai_edit_image': ({'op': 'remove-bg'}, 'remove the background'),
|
||||
}
|
||||
schemas = {
|
||||
schema['function']['name']: schema
|
||||
@@ -3537,6 +3541,9 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
|
||||
}
|
||||
else {'image_editing'} if name == 'edit_image'
|
||||
else {'image_generation'} if name == 'generate_image'
|
||||
else {'photo_editor'} if name in {
|
||||
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
|
||||
}
|
||||
else frozenset()
|
||||
),
|
||||
contract_required_tools={name},
|
||||
@@ -7284,3 +7291,216 @@ async def test_tool_round_leadin_is_replaced_by_terminal_synthesis(monkeypatch):
|
||||
"I'll browse IKEA and look at their chairs.",
|
||||
'The most epic option is the DYVLINGE swivel chair.',
|
||||
]
|
||||
|
||||
def test_spec_router_think_decision_and_reason(monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
from src.clean_agent_preview import (
|
||||
spec_router_thinking_decision,
|
||||
spec_router_think_reason,
|
||||
)
|
||||
|
||||
# --- router disabled -> no decision, no reason (default OFF) -------------
|
||||
fake = types.ModuleType('src.spec_router')
|
||||
fake.think_router_enabled = lambda: False
|
||||
fake.route_thinking = lambda text: {'think': True, 'p_think': 0.9, 'ms': 1.0}
|
||||
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
|
||||
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
|
||||
assert spec_router_think_reason(1, None, '') == ''
|
||||
|
||||
# --- enabled but not a spec surface -> no decision ----------------------
|
||||
fake.think_router_enabled = lambda: True
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'native', raising=False,
|
||||
)
|
||||
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
|
||||
|
||||
# --- enabled on spec + think=True -> reason only on round 1 when no other
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False,
|
||||
)
|
||||
decision = spec_router_thinking_decision('u', 'm', 'o', {}, 'plan the refactor')
|
||||
assert decision == {'think': True, 'p_think': 0.9, 'ms': 1.0}
|
||||
assert spec_router_think_reason(1, decision, '') == 'router_think'
|
||||
# existing rule wins; only round 1; think=False never fires
|
||||
assert spec_router_think_reason(1, decision, 'code') == ''
|
||||
assert spec_router_think_reason(2, decision, '') == ''
|
||||
assert spec_router_think_reason(1, {'think': False}, '') == ''
|
||||
|
||||
# --- route_thinking raises -> fail open (None), rules only --------------
|
||||
def _boom(text):
|
||||
raise RuntimeError('router down')
|
||||
fake.route_thinking = _boom
|
||||
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
|
||||
assert spec_router_think_reason(1, None, '') == ''
|
||||
|
||||
# --- wiring present in stream_preview -----------------------------------
|
||||
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
|
||||
assert 'spec_router_thinking_decision(' in source
|
||||
assert 'spec_router_think_reason(' in source
|
||||
|
||||
|
||||
def test_spec_direct_no_tools_decision(monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
from src.clean_agent_preview import spec_direct_no_tools_p
|
||||
|
||||
fake = types.ModuleType('src.spec_router')
|
||||
fake.LAST_DECISIONS = [
|
||||
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
|
||||
]
|
||||
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False,
|
||||
)
|
||||
|
||||
# --- default OFF -> no override, identical behaviour --------------------
|
||||
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
|
||||
|
||||
# --- ON + answer_directly p >= 0.9 -> returns the confidence ------------
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.971
|
||||
|
||||
# --- low confidence -> no override --------------------------------------
|
||||
fake.LAST_DECISIONS = [
|
||||
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.5},
|
||||
]
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
|
||||
|
||||
# --- MIN_P override lowers the bar --------------------------------------
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.4')
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.5
|
||||
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
|
||||
|
||||
# --- a non answer_directly choice -> no override ------------------------
|
||||
fake.LAST_DECISIONS = [
|
||||
{'message': 'Yo', 'choice': 'web_search', 'answer_directly': 0.971},
|
||||
]
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
|
||||
|
||||
# --- stale decision for a different message -> no override --------------
|
||||
fake.LAST_DECISIONS = [
|
||||
{'message': 'something else entirely', 'choice': 'answer_directly',
|
||||
'answer_directly': 0.99},
|
||||
]
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
|
||||
|
||||
# --- non-spec surface -> no override ------------------------------------
|
||||
fake.LAST_DECISIONS = [
|
||||
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'native', raising=False,
|
||||
)
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
|
||||
|
||||
# --- router error -> fail open (None) -----------------------------------
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False,
|
||||
)
|
||||
|
||||
class _Boom:
|
||||
@property
|
||||
def LAST_DECISIONS(self):
|
||||
raise RuntimeError('router down')
|
||||
monkeypatch.setitem(sys.modules, 'src.spec_router', _Boom())
|
||||
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
|
||||
|
||||
# --- wiring present in stream_preview -----------------------------------
|
||||
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
|
||||
assert 'spec_direct_no_tools_p(' in source
|
||||
assert '[spec-harness] direct answer' in source
|
||||
|
||||
|
||||
|
||||
|
||||
def test_action_promise_ignores_fenced_code_and_knows_browse_verbs():
|
||||
import src.clean_agent_preview as module
|
||||
|
||||
gym_reply = (
|
||||
'I will browse Google Maps to find the closest gym near Todoroki.\n\n'
|
||||
'```python\n'
|
||||
'import requests\n'
|
||||
'from bs4 import BeautifulSoup\n'
|
||||
'resp = requests.get("https://maps.google.com/search?q=gym+todoroki")\n'
|
||||
'soup = BeautifulSoup(resp.text, "html.parser")\n'
|
||||
'for row in soup.select(".section-result"):\n'
|
||||
' print(row.get_text())\n'
|
||||
'```\n'
|
||||
)
|
||||
assert module.action_promise_response(gym_reply)
|
||||
|
||||
assert module.action_promise_response('I will fetch the page and scrape the listing.')
|
||||
assert module.action_promise_response('Let me search for the nearest gym now.')
|
||||
assert module.action_promise_response("I'll go check Google Maps for you.")
|
||||
|
||||
# A real answer that includes a code block is not an action promise.
|
||||
assert not module.action_promise_response(
|
||||
'The closest gym to Todoroki is Gold\u2019s Gym, about 600 m away.\n\n'
|
||||
'```python\n'
|
||||
'print("done")\n'
|
||||
'```\n'
|
||||
)
|
||||
assert not module.action_promise_response('Here is the script you asked for:\n```\nprint(1)\n```')
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prompt', [
|
||||
'please explain how a student should break down work about customer feedback; this is general advice only, do not create or schedule anything.',
|
||||
'Give general advice about customer feedback and task planning; do not create anything.',
|
||||
'Explain feedback tasks without scheduling anything.',
|
||||
])
|
||||
def test_feedback_advice_does_not_claim_write_authority(prompt):
|
||||
assert not requests_mutation(prompt)
|
||||
|
||||
|
||||
|
||||
def test_feedback_subject_does_not_hide_real_write_request():
|
||||
assert requests_mutation('Schedule a customer feedback task for tomorrow.')
|
||||
assert requests_mutation('Do not create anything; schedule a customer feedback task tomorrow.')
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_feedback_advice_is_not_replaced_with_preview_denial(monkeypatch):
|
||||
import src.clean_agent_preview as module
|
||||
|
||||
answer = 'Set a goal, group the customer comments by theme, and summarize what you learned.'
|
||||
|
||||
class Response:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def raise_for_status(self): pass
|
||||
async def aiter_lines(self):
|
||||
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': answer}}]})
|
||||
yield 'data: [DONE]'
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def stream(self, *args, **kwargs):
|
||||
assert 'tools' not in kwargs['json']
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
|
||||
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
|
||||
prompt = ('please explain how a student should break down work about customer feedback; '
|
||||
'this is general advice only, do not create or schedule anything.')
|
||||
chunks = [chunk async for chunk in stream_preview(
|
||||
endpoint_url='http://test', model='ajax-ablated500-base',
|
||||
messages=[{'role': 'user', 'content': prompt}], headers={},
|
||||
turn_contract=contract, session_id='test', owner='test',
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(),
|
||||
)]
|
||||
events = [json.loads(chunk[6:]) for chunk in chunks if '[DONE]' not in chunk]
|
||||
assert ''.join(event.get('delta', '') for event in events) == answer
|
||||
assert not any(event.get('type') == 'final_response' and event.get('content') == denied_response()
|
||||
for event in events)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from src.agent_evidence import infer_completion_requirements
|
||||
from src.clean_agent_preview import active_email_context_message, email_identifier_error
|
||||
|
||||
|
||||
def test_open_reader_uid_is_valid_email_evidence():
|
||||
reader = active_email_context_message({
|
||||
'uid': '3971', 'folder': 'INBOX', 'account': 'account-1',
|
||||
})
|
||||
assert email_identifier_error(
|
||||
'mcp__email__read_email',
|
||||
{'uid': '3971', 'folder': 'INBOX', 'account': 'account-1'},
|
||||
user_text='Summarize this email', history=[reader],
|
||||
) is None
|
||||
assert email_identifier_error(
|
||||
'mcp__email__read_email', {'uid': '3972'},
|
||||
user_text='Summarize this email', history=[reader],
|
||||
)
|
||||
|
||||
|
||||
def test_pasted_email_address_does_not_become_required_artifact():
|
||||
instruction = (
|
||||
'Summarize this email\r\n'
|
||||
'If you do not wish to make use of this service, please send a mail '
|
||||
'to news.alerts@example.test.\r\n'
|
||||
)
|
||||
assert infer_completion_requirements(instruction).required_artifacts == ()
|
||||
@@ -386,7 +386,7 @@ def test_gallery_image_endpoint_lookups_are_owner_scoped():
|
||||
assert body.count("_first_visible_image_endpoint(db, user)") >= 4
|
||||
assert body.count("_visible_image_endpoint_for_base(db,") >= 2
|
||||
assert "def _current_user_is_admin" in body
|
||||
assert body.count('raise HTTPException(403, "Choose a registered image endpoint")') == 2
|
||||
assert body.count('raise HTTPException(403, "Choose a registered image endpoint")') == 3
|
||||
for marker in (
|
||||
"async def gallery_ai_upscale",
|
||||
"async def gallery_style_transfer",
|
||||
|
||||
@@ -148,6 +148,7 @@ def _chat_stream_endpoint(
|
||||
endpoint_url="https://selected.example/v1",
|
||||
session_model="selected-model",
|
||||
session_history=(),
|
||||
attachment_meta=(),
|
||||
):
|
||||
def add_message(message):
|
||||
captured.setdefault("added_messages", []).append(message)
|
||||
@@ -172,7 +173,7 @@ def _chat_stream_endpoint(
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "hello"},
|
||||
],
|
||||
preprocessed=SimpleNamespace(attachment_meta=[]),
|
||||
preprocessed=SimpleNamespace(attachment_meta=list(attachment_meta)),
|
||||
auto_opened_docs=[],
|
||||
rag_sources=[],
|
||||
web_sources=[],
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for the jeff-serve decision client and the email-urgency adapter path.
|
||||
|
||||
Covers the calibrated-score mapping plus the two high-precision overrides
|
||||
(promotions and "waiting outside") that the prompt path also applies. All
|
||||
network is mocked, so these run offline.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from src import builtin_actions as ba
|
||||
from src import jeff_decision as jd
|
||||
|
||||
|
||||
def _answer(score):
|
||||
return {"urgency": {"type": "score", "score": score, "probabilities": {},
|
||||
"legend": {}, "confidence": 0.9}}
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _enable(monkeypatch, score):
|
||||
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "1")
|
||||
|
||||
async def fake(*a, **k):
|
||||
return _answer(score)
|
||||
|
||||
monkeypatch.setattr(jd, "jeff_systemone", fake)
|
||||
|
||||
|
||||
def test_score_value_and_choice_of():
|
||||
assert jd.score_value(_answer(2.5)["urgency"]) == 2.5
|
||||
assert jd.score_value({"type": "choice", "probabilities": {"a": 0.25, "b": 0.75}}) == 0.75
|
||||
assert jd.choice_of({"type": "choice", "choice": "b"}) == "b"
|
||||
assert jd.score_value(None) is None
|
||||
assert jd.choice_of(None) is None
|
||||
|
||||
|
||||
def test_disabled_job_does_not_call_adapter(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "0")
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake(*a, **k):
|
||||
calls["n"] += 1
|
||||
return _answer(4)
|
||||
|
||||
monkeypatch.setattr(jd, "jeff_systemone", fake)
|
||||
out = _run(ba._spec_triage_email_urgency({"subject": "x", "body": "y"}, triage_version=11))
|
||||
assert out is None
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
def test_high_score_maps_to_urgent(monkeypatch):
|
||||
_enable(monkeypatch, 4)
|
||||
v = _run(ba._spec_triage_email_urgency(
|
||||
{"subject": "Locked out", "from": "A <a@b>", "body": "help now", "uid": "1", "message_id": "m"},
|
||||
triage_version=11))
|
||||
assert v["score"] == 3 and v["tags"] == ["urgent"] and v["triage_version"] == 11
|
||||
|
||||
|
||||
def test_low_score_maps_to_zero(monkeypatch):
|
||||
_enable(monkeypatch, 0)
|
||||
v = _run(ba._spec_triage_email_urgency({"subject": "FYI", "body": "no action needed", "uid": "2"},
|
||||
triage_version=11))
|
||||
assert v["score"] == 0 and v["tags"] == []
|
||||
|
||||
|
||||
def test_promotion_is_overridden_to_zero(monkeypatch):
|
||||
_enable(monkeypatch, 4)
|
||||
v = _run(ba._spec_triage_email_urgency(
|
||||
{"subject": "FLASH SALE", "body": "discount, limited time, shop now, coupon", "uid": "3"},
|
||||
triage_version=11))
|
||||
assert v["score"] == 0 and v["reason"].startswith("bulk")
|
||||
|
||||
|
||||
def test_waiting_outside_forces_urgent(monkeypatch):
|
||||
_enable(monkeypatch, 0.2)
|
||||
v = _run(ba._spec_triage_email_urgency(
|
||||
{"subject": "Deliveries", "body": "I am waiting outside, can't get in", "uid": "4"},
|
||||
triage_version=11))
|
||||
assert v["score"] == 3
|
||||
|
||||
|
||||
def test_adapter_unavailable_returns_none(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "1")
|
||||
|
||||
async def fake(*a, **k):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(jd, "jeff_systemone", fake)
|
||||
assert _run(ba._spec_triage_email_urgency({"subject": "x", "body": "y"}, triage_version=11)) is None
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from services.memory.memory_extractor import _has_user_fact_evidence, _is_text_duplicate
|
||||
|
||||
|
||||
@pytest.mark.parametrize('content,evidence,expected', [
|
||||
('I live in Lisbon.', 'I live in Lisbon', True),
|
||||
('I live in Lisbon. Can you find cafes?', 'I live in Lisbon', True),
|
||||
('Can you tell me where I live?', 'I live in Lisbon', False),
|
||||
('I live in Lisbon?', 'I live in Lisbon', False),
|
||||
('Please read my records.', 'My home is in Lisbon', False),
|
||||
])
|
||||
def test_extracted_memories_require_user_assertion(content, evidence, expected):
|
||||
assert _has_user_fact_evidence(
|
||||
{'text': 'User lives in Lisbon', 'evidence': evidence},
|
||||
[{'role': 'user', 'content': content}],
|
||||
) is expected
|
||||
|
||||
|
||||
def test_assistant_statement_does_not_ground_memory():
|
||||
assert not _has_user_fact_evidence(
|
||||
{'text': 'User lives in Lisbon', 'evidence': 'I live in Lisbon'},
|
||||
[{'role': 'assistant', 'content': 'I live in Lisbon'}],
|
||||
)
|
||||
|
||||
|
||||
def test_name_dedup_accepts_refinement_but_preserves_different_slots():
|
||||
assert _is_text_duplicate("User's name is Alex.", [{'text': 'Name is Alex Morgan'}])
|
||||
assert not _is_text_duplicate("User's username is alex.", [{'text': "User's name is Alex."}])
|
||||
@@ -54,7 +54,7 @@ class FakeSession:
|
||||
|
||||
def get_context_messages(self):
|
||||
return [
|
||||
{"role": "user", "content": "Tell me where I live."},
|
||||
{"role": "user", "content": "My home is in Lisbon. My role is architect."},
|
||||
{"role": "assistant", "content": "Noted."},
|
||||
]
|
||||
|
||||
@@ -118,7 +118,7 @@ def test_vector_match_from_other_tenant_does_not_drop_users_fact(monkeypatch):
|
||||
])
|
||||
# The vector store reports user B's new fact as a near-duplicate of a1.
|
||||
vec = FakeVector(match_id="a1")
|
||||
_install_llm_stub(monkeypatch, '["My home is in Lisbon"]')
|
||||
_install_llm_stub(monkeypatch, '[{"text":"My home is in Lisbon","evidence":"My home is in Lisbon"}]')
|
||||
|
||||
memory_extractor = _load_extractor()
|
||||
|
||||
@@ -147,7 +147,7 @@ def test_identity_auto_pin_stops_after_first_five_for_owner(monkeypatch):
|
||||
])
|
||||
_install_llm_stub(
|
||||
monkeypatch,
|
||||
'[{"text": "New identity that should not stay pinned", "category": "identity"}]',
|
||||
'[{"text": "User works as an architect", "category": "identity", "evidence": "My role is architect"}]',
|
||||
)
|
||||
|
||||
memory_extractor = _load_extractor()
|
||||
@@ -158,7 +158,7 @@ def test_identity_auto_pin_stops_after_first_five_for_owner(monkeypatch):
|
||||
))
|
||||
|
||||
rows = mm.load(owner="userA")
|
||||
added = next(r for r in rows if r["text"] == "New identity that should not stay pinned")
|
||||
added = next(r for r in rows if r["text"] == "User works as an architect")
|
||||
assert added.get("category") == "identity"
|
||||
assert added.get("pinned") is not True
|
||||
assert sum(1 for r in rows if r.get("category") == "identity" and r.get("pinned")) == 5
|
||||
|
||||
@@ -31,7 +31,7 @@ class _FakeSession:
|
||||
|
||||
def get_context_messages(self):
|
||||
return [
|
||||
{"role": "user", "content": "Hi, a few things about me."},
|
||||
{"role": "user", "content": "I live in Lisbon. I prefer tea over coffee."},
|
||||
{"role": "assistant", "content": "Noted."},
|
||||
]
|
||||
|
||||
@@ -54,8 +54,8 @@ def _run(coro):
|
||||
|
||||
def test_extraction_persists_facts_when_vector_store_fails_at_runtime(monkeypatch):
|
||||
facts_json = (
|
||||
'[{"text": "Alice lives in Lisbon", "category": "fact"}, '
|
||||
'{"text": "Alice prefers tea over coffee", "category": "preference"}]'
|
||||
'[{"text": "Alice lives in Lisbon", "category": "fact", "evidence": "I live in Lisbon"}, '
|
||||
'{"text": "Alice prefers tea over coffee", "category": "preference", "evidence": "I prefer tea over coffee"}]'
|
||||
)
|
||||
|
||||
async def _fake_llm(url, model, messages, **kwargs):
|
||||
@@ -94,7 +94,7 @@ def test_healthy_vector_store_still_dedups_normally(monkeypatch):
|
||||
memory, so the dedup must still fire."""
|
||||
|
||||
async def _fake_llm(url, model, messages, **kwargs):
|
||||
return '[{"text": "Alice lives in Lisbon", "category": "fact"}]'
|
||||
return '[{"text": "Alice lives in Lisbon", "category": "fact", "evidence": "I live in Lisbon"}]'
|
||||
|
||||
monkeypatch.setattr(src.llm_core, "llm_call_async", _fake_llm)
|
||||
monkeypatch.setattr(src.event_bus, "fire_event", lambda *a, **k: None)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Model-picker backend: a slow/dead endpoint must not stall /api/models refresh.
|
||||
|
||||
Covers the fixes in routes/model_routes.py:
|
||||
- the local endpoint refresh timeout default is 2s (was 10s);
|
||||
- a recently failed/timed-out endpoint keeps a ~60s cooldown even on a
|
||||
user-forced refresh, so a forced refresh does not re-probe a dead endpoint
|
||||
on every picker open;
|
||||
- probing is parallel, so one slow endpoint does not push the whole refresh
|
||||
past its own timeout.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.helpers.import_state import (
|
||||
clear_fake_endpoint_resolver_modules,
|
||||
preserve_import_state,
|
||||
)
|
||||
|
||||
|
||||
def _load_model_routes():
|
||||
with preserve_import_state(
|
||||
"core.database", "src.database", "core.session_manager", "routes.model_routes"
|
||||
):
|
||||
clear_fake_endpoint_resolver_modules()
|
||||
if "core.database" not in sys.modules:
|
||||
stub = types.ModuleType("core.database")
|
||||
for name in (
|
||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note", "CalendarCal",
|
||||
"CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
|
||||
"ProviderAuthSession", "Base",
|
||||
):
|
||||
setattr(stub, name, MagicMock())
|
||||
stub.utcnow_naive = MagicMock()
|
||||
sys.modules["core.database"] = stub
|
||||
import routes.model_routes as model_routes
|
||||
return model_routes
|
||||
|
||||
|
||||
def _endpoint(**kw):
|
||||
base = dict(
|
||||
id="ep", base_url="http://127.0.0.1:9/v1", api_key="k",
|
||||
provider_auth_id=None, cached_models="[]", pinned_models=[],
|
||||
is_enabled=True, model_refresh_mode="auto",
|
||||
)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_local_refresh_timeout_is_two_seconds():
|
||||
model_routes = _load_model_routes()
|
||||
ep = _endpoint(base_url="http://100.67.207.85:8071/v1")
|
||||
assert model_routes._endpoint_refresh_timeout(ep, "local") == 2.0
|
||||
assert model_routes._endpoint_refresh_timeout(ep, "remote") == 2.0
|
||||
|
||||
|
||||
def test_recent_failure_is_not_reprobed_even_when_forced():
|
||||
model_routes = _load_model_routes()
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
should = router._should_refresh_endpoint
|
||||
ep = _endpoint(id="remote-dead", base_url="http://100.67.207.85:8071/v1")
|
||||
|
||||
now = time.time()
|
||||
ok, info = should(ep, now, force=True)
|
||||
assert ok is True and info.get("base")
|
||||
key = info["key"]
|
||||
|
||||
# A failure one second ago keeps a 60s cooldown even on a forced refresh.
|
||||
router._refresh_state[key] = {
|
||||
"fail_count": 1, "last_failure": now - 1.0, "inflight": False,
|
||||
}
|
||||
ok_recent, _ = should(ep, time.time(), force=True)
|
||||
assert ok_recent is False
|
||||
|
||||
# A failure long past the 60s floor is re-probed again.
|
||||
router._refresh_state[key] = {
|
||||
"fail_count": 1, "last_failure": now - 120.0, "inflight": False,
|
||||
}
|
||||
ok_old, _ = should(ep, time.time(), force=True)
|
||||
assert ok_old is True
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows, first_value):
|
||||
self._rows = rows
|
||||
self._first = first_value
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
def first(self):
|
||||
return self._first
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, rows, first_value):
|
||||
self._rows = rows
|
||||
self._first = first_value
|
||||
self.committed = False
|
||||
|
||||
def query(self, model):
|
||||
return _FakeQuery(self._rows, self._first)
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_one_slow_endpoint_does_not_stall_the_forced_refresh(monkeypatch):
|
||||
model_routes = _load_model_routes()
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
healthy = _endpoint(id="healthy", base_url="http://127.0.0.1:7021/v1")
|
||||
dead = _endpoint(id="dead", base_url="http://127.0.0.1:9/v1")
|
||||
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDB([healthy, dead], healthy))
|
||||
monkeypatch.setattr(
|
||||
model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False
|
||||
)
|
||||
|
||||
def _fake_probe(base, api_key=None, timeout=5):
|
||||
if "127.0.0.1:9" in base:
|
||||
time.sleep(min(float(timeout or 2), 2.0))
|
||||
raise RuntimeError("timed out")
|
||||
return ["healthy-model"]
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", _fake_probe)
|
||||
|
||||
started = time.time()
|
||||
done = router._refresh_caches_bg(force=True)
|
||||
assert done is not None
|
||||
assert done.wait(5) is True
|
||||
elapsed = time.time() - started
|
||||
|
||||
assert elapsed <= 3.0, f"forced refresh took {elapsed:.2f}s"
|
||||
assert json.loads(healthy.cached_models) == ["healthy-model"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Session-load 404 regression (passover item #9).
|
||||
|
||||
Loading a chat session used to fire two benign 404s in the browser console:
|
||||
GET /api/research/status/<id> (no research for this session)
|
||||
GET /api/chat/stream_status/<id> (no active stream for this session)
|
||||
|
||||
Both are now a normal "empty" state: HTTP 200 with an explicit status
|
||||
("none" / "idle"). The clients already branch on the body status, so this is
|
||||
backward compatible (an older server's 404 is still tolerated).
|
||||
"""
|
||||
import asyncio
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from routes import chat_routes
|
||||
from routes import research_routes
|
||||
|
||||
|
||||
def _route_endpoint(router, path, method="GET"):
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") != path:
|
||||
continue
|
||||
if method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} not registered")
|
||||
|
||||
|
||||
def _req(user="alice"):
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||
|
||||
|
||||
# --- /api/research/status/<id> -------------------------------------------
|
||||
|
||||
def test_research_status_returns_200_none_when_absent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
|
||||
handler = MagicMock()
|
||||
handler._active_tasks = {}
|
||||
router = research_routes.setup_research_routes(handler)
|
||||
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
|
||||
|
||||
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
|
||||
|
||||
assert out == {"status": "none"}
|
||||
|
||||
|
||||
def test_research_status_returns_status_when_present(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
|
||||
handler = MagicMock()
|
||||
handler._active_tasks = {"sess-abc": {"owner": "alice"}}
|
||||
handler.get_status.return_value = {"status": "running", "progress": {"phase": "searching"}}
|
||||
handler.get_source_state.return_value = None
|
||||
handler.get_source_coverage.return_value = None
|
||||
handler.get_navigation_trace.return_value = None
|
||||
handler.get_action_trace.return_value = None
|
||||
router = research_routes.setup_research_routes(handler)
|
||||
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
|
||||
|
||||
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
|
||||
|
||||
assert out["status"] == "running"
|
||||
|
||||
|
||||
# --- /api/chat/stream_status/<id> ----------------------------------------
|
||||
|
||||
def _chat_stream_status_endpoint(monkeypatch):
|
||||
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda request, session_id: None)
|
||||
router = chat_routes.setup_chat_routes(
|
||||
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
||||
)
|
||||
return _route_endpoint(router, "/api/chat/stream_status/{session_id}")
|
||||
|
||||
|
||||
def test_stream_status_returns_200_idle_when_no_stream(monkeypatch):
|
||||
endpoint = _chat_stream_status_endpoint(monkeypatch)
|
||||
chat_routes._active_streams.pop("sess-idle", None)
|
||||
|
||||
out = asyncio.run(endpoint(SimpleNamespace(), "sess-idle"))
|
||||
|
||||
assert out == {"status": "idle"}
|
||||
|
||||
|
||||
def test_stream_status_returns_record_when_streaming(monkeypatch):
|
||||
endpoint = _chat_stream_status_endpoint(monkeypatch)
|
||||
chat_routes._active_streams["sess-live"] = {"status": "streaming", "mode": "agent"}
|
||||
try:
|
||||
out = asyncio.run(endpoint(SimpleNamespace(), "sess-live"))
|
||||
finally:
|
||||
chat_routes._active_streams.pop("sess-live", None)
|
||||
|
||||
assert out["status"] == "streaming"
|
||||
|
||||
|
||||
# --- client compatibility (source-level) ---------------------------------
|
||||
|
||||
def test_chat_watchdog_accepts_200_idle_body():
|
||||
src = inspect.getsource(chat_routes) # ensure chat_routes imported
|
||||
chatjs = open("static/js/chat.js", encoding="utf-8").read()
|
||||
# The stale-stream watchdog must key off the body status, not only a 404.
|
||||
assert "_noActiveStream" in chatjs
|
||||
assert "_probe.status !== 'streaming'" in chatjs
|
||||
assert src # keep flake happy
|
||||
|
||||
|
||||
def test_sessions_check_uses_body_status_not_ok_only():
|
||||
sj = open("static/js/sessions.js", encoding="utf-8").read()
|
||||
assert "info.status !== 'streaming'" in sj
|
||||
assert "status !== 'streaming'" in sj
|
||||
@@ -0,0 +1,243 @@
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from src import clean_agent_preview as runner
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.turn_contract import (
|
||||
requested_capabilities,
|
||||
resolve_turn_contract,
|
||||
selected_tools_for_request,
|
||||
)
|
||||
|
||||
PROMPT = 'Search the web for the Ajax population.'
|
||||
|
||||
|
||||
def _contract(prompt=PROMPT):
|
||||
policy = ToolPolicy()
|
||||
selected = selected_tools_for_request(prompt)
|
||||
return resolve_turn_contract(
|
||||
capabilities=requested_capabilities(prompt),
|
||||
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||
policy=policy,
|
||||
selected_tools=selected,
|
||||
message=prompt,
|
||||
), policy
|
||||
|
||||
|
||||
def _install_spec_router(monkeypatch, *, p):
|
||||
fake = types.ModuleType('src.spec_router')
|
||||
fake.LAST_DECISIONS = [
|
||||
{'message': PROMPT, 'choice': 'answer_directly', 'answer_directly': p},
|
||||
]
|
||||
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _fake_response(captured):
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def aiter_lines(self):
|
||||
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': 'Hi there.'}}]})
|
||||
yield 'data: [DONE]'
|
||||
|
||||
@asynccontextmanager
|
||||
async def response(*args, **kwargs):
|
||||
request = args[3] if len(args) > 3 else kwargs.get('request')
|
||||
captured.append(request)
|
||||
yield Response()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def _run(monkeypatch, captured):
|
||||
monkeypatch.setattr(runner, 'preview_model_response', _fake_response(captured))
|
||||
contract, policy = _contract()
|
||||
events = []
|
||||
async for chunk in runner.stream_preview(
|
||||
endpoint_url='http://fixture', model='Ajax', headers={},
|
||||
messages=[{'role': 'user', 'content': PROMPT}], turn_contract=contract,
|
||||
session_id='spec-direct', owner='fixture', disabled_tools=set(),
|
||||
tool_policy=policy, thinking_mode='off', max_rounds=1,
|
||||
):
|
||||
if chunk.startswith('data: ') and '[DONE]' not in chunk:
|
||||
events.append(json.loads(chunk[6:]))
|
||||
return contract, events
|
||||
|
||||
|
||||
def _sent_tools(captured):
|
||||
first = captured[0]
|
||||
return {(s.get('function') or {}).get('name') for s in (first.get('tools') or [])}
|
||||
|
||||
|
||||
async def _control_tools(monkeypatch):
|
||||
"""Tools offered for the identical request with the spec feature disabled.
|
||||
|
||||
Upstream's harness applies its own round-1 tool policy (bounded research,
|
||||
permitted-schema filtering), so the spec feature is only required to leave
|
||||
that list untouched rather than match the raw contract.offered set.
|
||||
"""
|
||||
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
|
||||
control = []
|
||||
await _run(monkeypatch, control)
|
||||
assert control
|
||||
return _sent_tools(control)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_direct_round1_forces_tool_choice_none_with_same_tools(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
_install_spec_router(monkeypatch, p=0.971)
|
||||
captured = []
|
||||
contract, _events = await _run(monkeypatch, captured)
|
||||
|
||||
assert captured, 'stream_preview made a provider round'
|
||||
first = captured[0]
|
||||
assert first.get('tool_choice') == 'none'
|
||||
sent = _sent_tools(captured)
|
||||
assert sent == await _control_tools(monkeypatch) # tools list unchanged by spec
|
||||
assert sent # the offered set is non-empty here
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_direct_low_confidence_is_unchanged(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
_install_spec_router(monkeypatch, p=0.5)
|
||||
captured = []
|
||||
await _run(monkeypatch, captured)
|
||||
assert captured
|
||||
assert captured[0].get('tool_choice') != 'none'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_direct_env_off_is_identical(monkeypatch):
|
||||
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
|
||||
_install_spec_router(monkeypatch, p=0.971)
|
||||
captured = []
|
||||
await _run(monkeypatch, captured)
|
||||
assert captured
|
||||
assert captured[0].get('tool_choice') != 'none'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_direct_router_error_is_unchanged(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
|
||||
class _Boom:
|
||||
@property
|
||||
def LAST_DECISIONS(self):
|
||||
raise RuntimeError('router down')
|
||||
monkeypatch.setitem(sys.modules, 'src.spec_router', _Boom())
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False,
|
||||
)
|
||||
captured = []
|
||||
await _run(monkeypatch, captured)
|
||||
assert captured
|
||||
assert captured[0].get('tool_choice') != 'none'
|
||||
|
||||
|
||||
def _fake_response_with_text(captured, text):
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def aiter_lines(self):
|
||||
yield 'data: ' + json.dumps(
|
||||
{'choices': [{'delta': {'content': text}}]})
|
||||
yield 'data: [DONE]'
|
||||
|
||||
@asynccontextmanager
|
||||
async def response(*args, **kwargs):
|
||||
request = args[3] if len(args) > 3 else kwargs.get('request')
|
||||
captured.append(request)
|
||||
yield Response()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def test_spec_direct_mode_helper_defaults_to_tool_choice_none(monkeypatch):
|
||||
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MODE', raising=False)
|
||||
assert runner.spec_direct_mode() == 'tool_choice_none'
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'empty_menu')
|
||||
assert runner.spec_direct_mode() == 'empty_menu'
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'nonsense')
|
||||
assert runner.spec_direct_mode() == 'tool_choice_none'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_direct_empty_menu_sends_no_tools_and_thinking_off(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'empty_menu')
|
||||
_install_spec_router(monkeypatch, p=0.971)
|
||||
captured = []
|
||||
await _run(monkeypatch, captured)
|
||||
|
||||
assert captured, 'stream_preview made a provider round'
|
||||
first = captured[0]
|
||||
assert not (first.get('tools') or []) # no tools offered at all
|
||||
assert first.get('tool_choice') in (None, 'none')
|
||||
# Think router did not decide -> thinking stays off for the round.
|
||||
assert first.get('chat_template_kwargs', {}).get('enable_thinking') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_direct_default_mode_keeps_tool_choice_none(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'tool_choice_none')
|
||||
_install_spec_router(monkeypatch, p=0.971)
|
||||
captured = []
|
||||
contract, _events = await _run(monkeypatch, captured)
|
||||
|
||||
assert captured
|
||||
first = captured[0]
|
||||
assert first.get('tool_choice') == 'none'
|
||||
sent = _sent_tools(captured)
|
||||
assert sent == await _control_tools(monkeypatch) # tools list unchanged by spec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_direct_empty_menu_does_not_execute_text_tool_call(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MODE', 'empty_menu')
|
||||
_install_spec_router(monkeypatch, p=0.971)
|
||||
|
||||
executed = []
|
||||
|
||||
async def _recorder(*args, **kwargs):
|
||||
executed.append(args)
|
||||
return ('', {'exit_code': 0})
|
||||
monkeypatch.setattr(runner, 'execute_tool_block', _recorder, raising=False)
|
||||
|
||||
markup = ('Sure thing. '
|
||||
'<tool_call>{"name": "web_search", "arguments": {"query": "Ajax"}}'
|
||||
'</tool_call> Anything else?')
|
||||
monkeypatch.setattr(
|
||||
runner, 'preview_model_response', _fake_response_with_text([], markup))
|
||||
|
||||
contract, policy = _contract()
|
||||
events = []
|
||||
async for chunk in runner.stream_preview(
|
||||
endpoint_url='http://fixture', model='Ajax', headers={},
|
||||
messages=[{'role': 'user', 'content': PROMPT}], turn_contract=contract,
|
||||
session_id='spec-direct-empty', owner='fixture', disabled_tools=set(),
|
||||
tool_policy=policy, thinking_mode='off', max_rounds=1,
|
||||
):
|
||||
if chunk.startswith('data: ') and '[DONE]' not in chunk:
|
||||
events.append(json.loads(chunk[6:]))
|
||||
|
||||
assert executed == [] # nothing executed: nothing was offered
|
||||
visible = ''.join(
|
||||
str(e.get('content') or e.get('delta') or '') for e in events)
|
||||
assert '<tool_call' not in visible # markup stripped from the answer
|
||||
assert 'web_search' not in visible
|
||||
@@ -0,0 +1,334 @@
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src import clean_agent_preview as runner
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.turn_contract import (
|
||||
requested_capabilities,
|
||||
resolve_turn_contract,
|
||||
selected_tools_for_request,
|
||||
)
|
||||
|
||||
PROMPT = 'Search the web for the Ajax population.'
|
||||
|
||||
|
||||
def _contract(prompt=PROMPT):
|
||||
policy = ToolPolicy()
|
||||
selected = selected_tools_for_request(prompt)
|
||||
return resolve_turn_contract(
|
||||
capabilities=requested_capabilities(prompt),
|
||||
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||
policy=policy,
|
||||
selected_tools=selected,
|
||||
message=prompt,
|
||||
), policy
|
||||
|
||||
|
||||
def _install_driver_router(monkeypatch, decision, picks=None):
|
||||
"""Fake src.spec_router exposing spec_route_tools + LAST_DECISIONS."""
|
||||
fake = types.ModuleType('src.spec_router')
|
||||
fake.think_router_enabled = lambda: False
|
||||
fake.LAST_DECISIONS = [decision]
|
||||
|
||||
def _spec_route_tools(user_message, schemas, disabled_tools=None,
|
||||
messages=None, return_decision=False):
|
||||
if picks is not None:
|
||||
chosen = picks
|
||||
elif decision.get('tool_name'):
|
||||
chosen = [decision['tool_name']]
|
||||
else:
|
||||
chosen = list(decision.get('tools') or [])
|
||||
return (chosen, True) if return_decision else chosen
|
||||
|
||||
fake.spec_route_tools = _spec_route_tools
|
||||
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _fake_response(captured, scripts):
|
||||
pending = list(scripts)
|
||||
|
||||
class Response:
|
||||
def __init__(self, script=None):
|
||||
self._s = script or {}
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def aiter_lines(self):
|
||||
calls = self._s.get('tool_calls')
|
||||
if calls:
|
||||
for i, c in enumerate(calls):
|
||||
yield 'data: ' + json.dumps({'choices': [{'delta': {'tool_calls': [
|
||||
{'index': i, 'id': f'c{i}', 'type': 'function',
|
||||
'function': {'name': c['name'],
|
||||
'arguments': c.get('arguments') or '{}'}}]}}]})
|
||||
else:
|
||||
content = self._s.get('content', 'Hi.')
|
||||
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': content}}]})
|
||||
yield 'data: [DONE]'
|
||||
|
||||
@asynccontextmanager
|
||||
async def response(*args, **kwargs):
|
||||
captured.append(args[3] if len(args) > 3 else kwargs.get('request'))
|
||||
yield Response(pending.pop(0) if pending else None)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def _run(monkeypatch, captured, model='odysseus-agentic-v4', scripts=None,
|
||||
max_rounds=1):
|
||||
monkeypatch.setattr(
|
||||
runner, 'preview_model_response', _fake_response(captured, scripts or []))
|
||||
contract, policy = _contract()
|
||||
events = []
|
||||
async for chunk in runner.stream_preview(
|
||||
endpoint_url='http://fixture', model=model, headers={},
|
||||
messages=[{'role': 'user', 'content': PROMPT}], turn_contract=contract,
|
||||
session_id='spec-driver', owner='fixture', disabled_tools=set(),
|
||||
tool_policy=policy, thinking_mode='off', max_rounds=max_rounds,
|
||||
):
|
||||
if chunk.startswith('data: ') and '[DONE]' not in chunk:
|
||||
events.append(json.loads(chunk[6:]))
|
||||
return contract, events
|
||||
|
||||
|
||||
def _install_recorder(monkeypatch):
|
||||
executed = []
|
||||
|
||||
async def _rec(block, *args, **kwargs):
|
||||
executed.append(getattr(block, 'tool_type', None))
|
||||
return 'ok', {'exit_code': 0, 'output': 'ok'}
|
||||
|
||||
monkeypatch.setattr(runner, 'execute_tool_block', _rec)
|
||||
return executed
|
||||
|
||||
|
||||
def test_spec_driver_active_gate(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False)
|
||||
assert runner.spec_driver_active('http://x', 'm', 'o', {}) is True
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'strict')
|
||||
assert runner.spec_driver_active('http://x', 'm', 'o', {}) is False
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'compact', raising=False)
|
||||
assert runner.spec_driver_active('http://x', 'm', 'o', {}) is False
|
||||
|
||||
|
||||
def test_spec_driver_pick_shapes(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
# tool pick at any confidence
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 't3', 'tools': ['web_fetch'],
|
||||
'top': [('web_fetch', 0.12)], 'answer_directly': 0.2, 'ask_user': 0.1,
|
||||
})
|
||||
pick = runner.spec_driver_pick([{'role': 'user', 'content': PROMPT}], set(), PROMPT)
|
||||
assert pick == {'choice': 'tool', 'tool_name': 'web_fetch', 'p': 0.12}
|
||||
|
||||
# tool pick below MIN_CONF: the router return is empty but top-1 is a tool
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 't5', 'tools': [],
|
||||
'top': [('web_fetch', 0.12)], 'answer_directly': 0.2, 'ask_user': 0.1,
|
||||
}, picks=[])
|
||||
pick = runner.spec_driver_pick([], set(), PROMPT)
|
||||
assert pick == {'choice': 'tool', 'tool_name': 'web_fetch', 'p': 0.12}
|
||||
|
||||
# answer_directly
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 'answer_directly', 'tools': [],
|
||||
'top': [('answer_directly', 0.4)], 'answer_directly': 0.4, 'ask_user': 0.1,
|
||||
})
|
||||
pick = runner.spec_driver_pick([], set(), PROMPT)
|
||||
assert pick['choice'] == 'answer_directly' and pick['p'] == 0.4
|
||||
|
||||
# ask_user
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 'ask_user', 'tools': [],
|
||||
'top': [('ask_user', 0.3)], 'answer_directly': 0.1, 'ask_user': 0.3,
|
||||
})
|
||||
pick = runner.spec_driver_pick([], set(), PROMPT)
|
||||
assert pick['choice'] == 'ask_user'
|
||||
|
||||
# router error / empty decision -> fall back to the normal menu
|
||||
_install_driver_router(monkeypatch, {'message': PROMPT, 'error': 'timeout'})
|
||||
assert runner.spec_driver_pick([], set(), PROMPT) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_driver_tool_pick_sends_only_that_tool(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 't1', 'tools': ['web_fetch'],
|
||||
'top': [('web_fetch', 0.11)], 'answer_directly': 0.2, 'ask_user': 0.1,
|
||||
}, picks=['web_fetch'])
|
||||
captured = []
|
||||
await _run(monkeypatch, captured)
|
||||
|
||||
assert captured, 'stream_preview made a provider round'
|
||||
first = captured[0]
|
||||
names = [(s.get('function') or {}).get('name') for s in (first.get('tools') or [])]
|
||||
assert names == ['web_fetch']
|
||||
assert first.get('tool_choice') == {
|
||||
'type': 'function', 'function': {'name': 'web_fetch'}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_driver_answer_directly_omits_tools(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 'answer_directly', 'tools': [],
|
||||
'top': [('answer_directly', 0.35)], 'answer_directly': 0.35, 'ask_user': 0.05,
|
||||
}, picks=[])
|
||||
captured = []
|
||||
await _run(monkeypatch, captured)
|
||||
|
||||
assert captured
|
||||
assert 'tools' not in captured[0] # vLLM rejects an empty array
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_driver_router_error_keeps_the_normal_menu(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': None, 'tools': [],
|
||||
'top': [], 'answer_directly': 0.0, 'ask_user': 0.0, 'error': 'boom',
|
||||
}, picks=[])
|
||||
captured = []
|
||||
contract, _events = await _run(monkeypatch, captured)
|
||||
|
||||
assert captured
|
||||
sent = {(s.get('function') or {}).get('name')
|
||||
for s in (captured[0].get('tools') or [])}
|
||||
assert sent == set(contract.offered) # unchanged normal menu
|
||||
assert sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_driver_pick_uses_the_full_spec_surface(monkeypatch):
|
||||
"""m3518: Spec picks from the FULL surface (not the compact round menu) and
|
||||
the pick is admitted/executable that round (sent as the single tool)."""
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 't1', 'tools': ['manage_calendar'],
|
||||
'top': [('manage_calendar', 0.9)], 'answer_directly': 0.05, 'ask_user': 0.05,
|
||||
}, picks=['manage_calendar'])
|
||||
captured = []
|
||||
await _run(monkeypatch, captured)
|
||||
|
||||
assert captured
|
||||
names = [(s.get('function') or {}).get('name')
|
||||
for s in (captured[0].get('tools') or [])]
|
||||
assert names == ['manage_calendar']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_driver_pick_not_on_the_surface_keeps_the_normal_menu(monkeypatch):
|
||||
"""Only a pick that is truly not executable falls back for the round."""
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 't1', 'tools': ['totally_missing_tool'],
|
||||
'top': [('totally_missing_tool', 0.9)], 'answer_directly': 0.05, 'ask_user': 0.05,
|
||||
}, picks=['totally_missing_tool'])
|
||||
captured = []
|
||||
contract, _events = await _run(monkeypatch, captured)
|
||||
|
||||
assert captured
|
||||
sent = {(s.get('function') or {}).get('name')
|
||||
for s in (captured[0].get('tools') or [])}
|
||||
assert 'totally_missing_tool' not in sent
|
||||
assert sent == set(contract.offered) # normal menu retained
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_driver_drops_off_pick_native_call(monkeypatch):
|
||||
"""m3517: execute ONLY the Spec pick; drop any other native call."""
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 't1', 'tools': ['web_fetch'],
|
||||
'top': [('web_fetch', 0.7)], 'answer_directly': 0.1, 'ask_user': 0.1,
|
||||
}, picks=['web_fetch'])
|
||||
executed = _install_recorder(monkeypatch)
|
||||
scripts = [
|
||||
{'tool_calls': [
|
||||
{'name': 'web_search', 'arguments': '{"query": "x"}'},
|
||||
{'name': 'web_fetch', 'arguments': '{"url": "http://x"}'},
|
||||
]},
|
||||
{'content': 'done'},
|
||||
]
|
||||
captured = []
|
||||
await _run(monkeypatch, captured, scripts=scripts, max_rounds=2)
|
||||
|
||||
assert executed == ['web_fetch'], executed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_driver_answer_directly_strips_markup_and_executes_nothing(monkeypatch):
|
||||
monkeypatch.setenv('ODYSSEUS_SPEC_MODE', 'driver')
|
||||
_install_driver_router(monkeypatch, {
|
||||
'message': PROMPT, 'choice': 'answer_directly', 'tools': [],
|
||||
'top': [('answer_directly', 0.9)], 'answer_directly': 0.9, 'ask_user': 0.05,
|
||||
}, picks=[])
|
||||
executed = _install_recorder(monkeypatch)
|
||||
scripts = [{
|
||||
'content': 'Hello <tool_call>{"name": "web_search", "arguments": {}}</tool_call> world',
|
||||
}]
|
||||
captured = []
|
||||
_contract_, events = await _run(monkeypatch, captured, scripts=scripts)
|
||||
|
||||
assert executed == []
|
||||
finals = [e.get('content', '') for e in events if e.get('type') == 'final_response']
|
||||
assert finals, events
|
||||
assert 'Hello' in finals[-1] and '<tool_call' not in finals[-1]
|
||||
|
||||
|
||||
def test_spec_driver_wired_in_stream_preview():
|
||||
src = Path(runner.__file__).read_text()
|
||||
assert '[spec-driver] round=' in src
|
||||
assert 'spec_driver_pick(' in src
|
||||
assert '[spec-driver] dropped off-pick call name=' in src
|
||||
|
||||
|
||||
def test_spec_contract_offered_extends_the_model_runtime(monkeypatch):
|
||||
"""A Spec pick outside PREVIEW_TOOLS must still be runnable: on a
|
||||
spec-router turn the contract's offered set is part of the runtime."""
|
||||
import types
|
||||
from src import turn_contract as tc
|
||||
from src.clean_agent_preview import PREVIEW_TOOLS, evaluate_preview_call
|
||||
|
||||
assert 'get_weather' not in PREVIEW_TOOLS
|
||||
|
||||
class _C:
|
||||
routing_experiment = 'spec-router'
|
||||
offered = frozenset({'get_weather'})
|
||||
|
||||
monkeypatch.setattr(tc, 'active_turn_contract', lambda: _C())
|
||||
allowed = evaluate_preview_call(
|
||||
'get_weather', {'location': 'Tokyo'}, 'weather',
|
||||
turn_authorized_families=frozenset(),
|
||||
)
|
||||
assert allowed.allowed, allowed
|
||||
|
||||
class _Plain:
|
||||
routing_experiment = 'baseline'
|
||||
offered = frozenset({'get_weather'})
|
||||
|
||||
monkeypatch.setattr(tc, 'active_turn_contract', lambda: _Plain())
|
||||
denied = evaluate_preview_call(
|
||||
'get_weather', {'location': 'Tokyo'}, 'weather',
|
||||
turn_authorized_families=frozenset(),
|
||||
)
|
||||
assert not denied.allowed
|
||||
assert denied.reason == 'tool_not_in_model_runtime'
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The Spec router may select a tool the request policy has withheld."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from src import agent_loop
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.turn_contract import resolve_turn_contract
|
||||
|
||||
|
||||
PROMPT = "open twitter and see what elon tweeted last"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_active_contract():
|
||||
from src.turn_contract import _ACTIVE_CONTRACT
|
||||
token = _ACTIVE_CONTRACT.set(None)
|
||||
yield
|
||||
_ACTIVE_CONTRACT.reset(token)
|
||||
|
||||
|
||||
def _route(monkeypatch, disabled, *, subset="1", base_browser=True, hidden=(),
|
||||
mode="strict", inherited_unavailable=()):
|
||||
fake = types.ModuleType("src.spec_router")
|
||||
fake.LAST_DECISIONS = [{
|
||||
"choice": "t62", "top": [("private_browser", 0.859)],
|
||||
}]
|
||||
fake.spec_route_tools = lambda *a, **kw: ({"private_browser"}, True)
|
||||
monkeypatch.setitem(sys.modules, "src.spec_router", fake)
|
||||
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: "spec")
|
||||
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
|
||||
monkeypatch.setenv("ODYSSEUS_SPEC_MODE", mode)
|
||||
monkeypatch.setenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", subset)
|
||||
|
||||
# Full schema inventory and an actual request policy are present. The
|
||||
# positive case can execute the browser; the negative case cannot.
|
||||
policy = ToolPolicy(disabled_tools=frozenset(disabled), hidden_tools=frozenset(hidden))
|
||||
contract = resolve_turn_contract(
|
||||
capabilities={"search_browser"}, required_capabilities=set(),
|
||||
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||
policy=policy,
|
||||
selected_tools={"private_browser"} if base_browser else {"ask_user"},
|
||||
message=PROMPT,
|
||||
)
|
||||
if inherited_unavailable:
|
||||
contract = replace(contract, unavailable=frozenset(inherited_unavailable))
|
||||
return agent_loop._spec_router_contract(
|
||||
contract, [{"role": "user", "content": PROMPT}],
|
||||
set(disabled), "http://fixture", "Ajax", "fixture", {}, tool_policy=policy,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subset", ["0", "1"])
|
||||
def test_spec_browser_withheld_by_web_policy_never_reaches_model(monkeypatch, subset):
|
||||
routed = _route(monkeypatch, {"private_browser"}, subset=subset)
|
||||
assert routed.routing_experiment == "spec-policy-denied"
|
||||
assert routed.unavailable == frozenset({"private_browser"})
|
||||
assert routed.offered == frozenset()
|
||||
assert routed.schema_json == ()
|
||||
assert "Enable Web search" in agent_loop._spec_unavailable_reply(routed.unavailable)
|
||||
assert "have not opened" in agent_loop._spec_unavailable_reply(routed.unavailable)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spec_unavailable_contract_finishes_before_provider_call(monkeypatch):
|
||||
routed = _route(monkeypatch, {"private_browser"})
|
||||
|
||||
async def unexpected_provider_path(*args, **kwargs):
|
||||
raise AssertionError("the provider/runtime must not see a denied tool")
|
||||
yield # pragma: no cover
|
||||
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", unexpected_provider_path)
|
||||
events = []
|
||||
async for chunk in agent_loop.stream_agent_loop(
|
||||
endpoint_url="http://fixture", model="Ajax",
|
||||
messages=[{"role": "user", "content": PROMPT}],
|
||||
turn_contract=routed, disabled_tools={"private_browser"},
|
||||
tool_policy=ToolPolicy(disabled_tools=frozenset({"private_browser"})),
|
||||
_is_teacher_run=True,
|
||||
):
|
||||
events.append(chunk)
|
||||
parsed = [json.loads(chunk[6:]) for chunk in events if chunk.startswith("data: {")]
|
||||
assert any(event.get("type") == "turn_contract" for event in parsed)
|
||||
assert any("Enable Web search" in event.get("delta", "") for event in parsed)
|
||||
assert not any(event.get("type") == "tool_start" for event in parsed)
|
||||
assert events[-1] == "data: [DONE]\n\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subset,base_browser", [("0", False), ("0", True), ("1", True)])
|
||||
def test_spec_browser_allowed_keeps_executable_tool(monkeypatch, subset, base_browser):
|
||||
routed = _route(monkeypatch, set(), subset=subset, base_browser=base_browser)
|
||||
assert routed.routing_experiment == "spec-router"
|
||||
assert not routed.unavailable
|
||||
assert "private_browser" in routed.offered
|
||||
assert "private_browser" in routed.executable
|
||||
assert any(json.loads(schema)["function"]["name"] == "private_browser"
|
||||
for schema in routed.schema_json)
|
||||
|
||||
|
||||
def test_subset_control_does_not_confuse_base_omission_with_policy_denial(monkeypatch):
|
||||
routed = _route(monkeypatch, set(), subset="1", base_browser=False)
|
||||
assert not routed.unavailable
|
||||
assert "private_browser" not in routed.offered
|
||||
assert "private_browser" in routed.executable
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_strict_inherited_unavailable_continues_to_preview(monkeypatch):
|
||||
# Non-strict Spec preserves an unrelated base-contract unavailable value.
|
||||
# That value must not be mistaken for our newly sealed policy conflict.
|
||||
inherited = _route(monkeypatch, set(), subset="0", mode="additive",
|
||||
inherited_unavailable={"manage_notes"})
|
||||
assert inherited.routing_experiment == "spec-router"
|
||||
assert inherited.unavailable == frozenset({"manage_notes"})
|
||||
|
||||
async def fake_preview(**kwargs):
|
||||
yield 'data: {"delta": "preview continued"}\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
|
||||
monkeypatch.setattr("src.clean_agent_preview.stream_preview", fake_preview)
|
||||
monkeypatch.setattr(agent_loop, "is_compact_preview_contract", lambda contract: True)
|
||||
events = [chunk async for chunk in agent_loop.stream_agent_loop(
|
||||
endpoint_url="http://fixture", model="Ajax",
|
||||
messages=[{"role": "user", "content": PROMPT}],
|
||||
turn_contract=inherited, disabled_tools=set(), tool_policy=ToolPolicy(),
|
||||
_is_teacher_run=True,
|
||||
)]
|
||||
assert any("preview continued" in chunk for chunk in events)
|
||||
assert not any("Enable Web search" in chunk for chunk in events)
|
||||
|
||||
|
||||
def test_policy_hidden_browser_is_denied_even_without_disabled_snapshot(monkeypatch):
|
||||
routed = _route(monkeypatch, set(), subset="0", hidden={"private_browser"})
|
||||
assert routed.unavailable == frozenset({"private_browser"})
|
||||
assert not routed.offered
|
||||
|
||||
|
||||
def test_guide_only_policy_keeps_its_existing_control_path(monkeypatch):
|
||||
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: "spec")
|
||||
contract = resolve_turn_contract(
|
||||
capabilities=set(), required_capabilities=set(), schemas=FUNCTION_TOOL_SCHEMAS,
|
||||
policy=ToolPolicy(), message="guide me only",
|
||||
)
|
||||
routed = agent_loop._spec_router_contract(
|
||||
contract, [{"role": "user", "content": "guide me only"}],
|
||||
set(), "http://fixture", "Ajax", "fixture", {},
|
||||
tool_policy=ToolPolicy(block_all_tool_calls=True, mode="guide_only"),
|
||||
)
|
||||
assert routed is contract
|
||||
|
||||
|
||||
def test_spec_direct_answer_is_not_blocked_by_unavailable_tool_policy(monkeypatch):
|
||||
fake = types.ModuleType("src.spec_router")
|
||||
fake.LAST_DECISIONS = [{"choice": "answer_directly", "top": []}]
|
||||
fake.spec_route_tools = lambda *a, **kw: (set(), True)
|
||||
monkeypatch.setitem(sys.modules, "src.spec_router", fake)
|
||||
monkeypatch.setattr(agent_loop, "_configured_model_tool_surface_raw", lambda *a, **kw: "spec")
|
||||
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
|
||||
monkeypatch.setenv("ODYSSEUS_SPEC_MODE", "strict")
|
||||
contract = resolve_turn_contract(
|
||||
capabilities=set(), required_capabilities=set(),
|
||||
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||
policy=ToolPolicy(disabled_tools=frozenset({"private_browser"})),
|
||||
message="hello",
|
||||
)
|
||||
routed = agent_loop._spec_router_contract(
|
||||
contract, [{"role": "user", "content": "hello"}],
|
||||
{"private_browser"}, "http://fixture", "Ajax", "fixture", {},
|
||||
)
|
||||
assert "private_browser" not in routed.unavailable
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Health audit: SSE client disconnects (e.g. /api/editor/bridge/events) surface
|
||||
as anyio.EndOfStream through Starlette's BaseHTTPMiddleware. The slow-request
|
||||
middleware must map that to a client-side 499, not a logged 500 traceback."""
|
||||
import asyncio
|
||||
|
||||
import anyio
|
||||
from starlette.requests import Request
|
||||
|
||||
from app import _SlowRequestLogMiddleware
|
||||
|
||||
|
||||
def _request(path):
|
||||
return Request({"type": "http", "method": "GET", "path": path, "headers": []})
|
||||
|
||||
|
||||
def test_stream_disconnect_is_mapped_to_499():
|
||||
async def call_next(_request):
|
||||
raise anyio.EndOfStream
|
||||
|
||||
mw = _SlowRequestLogMiddleware(lambda *a: None)
|
||||
resp = asyncio.run(mw.dispatch(_request("/api/editor/bridge/events"), call_next))
|
||||
assert resp.status_code == 499
|
||||
|
||||
|
||||
def test_real_responses_pass_through():
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
async def call_next(_request):
|
||||
return JSONResponse({"ok": True}, status_code=200)
|
||||
|
||||
mw = _SlowRequestLogMiddleware(lambda *a: None)
|
||||
resp = asyncio.run(mw.dispatch(_request("/api/health"), call_next))
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Inline-image vision path: direct image_url parts, downscale, probe, trimming.
|
||||
|
||||
Covers the change that sends attached images straight to the chat model as an
|
||||
OpenAI image_url content part (instead of a separate vision-describe pre-step)
|
||||
when the endpoint/model accepts images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src import chat_helpers as ch
|
||||
from src import document_processor as dp
|
||||
from src.context_compactor import keep_recent_image_turns
|
||||
|
||||
|
||||
def _png_bytes(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (width, height), (200, 40, 40)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# --- vision detection -------------------------------------------------------
|
||||
|
||||
def test_vision_model_env_override(monkeypatch):
|
||||
monkeypatch.delenv("ODYSSEUS_VISION_MODELS", raising=False)
|
||||
assert ch.is_vision_model("ajax-ablated500-base") is False
|
||||
monkeypatch.setenv("ODYSSEUS_VISION_MODELS", "ajax, some-other")
|
||||
assert ch.is_vision_model("ajax-ablated500-base") is True
|
||||
assert ch.is_vision_model("SOME-OTHER-9B") is True
|
||||
assert ch.is_vision_model("plain-text-model") is False
|
||||
|
||||
|
||||
def test_probe_endpoint_supports_vision_cached(monkeypatch):
|
||||
ch._vision_probe_cache.clear()
|
||||
calls = []
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status, text=""):
|
||||
self.status_code = status
|
||||
self.text = text
|
||||
|
||||
def _fake_post(url, json=None, timeout=None):
|
||||
calls.append(url)
|
||||
return _Resp(200, "")
|
||||
|
||||
monkeypatch.setattr(ch.httpx, "post", _fake_post)
|
||||
assert ch.probe_endpoint_supports_vision("http://127.0.0.1:18607/v1", "ajax-ablated500-base") is True
|
||||
# Second call is served from the cache (no extra POST).
|
||||
assert ch.probe_endpoint_supports_vision("http://127.0.0.1:18607/v1", "ajax-ablated500-base") is True
|
||||
assert len(calls) == 1
|
||||
assert calls[0].endswith("/v1/chat/completions")
|
||||
|
||||
|
||||
def test_probe_endpoint_rejects_image(monkeypatch):
|
||||
ch._vision_probe_cache.clear()
|
||||
|
||||
class _Resp:
|
||||
status_code = 400
|
||||
text = '{"error":"this model does not support image content"}'
|
||||
|
||||
monkeypatch.setattr(ch.httpx, "post", lambda *a, **k: _Resp())
|
||||
assert ch.probe_endpoint_supports_vision("http://127.0.0.1:18607/v1", "text-only-9b") is False
|
||||
|
||||
|
||||
def test_probe_never_probes_remote_hosts(monkeypatch):
|
||||
ch._vision_probe_cache.clear()
|
||||
calls = []
|
||||
monkeypatch.setattr(ch.httpx, "post", lambda *a, **k: calls.append(a) or None)
|
||||
assert ch.probe_endpoint_supports_vision("https://api.openai.com/v1", "gpt-x") is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_model_supports_vision_uses_probe_for_local(monkeypatch):
|
||||
monkeypatch.delenv("ODYSSEUS_VISION_MODELS", raising=False)
|
||||
monkeypatch.setattr(ch, "lmstudio_supports_vision", lambda *a, **k: None)
|
||||
monkeypatch.setattr(ch, "probe_endpoint_supports_vision", lambda url, model: True)
|
||||
assert ch.model_supports_vision("ajax-ablated500-base", "http://127.0.0.1:18607/v1") is True
|
||||
|
||||
|
||||
# --- downscale --------------------------------------------------------------
|
||||
|
||||
def test_encode_image_data_url_downscales_to_jpeg(tmp_path):
|
||||
path = tmp_path / "big.png"
|
||||
path.write_bytes(_png_bytes(2400, 600))
|
||||
data_url, fmt = dp.encode_image_data_url(str(path), "png")
|
||||
assert fmt == "jpeg"
|
||||
assert data_url.startswith("data:image/jpeg;base64,")
|
||||
raw = base64.b64decode(data_url.split(",", 1)[1])
|
||||
with Image.open(io.BytesIO(raw)) as img:
|
||||
assert max(img.size) <= 1280
|
||||
# aspect ratio preserved
|
||||
assert round(img.size[0] / img.size[1], 2) == 4.0
|
||||
|
||||
|
||||
def test_encode_image_data_url_small_image_ok(tmp_path):
|
||||
path = tmp_path / "small.png"
|
||||
path.write_bytes(_png_bytes(64, 48))
|
||||
data_url, fmt = dp.encode_image_data_url(str(path), "png")
|
||||
assert fmt == "jpeg"
|
||||
raw = base64.b64decode(data_url.split(",", 1)[1])
|
||||
with Image.open(io.BytesIO(raw)) as img:
|
||||
assert img.size == (64, 48)
|
||||
|
||||
|
||||
# --- keep last N image turns ------------------------------------------------
|
||||
|
||||
def _user_with_image(caption: str, text: str) -> dict:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": text},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
],
|
||||
"_caption": caption,
|
||||
}
|
||||
|
||||
|
||||
def test_keep_recent_image_turns_labels_old_images():
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
_user_with_image("a.png", "[Image attached: a.png]\nlook at a"),
|
||||
{"role": "assistant", "content": "sure"},
|
||||
_user_with_image("b.png", "[Image attached: b.png]\nlook at b"),
|
||||
_user_with_image("c.png", "[Image attached: c.png]\nlook at c"),
|
||||
]
|
||||
out = keep_recent_image_turns(msgs, keep=2)
|
||||
|
||||
def _img_count(m):
|
||||
c = m.get("content")
|
||||
return sum(1 for b in c if isinstance(b, dict) and b.get("type") == "image_url") if isinstance(c, list) else 0
|
||||
|
||||
# first (oldest) image turn lost its image part, gained the label
|
||||
assert _img_count(out[1]) == 0
|
||||
assert "[Image: a.png]" in "".join(
|
||||
b.get("text", "") for b in out[1]["content"] if isinstance(b, dict)
|
||||
)
|
||||
# two most recent image turns keep their image parts
|
||||
assert _img_count(out[3]) == 1
|
||||
assert _img_count(out[4]) == 1
|
||||
# non-image message untouched (identity preserved for strings)
|
||||
assert out[0]["content"] == "hello"
|
||||
|
||||
|
||||
def test_keep_recent_image_turns_noop_when_few_images():
|
||||
msgs = [_user_with_image("a.png", "[Image attached: a.png]")]
|
||||
out = keep_recent_image_turns(msgs, keep=2)
|
||||
assert out == msgs
|
||||
@@ -0,0 +1,27 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.clean_agent_preview import conversation
|
||||
|
||||
|
||||
def test_web_heavy_followup_keeps_conversation_subject():
|
||||
def answer(question, text):
|
||||
trace = [
|
||||
{'role': 'assistant', 'tool_calls': [{'id': 'search', 'function': {'name': 'web_search', 'arguments': '{}'}}]},
|
||||
{'role': 'tool', 'tool_call_id': 'search', 'content': 'Search result. ' * 2500},
|
||||
{'role': 'assistant', 'content': text},
|
||||
]
|
||||
return [
|
||||
{'role': 'user', 'content': question},
|
||||
{'role': 'assistant', 'content': text, 'metadata': {'clean_v3_turn': trace}},
|
||||
]
|
||||
|
||||
session = SimpleNamespace(history=(
|
||||
answer('Look up news Ethan and Hila', 'The H3 podcast has paused.')
|
||||
+ answer('Tell me more', 'The H3 podcast crew made a statement.')
|
||||
+ [{'role': 'user', 'content': 'What did the crew members say?'}]
|
||||
))
|
||||
result = conversation(session, [{'role': 'user', 'content': 'What did the crew members say?'}])
|
||||
assert [m['content'] for m in result if m['role'] == 'user'] == [
|
||||
'Look up news Ethan and Hila', 'Tell me more', 'What did the crew members say?'
|
||||
]
|
||||
assert any('H3 podcast crew' in m.get('content', '') for m in result)
|
||||
@@ -21,7 +21,7 @@ described as a switch that turns something off, the read rejects `0`, `false`,
|
||||
`no` and `off` and treats everything else as on. The `Default` column is the
|
||||
value the code falls back to when the variable is unset, quoted from the source.
|
||||
|
||||
The source tree reads **124** `ODYSSEUS_*` variables: 87 an operator may want to set, and 37 that are internal - sentinels, fixture switches, capture hooks and development tooling. The internal ones are listed too, in their own section, so this page can be checked against the source mechanically.
|
||||
The source tree reads **149** `ODYSSEUS_*` variables: 90 an operator may want to set, and 59 that are internal - sentinels, fixture switches, capture hooks and development tooling. The internal ones are listed too, in their own section, so this page can be checked against the source mechanically.
|
||||
|
||||
> This page is generated. Edit `scripts/generate_env_reference.py` and
|
||||
> re-run it; `tests/test_env_reference.py` enforces that the committed page
|
||||
@@ -69,6 +69,8 @@ The source tree reads **124** `ODYSSEUS_*` variables: 87 an operator may want to
|
||||
| `ODYSSEUS_MISTRAL_REASONING_EFFORT` | `'high'` | `src/llm_core.py` | Reasoning effort sent to Mistral thinking-capable models. The API accepts high, medium, low and none. |
|
||||
| `ODYSSEUS_MLX_IMAGE_VLM_MODEL` | *unset* | `scripts/mlx_image_server.py` | Vision-language model id for the MLX image server script. Required unless `--vlm-model` is passed on the command line. |
|
||||
| `ODYSSEUS_QWEN_ROUTE_THINKING` | `'auto'` | `src/agent_loop.py` | Thinking policy for the Qwen routing step. An unrecognized value falls back to `auto`. |
|
||||
| `ODYSSEUS_VISION_MODELS` | `''` | `src/chat_helpers.py` | Comma-separated model-name fragments that explicitly support inline images. |
|
||||
| `ODYSSEUS_VISION_PROBE` | `'1'` | `src/chat_helpers.py` | Allow a cached vision-capability probe against local model endpoints. |
|
||||
|
||||
### Agent loop and tool execution
|
||||
|
||||
@@ -91,9 +93,9 @@ The source tree reads **124** `ODYSSEUS_*` variables: 87 an operator may want to
|
||||
| `ODYSSEUS_BROWSER_MCP_CACHE` | `os.path.join(base_dir, 'data', 'local', 'playwright-mcp-cache')` | `src/builtin_mcp.py` | Cache directory handed to the browser MCP server, so its npm download survives a container rebuild. |
|
||||
| `ODYSSEUS_BROWSER_MCP_CALL_TIMEOUT_S` | `'90'` | `src/mcp_manager.py` | Upper bound in seconds for one browser MCP tool call. A call that exceeds it fails without being retried. |
|
||||
| `ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE` | `''` | `src/builtin_mcp.py` | Truthy refuses to start the browser MCP server unless its npm package is already in the npx cache, instead of installing it at startup. |
|
||||
| `ODYSSEUS_BROWSER_NAMESPACE` | `'odysseus-ui'` | `src/agent_tools/web_tools.py` | Namespace for the detached agent-browser daemon's pid files, so two runtimes on one machine do not terminate each other's browsers. |
|
||||
| `ODYSSEUS_BROWSER_NAMESPACE` | `'odysseus-ui'` | `src/agent_tools/web_tools.py` (+1 more) | Namespace for the detached agent-browser daemon's pid files, so two runtimes on one machine do not terminate each other's browsers. |
|
||||
| `ODYSSEUS_BROWSER_NO_SANDBOX` | `'1'` | `src/builtin_mcp.py` | Security-relevant. On by default, adding `--no-sandbox` because the Docker image cannot use the Chromium sandbox. Set 0, false or no to keep it. |
|
||||
| `ODYSSEUS_BROWSER_SCREENSHOT_DIR` | *unset* | `src/agent_tools/web_tools.py` | Where private-browser screenshots are written. Falls back to the container path, then the system temp directory. |
|
||||
| `ODYSSEUS_BROWSER_SCREENSHOT_DIR` | *unset* | `src/agent_tools/web_tools.py` (+1 more) | Where private-browser screenshots are written. Falls back to the container path, then the system temp directory. |
|
||||
|
||||
### Container and workspace mounts
|
||||
|
||||
@@ -147,6 +149,7 @@ The source tree reads **124** `ODYSSEUS_*` variables: 87 an operator may want to
|
||||
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10 * 1024 * 1024` | `src/upload_limits.py` (+1 more) | Maximum bytes accepted for a memory import file. |
|
||||
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py` (+1 more) | Maximum bytes accepted for a personal-documents upload. |
|
||||
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `25 * 1024 * 1024` | `src/upload_limits.py` | Maximum bytes accepted for an audio file submitted for transcription. |
|
||||
| `ODYSSEUS_VISION_MAX_SIDE` | `'1280'` | `src/document_processor.py` | Maximum pixel side for image attachments sent directly to a vision model. |
|
||||
|
||||
### Search
|
||||
|
||||
@@ -211,6 +214,28 @@ Listed for completeness. Setting one of these on a real install is either a no-o
|
||||
|---|---|---|---|
|
||||
| `ODYSSEUS_CAPTURE_MODEL_REQUESTS` | `''` | `src/agent_loop.py` | Truthy writes model-request snapshots for local debugging. The marker file `/tmp/odysseus_capture_model_requests` enables the same thing. |
|
||||
| `ODYSSEUS_EXPOSE_RAW_BROWSER_MCP` | `''` | `src/agent_loop.py` | Truthy stops hiding the raw Playwright MCP tools from agent prompts when the private-browser tool is available. |
|
||||
| `ODYSSEUS_JEFF_` | *unset* | `src/jeff_decision.py` | Prefix for per-job decision adapter opt-ins; each job is disabled by default. |
|
||||
| `ODYSSEUS_JEFF_TIMEOUT` | `str(default)` | `src/jeff_decision.py` | Timeout in seconds for a decision adapter request. |
|
||||
| `ODYSSEUS_JEFF_URL` | `'http://127.0.0.1:8765'` | `src/jeff_decision.py` | Base URL of the optional decision adapter service. |
|
||||
| `ODYSSEUS_PRIVATE_BROWSER_LEGACY` | `'0'` | `src/tool_execution.py` (+1 more) | Explicit opt-in to the legacy browser compatibility transport; disabled by default. |
|
||||
| `ODYSSEUS_SPEC_BROWSER_FALLBACK` | `'0'` | `src/spec_router.py` | Optional browser routing fallback: mcp, web, or 0 to disable. |
|
||||
| `ODYSSEUS_SPEC_DIRECT_MIN_P` | `'0.9'` | `src/clean_agent_preview.py` | Minimum confidence probability a spec-router direct-answer decision must reach before the no-tools path is taken. |
|
||||
| `ODYSSEUS_SPEC_DIRECT_MODE` | `'tool_choice_none'` | `src/clean_agent_preview.py` | Controls how the spec router answers high-confidence direct-answer turns; either tool_choice_none (default) or empty_menu. |
|
||||
| `ODYSSEUS_SPEC_DIRECT_NOTOOLS` | `''` | `src/clean_agent_preview.py` | Set to 1, true, yes or on to enable the spec router's direct-answer no-tools path for high-confidence answer_directly decisions. |
|
||||
| `ODYSSEUS_SPEC_DRIVER_TOPK` | `'1'` | `src/spec_driver.py` | Number of router-selected tool candidates exposed in a driver round. |
|
||||
| `ODYSSEUS_SPEC_MODE` | `'strict'` | `src/agent_loop.py` (+1 more) | Spec routing mode for explicitly configured models, including strict and driver. |
|
||||
| `ODYSSEUS_SPEC_ROUTER_MIN_CONF` | `'0.3'` | `src/spec_router.py` | Minimum tool-selection confidence accepted from the router. |
|
||||
| `ODYSSEUS_SPEC_ROUTER_MODEL` | `'odysseus-s60'` | `src/spec_router.py` | Decision adapter model used for tool routing. |
|
||||
| `ODYSSEUS_SPEC_ROUTER_SECOND` | `'0.25'` | `src/spec_router.py` | Minimum confidence for adding a secondary tool candidate. |
|
||||
| `ODYSSEUS_SPEC_ROUTER_TIMEOUT` | `'0.5'` | `src/spec_router.py` | Time budget in seconds for a router decision. |
|
||||
| `ODYSSEUS_SPEC_ROUTER_URL` | `''` | `src/spec_router.py` | Base URL of the optional Spec router; an empty URL disables requests. |
|
||||
| `ODYSSEUS_SPEC_SUBSET_OF_BASE` | `'1'` | `src/agent_loop.py` | Limit Spec tool selections to the existing request inventory by default. |
|
||||
| `ODYSSEUS_SPEC_THINK_MIN_P` | `'0.5'` | `src/spec_router.py` | Minimum router confidence for enabling thinking. |
|
||||
| `ODYSSEUS_SPEC_THINK_MODEL` | `os.getenv('ODYSSEUS_SPEC_ROUTER_MODEL', 'odysseus-s60')` | `src/spec_router.py` | Optional thinking decision adapter model, falling back to the tool router model. |
|
||||
| `ODYSSEUS_SPEC_THINK_QUESTION` | `'binary'` | `src/spec_router.py` | Thinking classifier question format: binary or kind5. |
|
||||
| `ODYSSEUS_SPEC_THINK_ROUTER` | `'0'` | `src/spec_router.py` | Enable router thinking decisions for explicitly configured Spec models. |
|
||||
| `ODYSSEUS_SPEC_TOOL_FORMAT` | `''` | `src/spec_router.py` | Use names for compact router tool signatures, otherwise include descriptions. |
|
||||
| `ODYSSEUS_SPEC_TRIM_PROMPT` | `'1'` | `src/agent_loop.py` | Enable prompt trimming for the selected Spec tool menu. |
|
||||
| `ODYSSEUS_TOOL_CONTRACT_ROOT` | `str(Path(__file__).resolve().parents[1] / 'scripts')` | `src/clean_agent_preview.py` (+1 more) | Directory holding the tool-contract scripts the clean-agent preview loads. The default resolves to the bundled scripts directory relative to the installed/source tree. |
|
||||
|
||||
### Email
|
||||
@@ -277,7 +302,7 @@ reads three ways, because no single pattern covers the codebase:
|
||||
lines, so one read lives inside a string literal.
|
||||
|
||||
The three passes are not redundancy. A line-based grep for a direct
|
||||
`os.environ.get("ODYSSEUS_...` call finds 94 of the 124 variables on this
|
||||
`os.environ.get("ODYSSEUS_...` call finds 119 of the 149 variables on this
|
||||
page. What it misses is reads through an env-reader helper, reads whose call
|
||||
spans more than one line, reads whose variable name is held in a module
|
||||
constant, and reads through a mapping passed in as an argument - which is the
|
||||
|
||||
Reference in New Issue
Block a user