Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions src/dedalus_labs/lib/runner/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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', '?')})")
Comment thread
cursor[bot] marked this conversation as resolved.
print(f" - {_tc_name(tc)} (id: {_tc_id(tc)})")
await self._execute_tool_calls(
tool_calls,
tool_handler,
Expand Down Expand Up @@ -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]}...]"
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]}...]"
Expand Down Expand Up @@ -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']}")
Expand Down Expand Up @@ -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}")
Expand Down
58 changes: 58 additions & 0 deletions tests/lib/test_runner_verbose.py
Original file line number Diff line number Diff line change
@@ -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]