111 lines
4.9 KiB
Python
111 lines
4.9 KiB
Python
"""Provider action shape matches the pinned framework, without response repair."""
|
|
import json
|
|
|
|
import pytest
|
|
from jsonschema import Draft202012Validator
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from backend.browser import diagnostics, structured_output as contract
|
|
|
|
|
|
def envelope(actions):
|
|
return {'evaluation_previous_goal': 'fixture', 'memory': 'fixture', 'next_goal': 'fixture', 'action': actions}
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def actual_contract():
|
|
from browser_use.agent.views import AgentOutput
|
|
from backend.browser.tools import build_prospecting_tools
|
|
actions = build_prospecting_tools('d43_contract_fixture').registry.create_action_model()
|
|
output = AgentOutput.type_with_custom_actions(actions)
|
|
function, instruction = contract.request_contract(output)
|
|
return output, function['function']['parameters'], instruction
|
|
|
|
|
|
@pytest.mark.parametrize('actions,valid', [
|
|
([{'go_back': {}}], True),
|
|
([{'go_back': {}}, {'inspect_source_candidates': {'max_comments': 1}}], True),
|
|
([{'go_back': {}, 'inspect_source_candidates': {'max_comments': 1}}], False),
|
|
([{'go_back': {}, 'wait': None}], False),
|
|
([{'name': 'go_back', 'parameters': {}}], False),
|
|
([{}], False),
|
|
])
|
|
def test_real_action_union_and_sent_schema_agree_on_complete_single_branches(actual_contract, actions, valid):
|
|
output, schema, instruction = actual_contract
|
|
arguments = envelope(actions)
|
|
assert Draft202012Validator(schema).is_valid(arguments) is valid
|
|
if valid:
|
|
result = output.model_validate(arguments)
|
|
assert len(result.action) == len(actions)
|
|
else:
|
|
with pytest.raises(ValidationError):
|
|
output.model_validate(arguments)
|
|
assert '只能包含一个' in instruction and '多个数组元素' in instruction
|
|
|
|
|
|
def test_empty_action_array_is_rejected_by_standard_schema_as_well_as_transport(actual_contract):
|
|
from types import SimpleNamespace as Obj
|
|
output, schema, _ = actual_contract
|
|
arguments = envelope([])
|
|
assert not Draft202012Validator(schema).is_valid(arguments)
|
|
message = Obj(tool_calls=[Obj(type='function', function=Obj(
|
|
name=contract.FUNCTION_NAME, arguments=json.dumps(arguments)))])
|
|
with pytest.raises(contract.ResponseContractError, match='empty_actions'):
|
|
contract.validated_tool_result(message, output)
|
|
|
|
|
|
def test_generic_action_payload_is_not_narrowed_to_a_browser_action():
|
|
class GenericOutput(BaseModel):
|
|
action: list[dict]
|
|
original = GenericOutput.model_json_schema()
|
|
# A custom caller can cache its exported schema; request preparation must
|
|
# not mutate that caller-owned value either.
|
|
class CachedSchema:
|
|
@classmethod
|
|
def model_json_schema(cls):
|
|
return original
|
|
function, instruction = contract.request_contract(CachedSchema)
|
|
schema = function['function']['parameters']
|
|
assert Draft202012Validator(schema).is_valid({'action': [{'a': 1, 'b': 2}]})
|
|
assert 'maxProperties' not in schema['properties']['action']['items']
|
|
assert '只能包含一个' not in instruction
|
|
assert 'minItems' not in original['properties']['action']
|
|
|
|
|
|
def test_safe_union_feedback_explains_single_action_shape_without_guessing_bad_input(actual_contract):
|
|
output, _, _ = actual_contract
|
|
with pytest.raises(ValidationError) as caught:
|
|
output.model_validate(envelope([{'PRIVATE-ACTION': {'PRIVATE-PARAM': 'PRIVATE-VALUE'}}]))
|
|
errors = diagnostics.validation_fields(caught.value, output)
|
|
hint = diagnostics.correction_hint({'kind': 'schema_error', 'errors': errors})
|
|
assert 'extra_forbidden' in hint and 'missing' in hint
|
|
assert '每个数组元素只能包含一个' in hint and '多个数组元素' in hint
|
|
assert 'PRIVATE' not in json.dumps(errors) + hint
|
|
assert '不要执行或复用' in hint
|
|
|
|
|
|
async def test_single_action_feedback_reaches_only_the_next_normal_call(actual_contract, tmp_path, monkeypatch):
|
|
from types import SimpleNamespace as Obj
|
|
from browser_use.llm.messages import UserMessage
|
|
from backend.browser import runner
|
|
output, _, _ = actual_contract
|
|
monkeypatch.setattr(diagnostics, 'task_doc_dir', lambda _: tmp_path)
|
|
calls = []
|
|
class Base:
|
|
async def get_model_output(self, messages):
|
|
calls.append(messages)
|
|
return Obj(action=[])
|
|
agent = runner._prospecting_agent_class(Base)()
|
|
agent._goal_based = True
|
|
original = [UserMessage(content='fixture')]
|
|
with diagnostics.run_scope('d43_fixture', 'd43_run'):
|
|
with pytest.raises(ValidationError) as caught:
|
|
output.model_validate(envelope([{'go_back': {}, 'wait': None}]))
|
|
diagnostics.schema_error(None, caught.value, output)
|
|
await agent.get_model_output(original)
|
|
assert len(calls) == 1 and len(original) == 1
|
|
assert '每个数组元素只能包含一个' in calls[0][-1].text
|
|
diagnostics.validated_response('browser')
|
|
await agent.get_model_output(original)
|
|
assert len(calls) == 2 and calls[1] is original
|