diff --git a/scripts/verify_clean_v3_search_quality.mjs b/scripts/verify_clean_v3_search_quality.mjs index 9747d7511..e0e94f408 100644 --- a/scripts/verify_clean_v3_search_quality.mjs +++ b/scripts/verify_clean_v3_search_quality.mjs @@ -84,6 +84,8 @@ async function send(page, prompt) { rounds: metrics?.agent_rounds ?? null, actual_model: metrics?.model ?? null, selection_mode: contract?.selection_mode ?? null, + policy_decisions: metrics?.policy_decisions || [], + proposed_calls: (metrics?.clean_v3_turn || []).flatMap(message => message.tool_calls || []), tools: starts, outputs, final, evidence: events.filter(event => event.type === 'tool_output').map(event => ({ tool: canonical(event.tool), arguments: event.command, diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index e1f34f878..9246d0a4a 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -31,7 +31,7 @@ from src.tool_schemas import ( from src.tool_types import ToolBlock from src.turn_contract import ( FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_request, - targets_bound_editor_request, + targets_bound_editor_request, inline_text_transformation, ) from src.prompt_security import untrusted_context_message from src.model_profiles import ( @@ -1436,6 +1436,8 @@ def tool_family(name): def authorized_write_families(user_text): """Conservative action authority; never controls which schemas are offered.""" text = str(user_text or '').casefold() + if inline_text_transformation(text): + return frozenset() families = set() patterns = { 'email': r'\b(?:e.?mail|emil|inbox|mail)\b', @@ -3783,7 +3785,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac suggestion_only=suggestion_target, ) offered = compact_schemas(turn_contract.schemas()) - if standalone_social_turn(direct_user_text): + if standalone_social_turn(direct_user_text) or inline_text_transformation(direct_user_text): offered = [] external_schema_by_name = { str((schema.get('function') or {}).get('name') or ''): copy.deepcopy(schema) diff --git a/src/turn_contract.py b/src/turn_contract.py index ba9688407..a698681b0 100644 --- a/src/turn_contract.py +++ b/src/turn_contract.py @@ -642,6 +642,23 @@ def _explicit_email_attachment_read(message: str) -> tuple[str, int] | None: return None +def inline_text_transformation(message: str) -> bool: + """An explicit text-editing prefix makes the colon payload data, not a tool request. + + Do not match edits *in* an account/editor or compound instructions before + the delimiter. Names of tools or personal objects inside supplied text do + not grant authority to operate on those objects. + """ + return bool(re.fullmatch( + r'\s*(?:please\s+)?(?:' + r'(?:fix|correct)\s+(?:the\s+)?(?:spelling|grammar|typos)' + r'|proofread(?:\s+(?:this|the following)(?:\s+text)?)?' + r'|translate\s+(?:this\s+)?(?:to|into)\s+[A-Za-z]+(?:\s+[A-Za-z]+)?' + r')\s*:\s*\S[\s\S]*', + str(message or ''), re.I, + )) + + def selected_tools_for_request(message: str) -> frozenset[str] | None: """Narrow only a complete, explicit operation; None retains family scope. @@ -649,6 +666,8 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None: mailbox-content requests. Account discovery needs only local metadata. """ raw_text = str(message or "").strip() + if inline_text_transformation(raw_text): + return frozenset() text = _normalize_request_lead(message) explicitly_named = { name diff --git a/tests/test_search_observation_budget.py b/tests/test_search_observation_budget.py index 35ab8fc5a..ca9b1808e 100644 --- a/tests/test_search_observation_budget.py +++ b/tests/test_search_observation_budget.py @@ -4,6 +4,32 @@ import json import pytest +@pytest.mark.parametrize('prompt', [ + 'fix spelling: i recieved the calender invte', + 'Correct grammar: I has sent the email', + 'Proofread this text: Delete the calendar event tomorrow.', + 'Translate to French: search the web and send an email', +]) +def test_supplied_text_is_not_tool_authority(prompt): + from src.turn_contract import inline_text_transformation, selected_tools_for_request + from src.clean_agent_preview import authorized_write_families, requests_mutation + assert inline_text_transformation(prompt) + assert selected_tools_for_request(prompt) == frozenset() + assert not authorized_write_families(prompt) + assert not requests_mutation(prompt) + + +@pytest.mark.parametrize('prompt', [ + 'Fix spelling in my calendar event', + 'Proofread the open document', + 'Translate and save a document: hello', + 'Find a spelling correction tool', +]) +def test_external_edits_are_not_mistaken_for_inline_text(prompt): + from src.turn_contract import inline_text_transformation + assert not inline_text_transformation(prompt) + + @pytest.mark.asyncio async def test_runtime_does_not_append_unverified_search_result_as_citation(monkeypatch): import src.clean_agent_preview as runtime