diff --git a/.map/scripts/map_step_runner.py b/.map/scripts/map_step_runner.py index 187aa918..274da86e 100755 --- a/.map/scripts/map_step_runner.py +++ b/.map/scripts/map_step_runner.py @@ -11046,6 +11046,27 @@ def save_research( _MONITOR_REQUIRED_KEYS = ("valid", "summary", "issues") _ACTOR_REQUIRED_KEYS = tuple(AGENT_OUTPUT_SCHEMAS["actor"]["required_keys"]) +# Leading marker prepended by Claude Code v2.1.210+ when a subagent report +# matches instruction-shaped patterns. The scan is advisory; the payload is +# the original agent output and should be parsed normally once the marker +# is stripped. See: https://code.claude.com/docs/en/sub-agents +_HARNESS_MARKER_PREFIX = "[harness: subagent output matched instruction-shaped pattern(s):" + + +def _strip_harness_markers(text: str) -> tuple[str, list[str]]: + """Strip known provider harness marker lines from the start of ``text``. + + Returns ``(cleaned_text, [stripped_marker_line, ...])``. + Only lines whose stripped form starts with ``_HARNESS_MARKER_PREFIX`` + are removed; any other leading/trailing text is left intact so the + downstream parser still classifies it as unexpected prose. + """ + stripped_markers: list[str] = [] + lines = text.split("\n") + while lines and lines[0].strip().startswith(_HARNESS_MARKER_PREFIX): + stripped_markers.append(lines.pop(0)) + return "\n".join(lines), stripped_markers + def detect_truncated_agent_output( text: str, @@ -11074,6 +11095,9 @@ def detect_truncated_agent_output( # "response ends mid-sentence" "parsed": dict | None, # the parsed object, or None on parse failure "agent_kind": str, # echoed for downstream logging + "harness_markers_stripped": [str, ...], + # provider marker lines removed before + # parsing (empty when no markers present) } ``expected_keys`` defaults per ``agent_kind``: monitor expects @@ -11090,6 +11114,22 @@ def detect_truncated_agent_output( "reasons": ["empty response"], "parsed": None, "agent_kind": agent_kind, + "harness_markers_stripped": [], + } + + # Strip known Claude harness marker lines before JSON parsing (#380). + # Claude Code v2.1.210+ may prepend a marker line to a subagent report + # when the output matches instruction-shaped patterns; these lines are + # not model prose and must not trigger the "leading text" rejection. + stripped, harness_markers = _strip_harness_markers(stripped) + stripped = stripped.strip() + if not stripped: + return { + "truncated": True, + "reasons": ["empty response"], + "parsed": None, + "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } parsed: Optional[dict[str, object]] = None @@ -11129,6 +11169,7 @@ def detect_truncated_agent_output( "reasons": reasons, "parsed": None, "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } # Validate required keys. @@ -11156,6 +11197,7 @@ def detect_truncated_agent_output( "reasons": reasons, "parsed": parsed, "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } diff --git a/src/mapify_cli/templates/map/scripts/map_step_runner.py b/src/mapify_cli/templates/map/scripts/map_step_runner.py index 187aa918..274da86e 100755 --- a/src/mapify_cli/templates/map/scripts/map_step_runner.py +++ b/src/mapify_cli/templates/map/scripts/map_step_runner.py @@ -11046,6 +11046,27 @@ def save_research( _MONITOR_REQUIRED_KEYS = ("valid", "summary", "issues") _ACTOR_REQUIRED_KEYS = tuple(AGENT_OUTPUT_SCHEMAS["actor"]["required_keys"]) +# Leading marker prepended by Claude Code v2.1.210+ when a subagent report +# matches instruction-shaped patterns. The scan is advisory; the payload is +# the original agent output and should be parsed normally once the marker +# is stripped. See: https://code.claude.com/docs/en/sub-agents +_HARNESS_MARKER_PREFIX = "[harness: subagent output matched instruction-shaped pattern(s):" + + +def _strip_harness_markers(text: str) -> tuple[str, list[str]]: + """Strip known provider harness marker lines from the start of ``text``. + + Returns ``(cleaned_text, [stripped_marker_line, ...])``. + Only lines whose stripped form starts with ``_HARNESS_MARKER_PREFIX`` + are removed; any other leading/trailing text is left intact so the + downstream parser still classifies it as unexpected prose. + """ + stripped_markers: list[str] = [] + lines = text.split("\n") + while lines and lines[0].strip().startswith(_HARNESS_MARKER_PREFIX): + stripped_markers.append(lines.pop(0)) + return "\n".join(lines), stripped_markers + def detect_truncated_agent_output( text: str, @@ -11074,6 +11095,9 @@ def detect_truncated_agent_output( # "response ends mid-sentence" "parsed": dict | None, # the parsed object, or None on parse failure "agent_kind": str, # echoed for downstream logging + "harness_markers_stripped": [str, ...], + # provider marker lines removed before + # parsing (empty when no markers present) } ``expected_keys`` defaults per ``agent_kind``: monitor expects @@ -11090,6 +11114,22 @@ def detect_truncated_agent_output( "reasons": ["empty response"], "parsed": None, "agent_kind": agent_kind, + "harness_markers_stripped": [], + } + + # Strip known Claude harness marker lines before JSON parsing (#380). + # Claude Code v2.1.210+ may prepend a marker line to a subagent report + # when the output matches instruction-shaped patterns; these lines are + # not model prose and must not trigger the "leading text" rejection. + stripped, harness_markers = _strip_harness_markers(stripped) + stripped = stripped.strip() + if not stripped: + return { + "truncated": True, + "reasons": ["empty response"], + "parsed": None, + "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } parsed: Optional[dict[str, object]] = None @@ -11129,6 +11169,7 @@ def detect_truncated_agent_output( "reasons": reasons, "parsed": None, "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } # Validate required keys. @@ -11156,6 +11197,7 @@ def detect_truncated_agent_output( "reasons": reasons, "parsed": parsed, "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } diff --git a/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja b/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja index 187aa918..274da86e 100755 --- a/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +++ b/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja @@ -11046,6 +11046,27 @@ def save_research( _MONITOR_REQUIRED_KEYS = ("valid", "summary", "issues") _ACTOR_REQUIRED_KEYS = tuple(AGENT_OUTPUT_SCHEMAS["actor"]["required_keys"]) +# Leading marker prepended by Claude Code v2.1.210+ when a subagent report +# matches instruction-shaped patterns. The scan is advisory; the payload is +# the original agent output and should be parsed normally once the marker +# is stripped. See: https://code.claude.com/docs/en/sub-agents +_HARNESS_MARKER_PREFIX = "[harness: subagent output matched instruction-shaped pattern(s):" + + +def _strip_harness_markers(text: str) -> tuple[str, list[str]]: + """Strip known provider harness marker lines from the start of ``text``. + + Returns ``(cleaned_text, [stripped_marker_line, ...])``. + Only lines whose stripped form starts with ``_HARNESS_MARKER_PREFIX`` + are removed; any other leading/trailing text is left intact so the + downstream parser still classifies it as unexpected prose. + """ + stripped_markers: list[str] = [] + lines = text.split("\n") + while lines and lines[0].strip().startswith(_HARNESS_MARKER_PREFIX): + stripped_markers.append(lines.pop(0)) + return "\n".join(lines), stripped_markers + def detect_truncated_agent_output( text: str, @@ -11074,6 +11095,9 @@ def detect_truncated_agent_output( # "response ends mid-sentence" "parsed": dict | None, # the parsed object, or None on parse failure "agent_kind": str, # echoed for downstream logging + "harness_markers_stripped": [str, ...], + # provider marker lines removed before + # parsing (empty when no markers present) } ``expected_keys`` defaults per ``agent_kind``: monitor expects @@ -11090,6 +11114,22 @@ def detect_truncated_agent_output( "reasons": ["empty response"], "parsed": None, "agent_kind": agent_kind, + "harness_markers_stripped": [], + } + + # Strip known Claude harness marker lines before JSON parsing (#380). + # Claude Code v2.1.210+ may prepend a marker line to a subagent report + # when the output matches instruction-shaped patterns; these lines are + # not model prose and must not trigger the "leading text" rejection. + stripped, harness_markers = _strip_harness_markers(stripped) + stripped = stripped.strip() + if not stripped: + return { + "truncated": True, + "reasons": ["empty response"], + "parsed": None, + "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } parsed: Optional[dict[str, object]] = None @@ -11129,6 +11169,7 @@ def detect_truncated_agent_output( "reasons": reasons, "parsed": None, "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } # Validate required keys. @@ -11156,6 +11197,7 @@ def detect_truncated_agent_output( "reasons": reasons, "parsed": parsed, "agent_kind": agent_kind, + "harness_markers_stripped": harness_markers, } diff --git a/tests/test_map_step_runner.py b/tests/test_map_step_runner.py index 0daad86f..86beffbe 100644 --- a/tests/test_map_step_runner.py +++ b/tests/test_map_step_runner.py @@ -5018,6 +5018,106 @@ def test_map_efficient_monitor_output_not_truncated(self): assert report["truncated"] is False, report assert report["reasons"] == [] + # --- Harness marker tolerance (issue #380) --- + + def test_harness_marker_before_valid_monitor_json_not_truncated(self): + """Claude Code v2.1.210+ may prepend a harness marker line to a + subagent report; that marker must not cause the truncation gate to + reject a valid Monitor JSON payload.""" + marker = ( + "[harness: subagent output matched instruction-shaped pattern(s): " + "contains instructions]" + ) + payload = json.dumps({ + "valid": True, + "summary": "all checks passed", + "issues": [], + }) + text = marker + "\n" + payload + report = map_step_runner.detect_truncated_agent_output( + text, agent_kind="monitor" + ) + assert report["truncated"] is False, report + assert report["reasons"] == [] + assert report["harness_markers_stripped"] == [marker] + + def test_harness_marker_before_valid_actor_json_not_truncated(self): + """Harness marker tolerance applies to non-monitor agents.""" + marker = ( + "[harness: subagent output matched instruction-shaped pattern(s): " + "multi-step instructions]" + ) + payload = json.dumps({ + "files_changed": ["src/foo.py"], + "tests_run": ["pytest: 5 passed"], + "validation_notes": "ok", + "blocker": None, + }) + text = marker + "\n" + payload + report = map_step_runner.detect_truncated_agent_output( + text, agent_kind="actor" + ) + assert report["truncated"] is False, report + assert report["harness_markers_stripped"] == [marker] + + def test_arbitrary_prose_before_json_still_truncated(self): + """Non-marker leading prose is not stripped and still triggers + 'trailing or leading text around JSON object'.""" + payload = json.dumps({"valid": True, "summary": "ok", "issues": []}) + text = "Here is my analysis:\n" + payload + report = map_step_runner.detect_truncated_agent_output( + text, agent_kind="monitor" + ) + assert report["truncated"] is True + assert any( + "trailing or leading text" in r for r in report["reasons"] + ) + assert report["harness_markers_stripped"] == [] + + def test_multiple_harness_markers_all_stripped(self): + """When multiple consecutive harness marker lines appear, all are + stripped and the payload is still accepted.""" + marker1 = ( + "[harness: subagent output matched instruction-shaped pattern(s): " + "pattern A]" + ) + marker2 = ( + "[harness: subagent output matched instruction-shaped pattern(s): " + "pattern B]" + ) + payload = json.dumps({"valid": False, "summary": "issue found", "issues": ["x"]}) + text = marker1 + "\n" + marker2 + "\n" + payload + report = map_step_runner.detect_truncated_agent_output( + text, agent_kind="monitor" + ) + assert report["truncated"] is False, report + assert report["harness_markers_stripped"] == [marker1, marker2] + + def test_no_markers_harness_markers_stripped_is_empty(self): + """Clean JSON with no harness markers has an empty + ``harness_markers_stripped`` list.""" + payload = json.dumps({"valid": True, "summary": "ok", "issues": []}) + report = map_step_runner.detect_truncated_agent_output( + payload, agent_kind="monitor" + ) + assert report["truncated"] is False + assert report["harness_markers_stripped"] == [] + + def test_harness_marker_with_trailing_prose_still_truncated(self): + """A harness marker followed by arbitrary prose (not the JSON payload) + is still classified as malformed — only the marker itself is exempt.""" + marker = ( + "[harness: subagent output matched instruction-shaped pattern(s): x]" + ) + payload = json.dumps({"valid": True, "summary": "ok", "issues": []}) + text = marker + "\nHere is some prose before the JSON.\n" + payload + report = map_step_runner.detect_truncated_agent_output( + text, agent_kind="monitor" + ) + # prose between marker and JSON must still be flagged + assert report["truncated"] is True + assert report["harness_markers_stripped"] == [marker] + class TestBlueprintContractAffectedFilesDrift: """Decomposer drift catch: when every declared affected_files path is