diff --git a/HARNESS_VERSION b/HARNESS_VERSION index 5d273134a..2f5aca337 100644 --- a/HARNESS_VERSION +++ b/HARNESS_VERSION @@ -1 +1 @@ -0.20.8 +0.20.9 diff --git a/src/agent_evidence.py b/src/agent_evidence.py index eaf9b5f19..5fc6165d8 100644 --- a/src/agent_evidence.py +++ b/src/agent_evidence.py @@ -239,11 +239,48 @@ def _artifact_match_is_email_host(instruction: str, match: re.Match[str]) -> boo return bool(re.search(r"[A-Za-z0-9_.+-]+@$", prefix)) +def _workspace_path_identity(value: str) -> str: + """Return a stable identity for native workspace path aliases.""" + + path = _clean_path(value).replace("\\", "/") + for prefix in ("/tmp_workspace/", "/workspace/"): + if path.startswith(prefix): + return path[len(prefix):] + return path + + +def _known_input_is_explicit_mutation_target(instruction: str, path: str) -> bool: + """Preserve a known input only when the user explicitly asks to edit it. + + Input descriptions commonly say that a file is "saved in" or is a + "post-write checklist". Those phrases must not turn read-only evidence + into a required output artifact. Direct edit/update requests remain + supported. + """ + + escaped = re.escape(_clean_path(path)) + active_edit = rf"(? CompletionRequirements: """Infer only explicitly requested output/edit paths from an instruction.""" @@ -270,6 +307,18 @@ def infer_completion_requirements( paths.append(path) paths = [path.rstrip("/") if path != "/" else path for path in paths] paths = list(dict.fromkeys(paths)) + input_identities = { + _workspace_path_identity(path) + for path in known_input_paths + if _clean_path(path) + } + if input_identities: + paths = [ + path + for path in paths + if _workspace_path_identity(path) not in input_identities + or _known_input_is_explicit_mutation_target(text, path) + ] # When the instruction names an absolute output directory and then gives # relative example filenames (for example ``1.tex, 2.tex, ...``), the # directory is the actual completion contract. Treating the first example @@ -321,9 +370,27 @@ def requirements_from_runtime_context( *, instruction: str = "", ) -> CompletionRequirements: - raw = (context or {}).get("completion_requirements") + runtime_context = context or {} + known_inputs: list[str] = [] + for value in runtime_context.get("input_files") or (): + path = _clean_path(str(value or "")) + if path: + known_inputs.append(path) + media_ingress = runtime_context.get("media_ingress") + if isinstance(media_ingress, Mapping): + for artifact in media_ingress.get("artifacts") or (): + if not isinstance(artifact, Mapping): + continue + path = _clean_path(str(artifact.get("source_path") or "")) + if path: + known_inputs.append(path) + + raw = runtime_context.get("completion_requirements") if not isinstance(raw, Mapping): - return infer_completion_requirements(instruction) + return infer_completion_requirements( + instruction, + known_input_paths=known_inputs, + ) paths = raw.get("required_artifacts") if not isinstance(paths, (list, tuple)): paths = () @@ -332,6 +399,16 @@ def requirements_from_runtime_context( for value in paths if (path := _clean_path(str(value or ""))) ) + input_identities = { + _workspace_path_identity(path) for path in known_inputs + } + if input_identities: + cleaned = tuple( + path + for path in cleaned + if _workspace_path_identity(path) not in input_identities + or _known_input_is_explicit_mutation_target(instruction, path) + ) verifier_commands = raw.get("verifier_commands") if not isinstance(verifier_commands, (list, tuple)): verifier_commands = () diff --git a/tests/test_agent_evidence.py b/tests/test_agent_evidence.py index 64cc0f386..22dda5dc8 100644 --- a/tests/test_agent_evidence.py +++ b/tests/test_agent_evidence.py @@ -19,6 +19,36 @@ def test_infers_only_explicit_output_or_edit_paths(): assert requirements.verifier_required is True +def test_known_workspace_inputs_are_not_inferred_as_output_artifacts(): + requirements = infer_completion_requirements( + "My clues are saved in /tmp_workspace/inputs/clues.md, the required answer " + "template is /tmp_workspace/inputs/template.md, and a post-write verification " + "list is /tmp_workspace/inputs/validation_checklist.txt. Read all three first. " + "Fill in /tmp_workspace/inputs/template.md exactly and save the completed " + "document to /tmp_workspace/results/results.md.", + known_input_paths=( + "/workspace/inputs/clues.md", + "/workspace/inputs/template.md", + "/workspace/inputs/validation_checklist.txt", + ), + ) + + assert requirements.required_artifacts == ( + "/tmp_workspace/results/results.md", + ) + + +def test_known_workspace_input_remains_required_when_explicitly_updated(): + requirements = infer_completion_requirements( + "Update /tmp_workspace/inputs/config.json and then verify it.", + known_input_paths=("/workspace/inputs/config.json",), + ) + + assert requirements.required_artifacts == ( + "/tmp_workspace/inputs/config.json", + ) + + def test_infers_artifact_from_common_past_participle_request(): requirements = infer_completion_requirements( "Build a digest saved to /workspace/results/ops_digest.md." @@ -125,6 +155,36 @@ def test_runtime_requirements_override_instruction_inference(): assert requirements.verifier_commands == ("./test.sh",) +def test_runtime_requirements_drop_media_inputs_misclassified_as_outputs(): + requirements = requirements_from_runtime_context( + { + "media_ingress": { + "artifacts": [ + {"source_path": "/workspace/inputs/clues.md"}, + {"source_path": "/workspace/inputs/template.md"}, + ], + }, + "completion_requirements": { + "required_artifacts": [ + "/tmp_workspace/inputs/clues.md", + "/tmp_workspace/inputs/template.md", + "/tmp_workspace/results/results.md", + ], + "verifier_required": True, + }, + }, + instruction=( + "Clues are saved in /tmp_workspace/inputs/clues.md and the template is " + "/tmp_workspace/inputs/template.md. Save the completed document to " + "/tmp_workspace/results/results.md." + ), + ) + + assert requirements.required_artifacts == ( + "/tmp_workspace/results/results.md", + ) + + def test_available_verifier_is_required_and_declared_command_is_evidence(): requirements = infer_completion_requirements( "Update app.py", diff --git a/tests/test_harness_version.py b/tests/test_harness_version.py index 8b3836f9e..a05cc471b 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.8" + assert version == "0.20.9" assert re.fullmatch(r"\d+\.\d+\.\d+", version)