diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 0718f76cb..3f86c6656 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -1471,6 +1471,54 @@ def execution_targets_required_artifact(tool_name, arguments, required_artifacts return any(str(path or '').rstrip('/') in serialized for path in required_artifacts) +def required_artifacts_have_content(required_artifacts): + """Return true only when every required output contains material evidence. + + A runner may declare a directory such as ``/tmp_workspace/results`` as its + output contract. Merely creating that directory is setup, not completion. + Directory outputs therefore require at least one non-empty regular file; + file outputs must themselves be non-empty. Required workspace artifacts + are local to the native runtime, so checking their post-mutation state is + stronger than inferring completion from a shell command string. + """ + targets = [Path(str(path or '').strip().rstrip('/')) for path in required_artifacts] + if not targets: + return False + for target in targets: + try: + if target.is_file(): + if target.stat().st_size <= 0: + return False + continue + if target.is_dir(): + if not any( + child.is_file() and child.stat().st_size > 0 + for child in target.rglob('*') + ): + return False + continue + except OSError: + return False + return False + return True + + +def successful_required_artifact_mutation(tool_name, arguments, required_artifacts): + """Recognize completed outputs without letting an empty directory pass. + + Typed tools can authoritatively report a successful exact file write even + when their workspace is mounted outside this process. Directory contracts + are different: mentioning or creating the directory only establishes a + container, so they require observable non-empty file content. + """ + targets = [str(path or '').strip().rstrip('/') for path in required_artifacts] + if required_artifacts_have_content(targets): + return True + if not targets or any(not Path(target).suffix for target in targets): + return False + return execution_targets_required_artifact(tool_name, arguments, targets) + + def artifact_completion_tool_schemas(offered_schemas, required_artifacts): """Bind the sole artifact writer to the runner-declared output file. @@ -5581,7 +5629,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac successful_duplicate_counts.pop(prior_signature, None) if canonical(block.tool_type) in {'edit_document', 'update_document'}: successful_editor_writer = canonical(block.tool_type) - if execution_targets_required_artifact( + if successful_required_artifact_mutation( block.tool_type, args, required_artifacts, ): successful_artifact_write = True diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index 1d64a66f1..c2d941d7c 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -1,5 +1,6 @@ from types import SimpleNamespace from dataclasses import replace +from pathlib import Path import json import jsonschema import pytest @@ -471,6 +472,131 @@ def test_runtime_required_artifacts_includes_runner_declared_directory(): ) == ('/tmp_workspace/results',) +def test_required_artifact_content_rejects_empty_directories(tmp_path): + import src.clean_agent_preview as module + + output = tmp_path / 'results' + output.mkdir() + (output / 'nested-empty').mkdir() + + assert not module.required_artifacts_have_content((str(output),)) + + +def test_required_artifact_content_accepts_nonempty_files_and_nested_outputs(tmp_path): + import src.clean_agent_preview as module + + output_file = tmp_path / 'output.html' + output_file.write_text('

done

') + output_dir = tmp_path / 'results' + nested = output_dir / 'part-01' + nested.mkdir(parents=True) + (nested / 'answer.json').write_text('{"done": true}') + + assert module.required_artifacts_have_content((str(output_file),)) + assert module.required_artifacts_have_content((str(output_dir),)) + assert not module.required_artifacts_have_content((str(tmp_path / 'missing'),)) + + +def test_required_artifact_mutation_trusts_exact_files_but_not_empty_directory_setup(tmp_path): + import src.clean_agent_preview as module + + output_dir = tmp_path / 'results' + output_dir.mkdir() + assert not module.successful_required_artifact_mutation( + 'bash', {'command': f'mkdir -p {output_dir}'}, (str(output_dir),), + ) + output_file = tmp_path / 'output.html' + assert module.successful_required_artifact_mutation( + 'write_file', {'path': str(output_file), 'content': '

done

'}, + (str(output_file),), + ) + + +@pytest.mark.asyncio +async def test_empty_required_directory_keeps_native_turn_running(monkeypatch, tmp_path): + import src.clean_agent_preview as module + + output = tmp_path / 'results' + responses = iter([ + {'choices': [{'delta': {'tool_calls': [{ + 'index': 0, + 'id': 'call-mkdir', + 'type': 'function', + 'function': { + 'name': 'bash', + 'arguments': json.dumps({'command': f'mkdir -p {output}'}), + }, + }]}}]}, + {'choices': [{'delta': {'tool_calls': [{ + 'index': 0, + 'id': 'call-write', + 'type': 'function', + 'function': { + 'name': 'write_file', + 'arguments': json.dumps({ + 'path': str(output / 'answer.txt'), 'content': 'done', + }), + }, + }]}}]}, + {'choices': [{'delta': {'content': 'Completed the requested output.'}}]}, + ]) + requests = [] + executed = [] + + class Response: + def __init__(self, payload): self.payload = payload + async def __aenter__(self): return self + async def __aexit__(self, *args): pass + def raise_for_status(self): pass + async def aiter_lines(self): + yield 'data: ' + json.dumps(self.payload) + yield 'data: [DONE]' + + class Client: + def __init__(self, **kwargs): pass + async def __aenter__(self): return self + async def __aexit__(self, *args): pass + def stream(self, *args, **kwargs): + requests.append(kwargs['json']) + return Response(next(responses)) + + async def execute(block, **kwargs): + executed.append(block.tool_type) + if block.tool_type == 'bash': + output.mkdir() + else: + (output / 'answer.txt').write_text('done') + return block.tool_type, {'output': '(no output)', 'exit_code': 0} + + monkeypatch.setattr(module.httpx, 'AsyncClient', Client) + monkeypatch.setattr(module, 'execute_tool_block', execute) + schemas = [ + item for item in FUNCTION_TOOL_SCHEMAS + if item['function']['name'] in {'bash', 'write_file'} + ] + contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy()) + raw = [chunk async for chunk in stream_preview( + endpoint_url='http://test', model='qwen-test', + messages=[{'role': 'user', 'content': 'Create the requested output files.'}], + headers={}, turn_contract=contract, session_id='test', owner='test', + disabled_tools=set(), tool_policy=ToolPolicy(), workspace=str(tmp_path), + client_runtime_context={ + 'surface': 'odysseus-native', 'terminal_agent': True, + 'unattended_mode': True, + 'completion_requirements': {'required_artifacts': [str(output)]}, + }, max_rounds=8, + )] + + events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk] + assert executed == ['bash', 'write_file'] + after_mkdir = next( + event for event in events + if event.get('type') == 'agent_step' and event.get('calls_used') == 1 + ) + assert after_mkdir['required_artifact_pending'] is True + assert (output / 'answer.txt').read_text() == 'done' + + def test_artifact_completion_schema_binds_single_required_file_without_mutating_source(): source = [{ 'type': 'function',