diff --git a/src/dedalus_labs/lib/runner/core.py b/src/dedalus_labs/lib/runner/core.py index a295f09..cd0d91d 100644 --- a/src/dedalus_labs/lib/runner/core.py +++ b/src/dedalus_labs/lib/runner/core.py @@ -71,6 +71,17 @@ def _extract_mcp_results(response: Any) -> list[MCPToolResult]: return [item if isinstance(item, MCPToolResult) else MCPToolResult.model_validate(item) for item in mcp_results] +def _tool_call_name(tc: Any) -> str: + """Return a tool call's function name, accepting either dicts or SDK model objects.""" + try: + if isinstance(tc, dict): + fn = tc.get("function", {}) + return (fn.get("name", "?") if isinstance(fn, dict) else getattr(fn, "name", "?")) or "?" + return getattr(getattr(tc, "function", None), "name", "?") or "?" + except Exception: + return "?" + + class _ToolHandler(Protocol): def schemas(self) -> list[Dict]: ... async def exec(self, name: str, args: Dict[str, JsonValue]) -> JsonValue: ... @@ -601,7 +612,8 @@ 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', '?')})") + tc_id = tc.get("id", "?") if isinstance(tc, dict) else getattr(tc, "id", "?") + print(f" - {_tool_call_name(tc)} (id: {tc_id})") await self._execute_tool_calls( tool_calls, tool_handler, @@ -646,7 +658,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 = [_tool_call_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 +743,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 = [_tool_call_name(tc) 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 +885,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 = [_tool_call_name(tc) for tc in tool_calls] print(f" 🔧 Tool calls in response: {tool_names}") if not tool_calls: @@ -921,7 +933,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 = [_tool_call_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 +963,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 = [_tool_call_name(tc) 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 +1030,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 = _tool_call_name(tc) # 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/test_runner_verbose.py b/tests/test_runner_verbose.py new file mode 100644 index 0000000..0a4d89a --- /dev/null +++ b/tests/test_runner_verbose.py @@ -0,0 +1,73 @@ +# ============================================================================== +# © 2025 Dedalus Labs, Inc. and affiliates +# Licensed under MIT +# github.com/dedalus-labs/dedalus-sdk-python/LICENSE +# ============================================================================== + +"""Regression tests for verbose-mode logging in DedalusRunner. + +Reproduces dedalus-labs/dedalus-sdk-python#54: ``verbose=True`` crashed with an +``AttributeError`` on the first response containing tool calls because the +verbose print path called ``.get(...)`` on SDK model objects (which behave like +attribute-only objects, not dicts). +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from dedalus_labs.lib.runner.core import DedalusRunner, _tool_call_name + + +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +def _model_tool_call(call_id: str, name: str, arguments: str): + """A tool call shaped like an SDK model object (attribute access, no ``.get``).""" + return SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name=name, arguments=arguments), + ) + + +def _response_with_tool_calls(*tool_calls): + message = SimpleNamespace(content=None, tool_calls=list(tool_calls)) + return SimpleNamespace(model="test-model", choices=[SimpleNamespace(message=message)]) + + +def _response_with_text(text: str): + message = SimpleNamespace(content=text, tool_calls=None) + return SimpleNamespace(model="test-model", choices=[SimpleNamespace(message=message)]) + + +def test_tool_call_name_accepts_dicts_and_model_objects(): + assert _tool_call_name({"function": {"name": "add"}}) == "add" + assert _tool_call_name(_model_tool_call("c1", "add", "{}")) == "add" + assert _tool_call_name({"function": SimpleNamespace(name="add")}) == "add" + assert _tool_call_name({}) == "?" + assert _tool_call_name(SimpleNamespace()) == "?" + + +def test_run_verbose_with_model_tool_calls_does_not_crash(capsys): + client = MagicMock() + client.chat.completions.create.side_effect = [ + _response_with_tool_calls(_model_tool_call("call_1", "add", '{"a": 3, "b": 4}')), + _response_with_text("The sum is 7."), + ] + client.mcp_tool_results = None + + runner = DedalusRunner(client, verbose=True) + result = runner.run( + model="openai/gpt-test", + input="What is 3 + 4? You MUST call the add tool.", + tools=[add], + ) + + assert result.tools_called == ["add"] + assert result.final_output == "The sum is 7." + # The verbose path should have printed the tool call name, not "?". + assert "add" in capsys.readouterr().out