diff --git a/src/turn_contract.py b/src/turn_contract.py index e3eea0a97..722a3e2cb 100644 --- a/src/turn_contract.py +++ b/src/turn_contract.py @@ -40,6 +40,13 @@ FAMILY_TOOLS = { "media_inspection": frozenset({"inspect_media"}), "ocr": frozenset({"extract_text"}), } + +# Retrieval is selection, not authorization. A normal agent turn must retain +# this small recovery surface when no family was recognized; explicit family +# selections and policy denials remain narrower. +CONTRACT_CORE_TOOLS = frozenset({ + "bash", "python", "read_file", "web_search", "web_fetch", "ask_user", +}) _FAMILY_WORDS = { "calendar": r"\b(?:calendar|calender|events?|appointments?|meetings?|agenda)\b", "notes": r"\b(?:notes?|checklists?|groceries|remind\s+me)\b", @@ -5655,6 +5662,8 @@ def resolve_turn_contract(*, capabilities: Iterable[str], schemas: Iterable[dict # Offer exactly that reader so the model cannot drift to a sibling # search/mutation tool after the router has already resolved intent. selected.intersection_update({canonical_tool(operation.tool)}) + elif not families: + selected.update(CONTRACT_CORE_TOOLS) selected.update(canonical_tool(n) for n in warm_tools if str(n or "").strip()) # Controls are neutral; enabling Web is permission, never a requested family. if selected: diff --git a/tests/test_turn_contract.py b/tests/test_turn_contract.py index 55e4363e4..861ed0973 100644 --- a/tests/test_turn_contract.py +++ b/tests/test_turn_contract.py @@ -143,6 +143,18 @@ def test_research_filter_followup_seals_a_library_search(): assert operation.args == {"action": "list", "search": "battery tech"} +def test_empty_family_contract_keeps_core_recovery_tools() -> None: + contract = resolve( + frozenset(), + selected_tools=None, + ) + + assert len(contract.offered & { + "bash", "python", "read_file", "web_search", "web_fetch", "ask_user", + }) >= 5 + assert contract.offered + + def test_full_inventory_experiment_respects_disabled_families_without_blocking_others(): from src.turn_contract import resolve_full_inventory_contract contract = resolve_full_inventory_contract(