diff --git a/HARNESS_VERSION b/HARNESS_VERSION index e7c6f5dbf..5d273134a 100644 --- a/HARNESS_VERSION +++ b/HARNESS_VERSION @@ -1 +1 @@ -0.20.7 +0.20.8 diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 356e26a16..032ccf047 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -1525,14 +1525,31 @@ def artifact_completion_tool_schemas(offered_schemas, required_artifacts): This applies only after native execution enters its reserved artifact-write phase. A JSON-Schema ``const`` gives the provider the exact destination instead of relying on it to recover the path from a long conversation. - Multiple outputs and directory targets stay unconstrained because choosing - one of those paths requires model intent. + Multiple outputs stay unconstrained. A sole directory target constrains the + writer to a non-empty descendant path while leaving the filename to the + model. """ targets = [str(path or '').strip().rstrip('/') for path in required_artifacts] - targets = [path for path in targets if path and Path(path).suffix] + targets = [path for path in targets if path] if len(targets) != 1 or len(tuple(required_artifacts or ())) != 1: return offered_schemas target = targets[0] + if not Path(target).suffix: + bound = copy.deepcopy(offered_schemas) + for schema in bound: + function = schema.get('function') or {} + if canonical(function.get('name')) != 'write_file': + continue + properties = (function.get('parameters') or {}).get('properties') or {} + path_schema = properties.get('path') + if not isinstance(path_schema, dict): + continue + path_schema['pattern'] = '^' + re.escape(target + '/') + '.+' + path_schema['description'] = ( + f'Write a new file inside the required directory {target}; ' + 'do not use the directory path itself.' + ) + return bound if Path(target).suffix.lower() in _NON_TEXT_ARTIFACT_SUFFIXES: # ``write_file`` deliberately accepts UTF-8 text only. Keeping it in # a binary artifact completion round lets a forced writer choice trap @@ -1571,14 +1588,9 @@ def artifact_completion_tool_schemas(offered_schemas, required_artifacts): def required_artifact_completion_tool_choice(required_artifacts, offered_schemas): - """Choose a writer only when the contract names an exact output file. - - A directory contract needs the model to choose one or more child filenames. - Forcing ``write_file`` there encourages an impossible write to the directory - path itself, so leave tool choice unconstrained for that case. - """ + """Force the compatible writer during reserved artifact completion.""" targets = [str(path or '').strip().rstrip('/') for path in required_artifacts] - if not targets or any(not Path(target).suffix for target in targets): + if not targets: return None offered = { canonical((schema.get('function') or {}).get('name')): diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index 58e1dd032..84c46a426 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -624,7 +624,7 @@ def test_artifact_completion_schema_binds_single_required_file_without_mutating_ assert 'const' not in source[0]['function']['parameters']['properties']['path'] -def test_artifact_completion_schema_does_not_bind_directory_or_multiple_outputs(): +def test_artifact_completion_schema_binds_directory_descendant_but_not_multiple_outputs(): source = [{ 'type': 'function', 'function': { @@ -636,9 +636,14 @@ def test_artifact_completion_schema_does_not_bind_directory_or_multiple_outputs( }, }] - assert artifact_completion_tool_schemas( + directory_bound = artifact_completion_tool_schemas( source, ('/workspace/results/',), - ) == source + ) + directory_path = directory_bound[0]['function']['parameters']['properties']['path'] + assert re.search(directory_path['pattern'], '/workspace/results/output.md') + assert not re.search(directory_path['pattern'], '/workspace/results') + assert 'inside the required directory' in directory_path['description'] + assert 'pattern' not in source[0]['function']['parameters']['properties']['path'] assert artifact_completion_tool_schemas( source, ('/workspace/a.txt', '/workspace/b.txt'), ) == source @@ -679,7 +684,7 @@ def test_required_binary_artifact_forces_python_instead_of_text_writer(): ] -def test_required_directory_artifact_does_not_force_writer_to_directory_path(): +def test_required_directory_artifact_forces_writer_with_descendant_schema(): offered = [ {'type': 'function', 'function': {'name': 'bash'}}, {'type': 'function', 'function': {'name': 'write_file'}}, @@ -687,7 +692,7 @@ def test_required_directory_artifact_does_not_force_writer_to_directory_path(): assert required_artifact_completion_tool_choice( ('/tmp_workspace/results',), offered, - ) is None + ) == {'type': 'function', 'function': {'name': 'write_file'}} def test_action_promise_response_rejects_future_work_but_not_real_answers(): @@ -5328,11 +5333,14 @@ async def test_native_stream_reserves_remaining_budget_for_required_artifact(mon assert "web_search" not in request_contract["offered_tools"] assert request_contract["tool_choice"] is None reserved_names = [tool["function"]["name"] for tool in requests[12]["tools"]] - assert reserved_names == ["bash", "python", "write_file"] + assert reserved_names == ["write_file"] assert "tool_choice" not in requests[12] + directory_path = requests[12]["tools"][0]["function"]["parameters"]["properties"]["path"] + assert re.search(directory_path["pattern"], "/tmp_workspace/results/out.md") + assert not re.search(directory_path["pattern"], "/tmp_workspace/results") assert "create one or more files inside" in requests[12]["messages"][-1]["content"] assert "Do not pass the directory itself as a file path" in requests[12]["messages"][-1]["content"] - assert "bash" in executed + assert "bash" not in executed assert any( event.get("type") == "tool_output" and event.get("tool") == "write_file" and not event.get("error") for event in events diff --git a/tests/test_harness_version.py b/tests/test_harness_version.py index 6f70ed371..8b3836f9e 100644 --- a/tests/test_harness_version.py +++ b/tests/test_harness_version.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_original_harness_version_is_canonical_semver() -> None: version = (ROOT / "HARNESS_VERSION").read_text(encoding="utf-8").strip() - assert version == "0.20.7" + assert version == "0.20.8" assert re.fullmatch(r"\d+\.\d+\.\d+", version)