diff --git a/agent/context_compressor.py b/agent/context_compressor.py index c6b18d148197c..4c7e28b4024e0 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1314,6 +1314,41 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten return "[todo] updated task list" if tool_name == "clarify": + response_prefix = "[clarify] user responded: " + max_summary_chars = 200 + truncation_marker = "...[truncated]" + + # Idempotence: a later pressure-pruning pass may see the summary + # produced by an earlier pass. Preserve it instead of trying to parse + # the summary text as the original JSON result. + if content.startswith(response_prefix): + if len(content) <= max_summary_chars: + return content + return ( + content[: max_summary_chars - len(truncation_marker)].rstrip() + + truncation_marker + ) + + try: + result = json.loads(content) + except (json.JSONDecodeError, TypeError): + result = {} + response = result.get("user_response") if isinstance(result, dict) else None + resolved = ( + isinstance(response, str) and bool(response) + ) or ( + isinstance(response, list) + and bool(response) + and all(isinstance(item, str) and item for item in response) + ) + if resolved: + summary = response_prefix + json.dumps(response, ensure_ascii=False) + if len(summary) > max_summary_chars: + summary = ( + summary[: max_summary_chars - len(truncation_marker)].rstrip() + + truncation_marker + ) + return summary return "[clarify] asked user a question" if tool_name == "text_to_speech": diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index a0e31e48333ed..ec702c6fffc37 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -61,6 +61,57 @@ class TestSummarizeToolResultWebExtract: assert summary == "[web_extract] https://example.com/h (500 chars)" +class TestSummarizeToolResultClarify: + def test_preserves_resolved_user_response_without_metadata(self): + content = json.dumps({ + "question": "When should I deploy?", + "choices_offered": ["Friday", "Monday"], + "user_response": "Friday", + }) + + summary = _summarize_tool_result("clarify", "{}", content) + + assert summary == '[clarify] user responded: "Friday"' + + def test_preserves_multi_select_user_response(self): + content = json.dumps({ + "question": "Which checks should I run?", + "choices_offered": ["lint", "tests", "types"], + "user_response": ["lint", "tests"], + }) + + summary = _summarize_tool_result("clarify", "{}", content) + + assert summary == '[clarify] user responded: ["lint", "tests"]' + + def test_long_response_is_bounded_and_survives_repeated_pruning(self): + content = json.dumps({ + "question": "Describe the deployment constraints", + "choices_offered": None, + "user_response": "A" * 1_000, + }) + + first_summary = _summarize_tool_result("clarify", "{}", content) + second_summary = _summarize_tool_result("clarify", "{}", first_summary) + + assert len(first_summary) == 200 + assert first_summary.startswith('[clarify] user responded: "AAA') + assert first_summary.endswith("...[truncated]") + assert second_summary == first_summary + + @pytest.mark.parametrize( + "content", + [ + json.dumps({"error": "Failed to get user input: internal details"}), + json.dumps({"question": "Q?", "user_response": ""}), + json.dumps({"question": "Q?", "user_response": {"internal": "value"}}), + "not json", + ], + ) + def test_does_not_expose_unresolved_or_internal_content(self, content): + summary = _summarize_tool_result("clarify", "{}", content) + + assert summary == "[clarify] asked user a question" class TestShouldCompress: