From 76bac152133b89615c4f1d4efb40850703c6c7d4 Mon Sep 17 00:00:00 2001 From: Islam Assanov Date: Tue, 12 May 2026 23:38:07 +0500 Subject: [PATCH] fix(runner): tolerate Pydantic tool_call objects in verbose logging --- src/dedalus_labs/lib/runner/core.py | 39 +++++++++++-------- tests/lib/test_runner_verbose.py | 58 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 tests/lib/test_runner_verbose.py diff --git a/src/dedalus_labs/lib/runner/core.py b/src/dedalus_labs/lib/runner/core.py index a295f09..1890661 100644 --- a/src/dedalus_labs/lib/runner/core.py +++ b/src/dedalus_labs/lib/runner/core.py @@ -42,6 +42,21 @@ from ..utils._schemas import to_schema +def _tc_name(tc: Any, default: str = "?") -> str: + """Read a tool call's function name regardless of dict vs Pydantic shape.""" + if isinstance(tc, dict): + return tc.get("function", {}).get("name", default) + fn = getattr(tc, "function", None) + return getattr(fn, "name", default) if fn is not None else default + + +def _tc_id(tc: Any, default: str = "?") -> str: + """Read a tool call's id regardless of dict vs Pydantic shape.""" + if isinstance(tc, dict): + return tc.get("id", default) + return getattr(tc, "id", default) + + def _process_policy(policy: PolicyInput, context: PolicyContext) -> Dict[str, JsonValue]: """Process policy, handling all possible input types safely.""" if policy is None: @@ -578,15 +593,7 @@ async def _execute_turns_async( if exec_config.verbose: print(f" Response content: {content[:100] if content else '(none)'}...") if tool_calls: - call_names = [] - for tc in tool_calls: - try: - if isinstance(tc, dict): - call_names.append(tc.get("function", {}).get("name", "?")) - else: - call_names.append(getattr(getattr(tc, "function", None), "name", "?")) - except Exception: - call_names.append("?") + call_names = [_tc_name(tc) for tc in tool_calls] print(f" Tool calls in response: {call_names}") if not tool_calls: @@ -601,7 +608,7 @@ async def _execute_turns_async( if exec_config.verbose: print(f" Extracted {len(tool_calls)} tool calls") for tc in tool_calls: - print(f" - {tc.get('function', {}).get('name', '?')} (id: {tc.get('id', '?')})") + print(f" - {_tc_name(tc)} (id: {_tc_id(tc)})") await self._execute_tool_calls( tool_calls, tool_handler, @@ -646,7 +653,7 @@ async def _execute_streaming_async( content = str(msg.get("content", ""))[:50] + "..." if msg.get("content") else "" tool_info = "" if msg.get("tool_calls"): - tool_names = [tc.get("function", {}).get("name", "?") for tc in msg.get("tool_calls", [])] + tool_names = [_tc_name(tc) for tc in msg.get("tool_calls", [])] tool_info = f" [calling: {', '.join(tool_names)}]" elif msg.get("tool_call_id"): tool_info = f" [response to: {msg.get('tool_call_id')[:8]}...]" @@ -731,7 +738,7 @@ async def _execute_streaming_async( if exec_config.verbose: # Keep a compact end-of-stream summary - names = [tc.get("function", {}).get("name", "unknown") for tc in tool_calls] + names = [_tc_name(tc, "unknown") for tc in tool_calls] print(f" Stream summary: chunks={chunk_count} content={content_chunks} tool_calls={tool_call_chunks}") if names: print(f" Tools called this turn: {names}") @@ -873,7 +880,7 @@ def _execute_turns_sync( if exec_config.verbose: print(f" Response content: {content[:100] if content else '(none)'}...") if tool_calls: - tool_names = [tc.get("function", {}).get("name", "?") for tc in tool_calls] + tool_names = [_tc_name(tc) for tc in tool_calls] print(f" 🔧 Tool calls in response: {tool_names}") if not tool_calls: @@ -921,7 +928,7 @@ def _execute_streaming_sync( content = str(msg.get("content", ""))[:50] + "..." if msg.get("content") else "" tool_info = "" if msg.get("tool_calls"): - tool_names = [tc.get("function", {}).get("name", "?") for tc in msg.get("tool_calls", [])] + tool_names = [_tc_name(tc) for tc in msg.get("tool_calls", [])] tool_info = f" [calling: {', '.join(tool_names)}]" elif msg.get("tool_call_id"): tool_info = f" [response to: {msg.get('tool_call_id')[:8]}...]" @@ -951,7 +958,7 @@ def _execute_streaming_sync( content_preview = str(msg.get("content", ""))[:100] tool_call_info = "" if msg.get("tool_calls"): - tool_names = [tc.get("function", {}).get("name", "unknown") for tc in msg.get("tool_calls", [])] + tool_names = [_tc_name(tc, "unknown") for tc in msg.get("tool_calls", [])] tool_call_info = f" tool_calls=[{', '.join(tool_names)}]" print(f" [{i}] {msg.get('role')}: {content_preview}...{tool_call_info}") print(f" MCP servers: {policy_result['mcp_servers']}") @@ -1018,7 +1025,7 @@ def _execute_streaming_sync( if tool_calls: print(f"\nReceived {len(tool_calls)} tool call(s)") for i, tc in enumerate(tool_calls, 1): - tool_name = tc.get("function", {}).get("name", "unknown") + tool_name = _tc_name(tc, "unknown") # Clean up the tool name for display display_name = tool_name.replace("transfer_to_", "").replace("_", " ").title() print(f" {i}. {display_name}") diff --git a/tests/lib/test_runner_verbose.py b/tests/lib/test_runner_verbose.py new file mode 100644 index 0000000..427fe83 --- /dev/null +++ b/tests/lib/test_runner_verbose.py @@ -0,0 +1,58 @@ +"""Regression test: verbose=True must not crash on Pydantic tool_call objects. + +Prior to the fix, several verbose-print sites in `runner/core.py` called +`tc.get(...)` on `ChatCompletionMessageToolCall` Pydantic objects, raising +`AttributeError`. This test drives a synthetic Pydantic response through +the verbose-print helpers and asserts no exception. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from dedalus_labs.lib.runner.core import _tc_id, _tc_name + + +class _FakeFunction: + def __init__(self, name: str) -> None: + self.name = name + + +class _FakeToolCall: + """Stand-in for ChatCompletionMessageToolCall — Pydantic-shaped (attrs, no .get).""" + + def __init__(self, id: str, name: str) -> None: + self.id = id + self.function = _FakeFunction(name) + + +def test_tc_name_handles_dict_shape() -> None: + tc: dict[str, Any] = {"id": "c1", "function": {"name": "add"}} + assert _tc_name(tc) == "add" + assert _tc_id(tc) == "c1" + + +def test_tc_name_handles_pydantic_shape() -> None: + tc = _FakeToolCall(id="c1", name="add") + assert _tc_name(tc) == "add" + assert _tc_id(tc) == "c1" + + +def test_tc_name_default() -> None: + assert _tc_name({}, default="unknown") == "unknown" + assert _tc_id({}, default="zzz") == "zzz" + + class _Empty: + pass + + assert _tc_name(_Empty(), default="unknown") == "unknown" + assert _tc_id(_Empty(), default="zzz") == "zzz" + + +def test_tc_name_pydantic_raises_without_helper() -> None: + """Sanity: confirms the original bug shape (Pydantic objects have no .get).""" + tc = _FakeToolCall(id="c1", name="add") + with pytest.raises(AttributeError): + tc.get("function", {}).get("name", "?") # type: ignore[attr-defined]