diff --git a/routes/chat_routes.py b/routes/chat_routes.py index dccfaaba8..ad126b17e 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -87,6 +87,7 @@ from src.model_profiles import ( ) from src.tool_execution import AgentExecutionBridge, bind_execution_bridge from src.agent_runtime.authority import is_internal_tool_request, request_authority_for_http +from src.agent_runtime.runtime_selection import uses_compact_preview_runtime from src.turn_contract import ( FAMILY_TOOLS, bind_turn_contract, preserve_bound_editor_selected_tools, requested_capabilities, resolve_turn_contract, @@ -145,6 +146,14 @@ def _turn_contract_enabled(*, exact_tool_approval, runtime_surface, ) +def _request_privileges(request, user) -> Dict[str, Any]: + """Per-user privileges from the app's auth manager; empty when unmanaged.""" + auth_manager = getattr(request.app.state, "auth_manager", None) + if not user or not auth_manager: + return {} + return auth_manager.get_privileges(user) or {} + + def _native_runtime_requires_local_browser(client_runtime_context): """Use the private browser to verify declared local HTML artifacts.""" context = client_runtime_context if isinstance(client_runtime_context, dict) else {} @@ -2893,20 +2902,32 @@ def setup_chat_routes( allowed_models=_allowed_models_for_request(request), ) - # A compact (clean v3) turn resolves its typed context window once, - # here, with the session's provider credentials. History shaping below - # and the compact runtime both reuse this exact object, so the turn - # neither probes twice nor mixes the legacy untyped lookup into it. - # The predicate mirrors ``_clean_v3_preview`` below; the native - # workspace term cannot veto a requested clean route. + # Decide once whether this turn runs on the compact (clean v3) + # runtime. Every input is final here; the native workspace term of + # the contract policy cannot veto a requested clean route. This one + # value prepares the turn below and stamps its contract later, and + # the agent loop dispatches on that stamp. + _compact_preview_turn = uses_compact_preview_runtime( + clean_route_requested=_clean_v3_route_requested, + turn_contract_enabled=_turn_contract_enabled( + exact_tool_approval=exact_tool_approval, + runtime_surface=str((client_runtime_context or {}).get("surface") or ""), + native_workspace_contract=False, + clean_v3_route=_clean_v3_route_requested, + full_schema_route=(_effective_tool_schema_mode == "full"), + ), + agent_mode=(chat_mode == "agent"), + agent_permitted=_request_privileges( + request, effective_user(request), + ).get("can_use_agent", True), + image_generation=image_generation_session, + ) + # A compact turn resolves its typed context window once, here, with + # the session's provider credentials. History shaping below and the + # compact runtime both reuse this exact object, so the turn neither + # probes twice nor mixes the legacy untyped lookup into it. _compact_context_resolution = None - if _clean_v3_route_requested and _turn_contract_enabled( - exact_tool_approval=exact_tool_approval, - runtime_surface=str((client_runtime_context or {}).get("surface") or ""), - native_workspace_contract=False, - clean_v3_route=True, - full_schema_route=(_effective_tool_schema_mode == "full"), - ): + if _compact_preview_turn: from src.agent_runtime.context_resolution import resolve_effective_context _compact_context_resolution = await resolve_effective_context( sess.endpoint_url, sess.model, headers=sess.headers, @@ -3143,7 +3164,9 @@ 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"} - _clean_v3_preview = bool(_use_turn_contract and _clean_v3_route_requested) + # Same decision that prepared the turn; it only stamps the contract + # inside the agent-contract branch below. + _clean_v3_preview = _compact_preview_turn # requested_capabilities already inherits a typed, recently executed # family for referential follow-ups. Do not additionally union stale # families into an explicit new request: that inflated regular-model @@ -3288,13 +3311,11 @@ def setup_chat_routes( }) # Enforce per-user privileges - _privs = {} # Bearer clients enter the agent loop as the sandboxed ``api`` user, # but their token is owned by the real account. Use that owner here so # a permitted TUI/WebUI client does not inherit api's default denial. _user = effective_user(request) - if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager: - _privs = request.app.state.auth_manager.get_privileges(_user) + _privs = _request_privileges(request, _user) if _privs: if not _privs.get("can_use_bash", True): disabled_tools.update(FAMILY_TOOLS["shell_files"]) diff --git a/src/agent_loop.py b/src/agent_loop.py index 9b2e5e6d3..f9130a1e6 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -84,6 +84,7 @@ from src.tool_types import ToolBlock from src.turn_contract import selected_tools_for_request, with_turn_contract from src.agent_runtime.journal import propose_action, execute_action from src.agent_runtime.completion import with_completion_gate +from src.agent_runtime.runtime_selection import is_compact_preview_contract from src.teacher_escalation import with_teacher_takeover, request_teacher_takeover from src.tool_utils import _truncate, get_mcp_manager from src.agent_tools import ( @@ -20461,7 +20462,7 @@ async def stream_agent_loop( ), ) - if turn_contract is not None and turn_contract.selection_mode == 'clean_compact_v3_preview': + if is_compact_preview_contract(turn_contract): from src.clean_agent_preview import stream_preview async for chunk in stream_preview( endpoint_url=endpoint_url, model=model, messages=messages, headers=headers, diff --git a/src/agent_runtime/runtime_selection.py b/src/agent_runtime/runtime_selection.py new file mode 100644 index 000000000..9150f070c --- /dev/null +++ b/src/agent_runtime/runtime_selection.py @@ -0,0 +1,44 @@ +"""Whether a turn runs on the compact (clean v3) preview runtime. + +The chat route decides this once, from facts known before context +preparation, and uses that one value both to prepare the turn (its typed +context resolution) and to stamp the turn contract's selection mode. The +agent loop dispatches on that stamp. Keeping both sides here, with no other +imports, means preparation and dispatch read one rule and cannot drift. + +Runtime selection is not authority: it grants or denies no operation. +""" + +COMPACT_PREVIEW_MODE = "clean_compact_v3_preview" + + +def uses_compact_preview_runtime( + *, + clean_route_requested: bool, + turn_contract_enabled: bool, + agent_mode: bool, + agent_permitted: bool, + image_generation: bool, +) -> bool: + """The single compact-runtime eligibility rule for one turn. + + ``turn_contract_enabled`` is the route's contract policy for this turn + (exact approvals, TUI surface and full-schema routes opt out). + ``agent_permitted`` is false when the user's privileges demote the turn + to plain chat; image generation sessions run their own execution path. + """ + return bool( + clean_route_requested + and turn_contract_enabled + and agent_mode + and agent_permitted + and not image_generation + ) + + +def is_compact_preview_contract(turn_contract) -> bool: + """Whether a turn contract was stamped for the compact runtime.""" + return ( + turn_contract is not None + and getattr(turn_contract, "selection_mode", None) == COMPACT_PREVIEW_MODE + ) diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 01b820bc5..8b1372f9a 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -21,6 +21,7 @@ import httpx import jsonschema from src.context_compactor import 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 from src.tool_capabilities import ToolEffect, ToolRunSecurityContext, capabilities_for_action @@ -47,7 +48,7 @@ from src.model_profiles import ( ) ENDPOINT_ID = 'cleanv3' -MODE = 'clean_compact_v3_preview' +MODE = COMPACT_PREVIEW_MODE class ProviderStreamError(Exception): diff --git a/tests/test_context_resolution_route.py b/tests/test_context_resolution_route.py index 9b64d04f9..5ec173546 100644 --- a/tests/test_context_resolution_route.py +++ b/tests/test_context_resolution_route.py @@ -399,3 +399,114 @@ async def test_offline_guard_replaces_only_io_edges(context_probe_ledger): assert first.probe_errors == ("models:transport_error",) assert (first.evidence, first.effective) == (ContextEvidence.KNOWN_TABLE, 128000) assert second.cached and not second.provider_io + + +# --------------------------------------------------------------------------- +# Route preparation and agent-loop dispatch share one compact decision +# --------------------------------------------------------------------------- + +class _RegularPath(Exception): + pass + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "case, model, mode, privileges, surface, configured, image_generation, expected, regular_loop", + [ + ("compact_agent", COMPACT_MODEL, "agent", None, None, "", False, True, False), + ("compact_chat_escalates", COMPACT_MODEL, "chat", None, None, "", False, True, False), + ("regular_model", "selected-model", "agent", None, None, "", False, False, True), + ("configured_compact", "selected-model", "agent", None, None, "compact", False, True, False), + ("configured_full", COMPACT_MODEL, "agent", None, None, "full", False, False, True), + # Plain chat and image generation leave before the agent loop. + ("agent_privilege_denied", COMPACT_MODEL, "agent", {"can_use_agent": False}, None, "", False, False, False), + ("agent_privilege_granted", COMPACT_MODEL, "agent", {"can_use_agent": True}, None, "", False, True, False), + ("tui_surface", COMPACT_MODEL, "agent", None, "odysseus-tui", "", False, False, True), + ("image_generation", COMPACT_MODEL, "agent", None, None, "", True, False, False), + ], +) +async def test_route_preparation_and_compact_dispatch_cannot_diverge( + monkeypatch, context_probe_ledger, + case, model, mode, privileges, surface, configured, image_generation, expected, regular_loop, +): + from routes import chat_routes + import src.agent_loop as agent_loop + + seen = _spy(monkeypatch) + _install_model(monkeypatch) + captured = {} + endpoint = _chat_stream_endpoint( + monkeypatch, mode, captured, capture_context=True, session_model=model, + ) + monkeypatch.setattr( + chat_routes, "coerce_message_and_session", lambda *args, **kwargs: ("hello", "session-1"), + ) + monkeypatch.setattr( + chat_routes, "_configured_model_tool_surface", lambda *args, **kwargs: configured, + ) + monkeypatch.setattr( + chat_routes, "_is_image_generation_session", lambda *args, **kwargs: image_generation, + ) + # Real agent loop: the compact branch reaches the recorded stream_preview; + # the regular branch stops at its first step. + regular = [] + + def stop_regular(*args, **kwargs): + regular.append(True) + raise _RegularPath() + + monkeypatch.setattr(agent_loop, "_contract_allows_single_action_terminal", stop_regular) + monkeypatch.setattr(chat_routes, "stream_agent_loop", agent_loop.stream_agent_loop) + + request = _RouteRequest(mode, privileges=privileges) + request._form.update({"message": "hello", "compare_mode": "false"}) + if surface: + request._form["client_runtime_context"] = json.dumps({"surface": surface}) + response = await endpoint(request) + try: + async for _ in response.body_iterator: + pass + except _RegularPath: + pass + + prepared = captured["build_context"].get("context_resolution") + dispatched_compact = bool(seen["preview_kwargs"]) + assert (prepared is not None) == dispatched_compact == expected, case + if expected: + # One resolution, prepared by the route and reused by dispatch. + assert len(seen["resolutions"]) == 1 and len(context_probe_ledger) == 1 + assert seen["preview_kwargs"][0]["context_resolution"] is prepared + else: + assert seen["resolutions"] == [] and context_probe_ledger == [] + # The case really reached the dispatch point it claims to exercise. + assert bool(regular) == regular_loop, case + + +def test_compact_selection_rule_and_contract_stamp(): + from src.agent_runtime.runtime_selection import ( + COMPACT_PREVIEW_MODE, is_compact_preview_contract, uses_compact_preview_runtime, + ) + from src.clean_agent_preview import MODE + from routes.chat_routes import _turn_contract_enabled + from types import SimpleNamespace + + facts = dict( + clean_route_requested=True, turn_contract_enabled=True, + agent_mode=True, agent_permitted=True, image_generation=False, + ) + assert uses_compact_preview_runtime(**facts) + for name, value in ( + ("clean_route_requested", False), ("turn_contract_enabled", False), + ("agent_mode", False), ("agent_permitted", False), ("image_generation", True), + ): + assert not uses_compact_preview_runtime(**{**facts, name: value}), name + # An exact tool approval opts the turn out through the contract policy. + assert not uses_compact_preview_runtime(**{**facts, "turn_contract_enabled": _turn_contract_enabled( + exact_tool_approval=object(), runtime_surface="", native_workspace_contract=False, + clean_v3_route=True, + )}) + # The route stamps contracts with the same constant the loop checks. + assert MODE == COMPACT_PREVIEW_MODE + assert is_compact_preview_contract(SimpleNamespace(selection_mode=MODE)) + assert not is_compact_preview_contract(SimpleNamespace(selection_mode="routed")) + assert not is_compact_preview_contract(None)