diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index b37bb35d9..b9c0eeb6e 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -554,6 +554,37 @@ class TestDelegateObservability(unittest.TestCase): self.assertIn("result_bytes", entry["tool_trace"][0]) self.assertEqual(entry["tool_trace"][0]["status"], "ok") + def test_tool_trace_handles_list_content_blocks(self): + """Tool-result content blocks should not crash observability metadata.""" + parent = _make_mock_parent(depth=0) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.model = "claude-sonnet-4-6" + mock_child.session_prompt_tokens = 0 + mock_child.session_completion_tokens = 0 + mock_child.run_conversation.return_value = { + "final_response": "done", + "completed": True, + "interrupted": False, + "api_calls": 1, + "messages": [ + {"role": "assistant", "tool_calls": [ + {"id": "tc_1", "function": {"name": "image_generate", "arguments": '{"prompt": "x"}'}} + ]}, + {"role": "tool", "tool_call_id": "tc_1", "content": [ + {"type": "text", "text": '{"success": true}'}, + ]}, + ], + } + MockAgent.return_value = mock_child + + result = json.loads(delegate_task(goal="Test list content", parent_agent=parent)) + trace = result["results"][0]["tool_trace"] + self.assertEqual(trace[0]["tool"], "image_generate") + self.assertEqual(trace[0]["status"], "ok") + self.assertGreater(trace[0]["result_bytes"], 0) + def test_tool_trace_detects_error(self): """Tool results containing 'error' should be marked as error status.""" parent = _make_mock_parent(depth=0) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 40e19a0e4..57998dcd0 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -276,7 +276,35 @@ def _extract_output_tail( return tail -def _looks_like_error_output(content: str) -> bool: +def _stringify_tool_content(content: Any) -> str: + """Return a stable text representation for tool-result content. + + Most providers store tool results as strings, but some OpenAI-compatible + paths can return content-block lists. Delegate observability must never + crash while summarising a child run just because the transport used blocks. + """ + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + else: + parts.append(json.dumps(item, ensure_ascii=False, default=str)) + else: + parts.append(str(item)) + return "\n".join(parts) + if isinstance(content, dict): + return json.dumps(content, ensure_ascii=False, default=str) + return str(content) + + +def _looks_like_error_output(content: Any) -> bool: """Conservative stderr/error detector for tool-result previews. The old heuristic flagged any preview containing the substring "error", @@ -286,6 +314,7 @@ def _looks_like_error_output(content: str) -> bool: - structured JSON with ``status`` of error/failed - first line starts with a classic error marker """ + content = _stringify_tool_content(content) if not content: return False @@ -1672,7 +1701,7 @@ def _run_single_child( if tc_id: trace_by_id[tc_id] = entry_t elif msg.get("role") == "tool": - content = msg.get("content", "") + content = _stringify_tool_content(msg.get("content", "")) is_error = _looks_like_error_output(content) result_meta = { "result_bytes": len(content),