diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 51d48dc..b015699 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,4 +53,4 @@ jobs: - name: Require the native provider and verify adapter boundaries offline run: | uv run --no-sync python -c 'from amplifier_module_provider_openai.native import NativeResponsesProvider' - uv run --no-sync pytest -q tests/test_native_provider_adapter.py tests/test_native_provider_budget_compatibility.py tests/test_native_job_attribution.py tests/test_native_adapter_fallback.py tests/test_image_budget.py + uv run --no-sync pytest -q tests/test_native_provider_adapter.py tests/test_native_history_options.py tests/test_native_provider_budget_compatibility.py tests/test_native_job_attribution.py tests/test_native_adapter_fallback.py tests/test_image_budget.py diff --git a/adapters/cli/amplifier_loop_live_cli/bundle_astra.py b/adapters/cli/amplifier_loop_live_cli/bundle_astra.py index c732da5..d2cfb0a 100644 --- a/adapters/cli/amplifier_loop_live_cli/bundle_astra.py +++ b/adapters/cli/amplifier_loop_live_cli/bundle_astra.py @@ -21,7 +21,8 @@ def wrap(cls, original): def _convert_messages(self, messages, **kwargs): from amplifier_loop_live_cli.computer_results import failed_computer_history,validate_computer_outputs - messages,_=failed_computer_history(messages) + messages,_=failed_computer_history( + messages, ordinary_names=kwargs.get("ordinary_computer_names", frozenset())) return validate_computer_outputs(super()._convert_messages(messages,**kwargs)) async def complete(self, request, **kwargs): @@ -47,9 +48,9 @@ def wrap(cls, original): return super().wrap(original, owner_getter=LIVE_OWNER.get, trace=trace, trace_context=trace_native_context) - def _prepare_native_messages(self, messages): + def _prepare_native_messages(self, messages, *, ordinary_names=frozenset()): from .computer_results import failed_computer_history - return failed_computer_history(messages)[0] + return failed_computer_history(messages, ordinary_names=ordinary_names)[0] def _validate_native_items(self, items): from .computer_results import validate_computer_outputs diff --git a/adapters/cli/amplifier_loop_live_cli/computer_results.py b/adapters/cli/amplifier_loop_live_cli/computer_results.py index 3c0bb13..43355de 100644 --- a/adapters/cli/amplifier_loop_live_cli/computer_results.py +++ b/adapters/cli/amplifier_loop_live_cli/computer_results.py @@ -39,23 +39,25 @@ def __init__(self, message): super().__init__(self.public_message, provider="openai", retryable=False) -def failed_computer_history(messages): +def failed_computer_history(messages, *, ordinary_names=frozenset()): """Represent an old failure as evidence only after a subsequent user message. The saved transcript is untouched. A failed native call cannot receive a made-up screenshot; the next user turn starts a new Responses lineage with an explicit failure record carrying the original identity and result. """ + # The provider owns native eligibility. An ordinary tool may share the + # computer name and actions-shaped arguments without returning screenshots. native = set() for message in messages: for call in message.get("tool_calls") or []: name=call.get("tool") or call.get("name") args=call.get("arguments") or call.get("input") or {} - if name=="computer" and isinstance(args,dict) and isinstance(args.get("actions"),list): + if name=="computer" and name not in ordinary_names and isinstance(args,dict) and isinstance(args.get("actions"),list): native.add(call.get("id")) content=message.get("content") for block in content if isinstance(content,list) else []: - if isinstance(block,dict) and block.get("type")=="tool_call" and block.get("name")=="computer" and isinstance((block.get("input") or {}).get("actions"),list): + if isinstance(block,dict) and block.get("type")=="tool_call" and block.get("name")=="computer" and block.get("name") not in ordinary_names and isinstance((block.get("input") or {}).get("actions"),list): native.add(block.get("id")) last_user=max((i for i,m in enumerate(messages) if m.get("role")=="user" and not (m.get("metadata") or {}).get("ephemeral")),default=-1) failures={} diff --git a/tests/test_native_history_options.py b/tests/test_native_history_options.py new file mode 100644 index 0000000..62a5de8 --- /dev/null +++ b/tests/test_native_history_options.py @@ -0,0 +1,65 @@ +"""Provider-owned ordinary-tool classification survives the optional CLI adapter.""" +import copy +import json +import sys +from pathlib import Path + +import pytest + +pytest.importorskip('amplifier_module_provider_openai.native') +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'adapters/cli')) +from amplifier_loop_live_cli.bundle_astra import BundleAstraProvider, SafeOpenAIProvider +from amplifier_loop_live_cli.computer_results import ComputerResultError, failed_computer_history +from amplifier_module_provider_openai import OpenAIProvider + + +def history(*, block, later_user): + call = {'id': 'ordinary-call', 'name': 'computer', 'arguments': {'actions': []}} + assistant = {'role': 'assistant', 'content': '', 'tool_calls': [call]} + if block: + assistant = {'role': 'assistant', 'content': [ + {'type': 'tool_call', 'id': call['id'], 'name': call['name'], 'input': call['arguments']}]} + messages = [{'role': 'user', 'content': 'Use the ordinary tool'}, assistant, + {'role': 'tool', 'tool_call_id': call['id'], + 'content': json.dumps({'success': False, 'error': 'ordinary tool failure'})}] + if later_user: + messages.append({'role': 'user', 'content': 'Explain that result'}) + return messages + + +@pytest.mark.parametrize('provider_type', [BundleAstraProvider, SafeOpenAIProvider]) +@pytest.mark.parametrize('block', [False, True]) +@pytest.mark.parametrize('later_user', [False, True]) +def test_actual_provider_serializer_preserves_ordinary_computer_result(provider_type, block, later_user): + messages = history(block=block, later_user=later_user) + original = copy.deepcopy(messages) + provider = provider_type.wrap(OpenAIProvider(api_key='fixture', config={'default_model': 'gpt-6-astra'})) + converted = provider._convert_messages(messages, ordinary_computer_names=frozenset({'computer'})) + output = [item for item in converted if item.get('type') == 'function_call_output'] + assert len(output) == 1 and output[0]['call_id'] == 'ordinary-call' + assert 'ordinary tool failure' in str(output[0]['output']) + assert not any(item.get('type') == 'computer_call_output' for item in converted) + assert messages == original + + +@pytest.mark.parametrize('block', [False, True]) +def test_native_failure_still_fails_closed_in_current_turn(block): + with pytest.raises(ComputerResultError, match='ordinary tool failure'): + failed_computer_history(history(block=block, later_user=False)) + + +@pytest.mark.parametrize('block', [False, True]) +def test_later_native_failure_is_reference_only_and_does_not_edit_transcript(block): + messages = history(block=block, later_user=True) + original = copy.deepcopy(messages) + projected, changed = failed_computer_history(messages) + assert changed and messages == original + assert any('Do not replay the prior action automatically' in str(m.get('content')) for m in projected) + assert not any(m.get('role') == 'tool' for m in projected) + +@pytest.mark.parametrize('block', [False, True]) +@pytest.mark.parametrize('later_user', [False, True]) +def test_native_projection_accepts_provider_owned_ordinary_names(block, later_user): + messages = history(block=block, later_user=later_user) + provider = BundleAstraProvider.wrap(OpenAIProvider(api_key='fixture', config={'default_model': 'gpt-6-astra'})) + assert provider._prepare_native_messages(messages, ordinary_names=frozenset({'computer'})) is messages