Skip to content
Merged
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
97 changes: 97 additions & 0 deletions src/conductor/providers/_event_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Helpers for formatting tool-call event payloads emitted to subscribers.

The console renderer, JSONL event logger, and web dashboard all consume
``agent_tool_start`` / ``agent_tool_complete`` events from both providers.
These helpers ensure the payloads are human-readable strings rather than
Python ``repr()`` output of structured SDK objects.

See https://github.com/microsoft/conductor/issues/93.
"""

from __future__ import annotations

import json
from typing import Any


def format_tool_arguments(arguments: Any, max_length: int = 500) -> str | None:
"""Render tool-call arguments as a compact, human-readable string.

Dict-like arguments are JSON-encoded so the dashboard sees ``{"k": "v"}``
rather than Python repr (``{'k': 'v'}`` with single quotes and doubled
backslashes on Windows paths). Falls back to ``str(arguments)`` for
inputs that aren't JSON-serializable.

When the rendered string exceeds ``max_length``, it is truncated to
exactly ``max_length`` characters and a single-character ellipsis
(``"…"``) is appended, so the returned string is at most
``max_length + 1`` characters long.

Args:
arguments: The tool-call arguments object (typically a dict).
max_length: Maximum length of the rendered string before an
ellipsis is appended. Note: the returned string may be
``max_length + 1`` characters long when truncation occurs.

Returns:
A formatted string, or ``None`` when ``arguments`` is falsy
(including ``None``, ``""``, and ``{}``).
"""
if not arguments:
return None

try:
# ``default=str`` lets us serialize objects (e.g. Path) without crashing.
rendered = json.dumps(arguments, default=str, ensure_ascii=False)
except (TypeError, ValueError):
rendered = str(arguments)

if len(rendered) > max_length:
return rendered[:max_length] + "…"
return rendered


def extract_tool_result_text(result: Any, max_length: int = 500) -> str | None:
"""Extract human-readable text from a tool-call result.

The Copilot SDK emits structured ``Result(content=..., detailed_content=...,
contents=..., kind=...)`` objects whose ``str()`` is the unhelpful
Python repr — newlines escaped, paths doubled, wrapper visible. This
helper unwraps the text payload as follows:

1. Plain strings (e.g. from the Claude provider's ``MCPManager``) are
returned unchanged.
2. For other objects, the helper reads the ``content`` attribute; if
absent or ``None``, it falls back to ``detailed_content``.
3. If neither attribute yields a non-empty string, ``str(result)`` is
used as a last resort for unknown shapes.

When the extracted text exceeds ``max_length``, it is truncated to
exactly ``max_length`` characters and a single-character ellipsis
(``"…"``) is appended, so the returned string is at most
``max_length + 1`` characters long.

Args:
result: The tool result object emitted by the SDK.
max_length: Maximum length of the extracted text before an
ellipsis is appended. Note: the returned string may be
``max_length + 1`` characters long when truncation occurs.

Returns:
A formatted string, or ``None`` when ``result`` is falsy
(including ``None`` and ``""``).
"""
if not result:
return None

if isinstance(result, str):
text: str = result
else:
text_attr = getattr(result, "content", None)
if text_attr is None:
text_attr = getattr(result, "detailed_content", None)
text = text_attr if isinstance(text_attr, str) and text_attr else str(result)

if len(text) > max_length:
return text[:max_length] + "…"
return text
8 changes: 6 additions & 2 deletions src/conductor/providers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@

from conductor.exceptions import ProviderError, ValidationError
from conductor.executor.output import validate_output
from conductor.providers._event_format import (
extract_tool_result_text,
format_tool_arguments,
)
from conductor.providers.base import AgentOutput, AgentProvider, EventCallback, match_model_id
from conductor.providers.reasoning import (
ReasoningEffort,
Expand Down Expand Up @@ -1539,7 +1543,7 @@ async def _execute_agentic_loop(
if event_callback:
try:
arguments = (
str(dict(tool_use.input))[:500]
format_tool_arguments(dict(tool_use.input))
if hasattr(tool_use, "input") and tool_use.input
else None
)
Expand Down Expand Up @@ -1570,7 +1574,7 @@ async def _execute_agentic_loop(
"agent_tool_complete",
{
"tool_name": tool_use.name,
"result": str(result)[:500] if result else None,
"result": extract_tool_result_text(result),
},
)
except Exception:
Expand Down
17 changes: 8 additions & 9 deletions src/conductor/providers/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
from typing import TYPE_CHECKING, Any

from conductor.exceptions import ProviderError, ValidationError
from conductor.providers._event_format import (
extract_tool_result_text,
format_tool_arguments,
)
from conductor.providers.base import AgentOutput, AgentProvider, EventCallback, match_model_id
from conductor.providers.reasoning import ReasoningEffort, resolve_reasoning_effort

Expand Down Expand Up @@ -1219,8 +1223,7 @@ def _print(renderable: Any) -> None:
if full_mode:
args = getattr(event.data, "arguments", None) or getattr(event.data, "args", None)
if args:
args_str = str(args)
args_preview = args_str[:200] + "..." if len(args_str) > 200 else args_str
args_preview = format_tool_arguments(args, max_length=200) or ""
arg_text = Text()
arg_text.append(" │ ", style="dim")
arg_text.append("args: ", style="dim italic")
Expand All @@ -1241,11 +1244,7 @@ def _print(renderable: Any) -> None:
if full_mode:
result = getattr(event.data, "result", None) or getattr(event.data, "output", None)
if result:
result_str = str(result)
if len(result_str) > 200:
result_preview = result_str[:200] + "..."
else:
result_preview = result_str
result_preview = extract_tool_result_text(result, max_length=200) or ""
result_text = Text()
result_text.append(" │ ", style="dim")
result_text.append("result: ", style="dim italic")
Expand Down Expand Up @@ -1327,7 +1326,7 @@ def _forward_event(event_type: str, event: Any, callback: EventCallback) -> None
"agent_tool_start",
{
"tool_name": str(tool_name),
"arguments": str(arguments)[:500] if arguments else None,
"arguments": format_tool_arguments(arguments),
},
)

Expand All @@ -1340,7 +1339,7 @@ def _forward_event(event_type: str, event: Any, callback: EventCallback) -> None
"agent_tool_complete",
{
"tool_name": str(tool_name) if tool_name else None,
"result": str(result)[:500] if result else None,
"result": extract_tool_result_text(result),
},
)

Expand Down
8 changes: 6 additions & 2 deletions tests/test_providers/test_claude_event_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,9 @@ async def test_tool_arguments_truncated(self) -> None:

start_events = [(t, d) for t, d in events if t == "agent_tool_start"]
assert len(start_events) == 1
assert len(start_events[0][1]["arguments"]) <= 500
# Truncated to 500 chars + a single-char "…" ellipsis when long.
assert len(start_events[0][1]["arguments"]) <= 501
assert start_events[0][1]["arguments"].endswith("…")

@pytest.mark.asyncio
async def test_tool_result_truncated(self) -> None:
Expand Down Expand Up @@ -422,7 +424,9 @@ async def test_tool_result_truncated(self) -> None:

complete_events = [(t, d) for t, d in events if t == "agent_tool_complete"]
assert len(complete_events) == 1
assert len(complete_events[0][1]["result"]) <= 500
# Truncated to 500 chars + a single-char "…" ellipsis when long.
assert len(complete_events[0][1]["result"]) <= 501
assert complete_events[0][1]["result"].endswith("…")


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading