Files
pallas/pallas/progress.py
Robert Helewka c18a477cda feat: replace MCPToolProgressManager with EnrichedMCPToolProgressManager
Swap out the standard `MCPToolProgressManager` from fast-agent with
the local `EnrichedMCPToolProgressManager` from `pallas.progress` to
provide richer progress reporting during tool execution in the
multimodal MCP server.
2026-04-18 06:02:47 -04:00

150 lines
4.8 KiB
Python

"""
Enriched MCP progress reporting for Pallas agents.
Wraps fast-agent's ``MCPToolProgressManager`` so that progress notifications
sent over MCP carry semantic detail — argument previews on tool start,
result summaries on tool completion — instead of bare ``started`` /
``completed`` markers.
The MCP ``notifications/progress`` payload only has room for a string
``message``, so all extra detail is packed into that one field. Daedalus
renders it verbatim, which is enough for the operator to tell whether an
agent is making real progress or spinning.
"""
from __future__ import annotations
import json
from typing import Any
from fast_agent.mcp.tool_progress import MCPToolProgressManager
from mcp.types import ContentBlock, ImageContent, TextContent
_MAX_ARGS_PREVIEW = 120
_MAX_RESULT_PREVIEW = 160
def _stringify(value: Any) -> str:
if isinstance(value, str):
return value
try:
return json.dumps(value, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return str(value)
def _truncate(text: str, limit: int) -> str:
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
def format_args_preview(arguments: dict | None) -> str:
"""Render a one-line preview of tool arguments for progress messages.
Picks the most informative value when there are multiple keys: the
longest string wins (queries, prompts, code), otherwise falls back
to a compact ``key=value, ...`` rendering.
"""
if not arguments:
return ""
string_values = [
(k, v) for k, v in arguments.items() if isinstance(v, str) and v.strip()
]
if string_values:
key, value = max(string_values, key=lambda kv: len(kv[1]))
return _truncate(f"{key}={value}" if len(arguments) > 1 else value, _MAX_ARGS_PREVIEW)
parts = [f"{k}={_stringify(v)}" for k, v in arguments.items()]
return _truncate(", ".join(parts), _MAX_ARGS_PREVIEW)
def format_result_preview(content: list[ContentBlock] | None) -> str:
"""Render a one-line preview of a tool's result content."""
if not content:
return "ok"
text_parts: list[str] = []
image_count = 0
other_count = 0
for block in content:
if isinstance(block, TextContent) and block.text:
text_parts.append(block.text)
elif isinstance(block, ImageContent):
image_count += 1
else:
other_count += 1
if text_parts:
joined = " ".join(text_parts)
preview = _truncate(joined, _MAX_RESULT_PREVIEW)
suffix_bits = []
if image_count:
suffix_bits.append(f"+{image_count} image{'s' if image_count != 1 else ''}")
if other_count:
suffix_bits.append(f"+{other_count} block{'s' if other_count != 1 else ''}")
if suffix_bits:
return f"{preview} ({', '.join(suffix_bits)})"
return preview
if image_count or other_count:
bits = []
if image_count:
bits.append(f"{image_count} image{'s' if image_count != 1 else ''}")
if other_count:
bits.append(f"{other_count} block{'s' if other_count != 1 else ''}")
return ", ".join(bits)
return "ok"
class EnrichedMCPToolProgressManager(MCPToolProgressManager):
"""Pallas progress manager that adds arg/result previews to MCP messages.
Drop-in replacement for ``MCPToolProgressManager``. Overrides only the
label and start/complete messaging — progress and permission events
keep the parent's behaviour.
"""
async def on_tool_start(
self,
tool_name: str,
server_name: str,
arguments: dict | None,
tool_use_id: str | None = None,
) -> str:
tool_call_id = await super().on_tool_start(
tool_name, server_name, arguments, tool_use_id
)
preview = format_args_preview(arguments)
if preview:
label = self._tool_labels.get(tool_call_id, f"{server_name}/{tool_name}")
self._tool_labels[tool_call_id] = f"{label}({preview})"
return tool_call_id
async def on_tool_complete(
self,
tool_call_id: str,
success: bool,
content: list[ContentBlock] | None,
error: str | None,
) -> None:
if success:
await self._report_progress(
1.0,
1.0,
self._format_message(
tool_call_id, message=f"{format_result_preview(content)}"
),
)
self._tool_labels.pop(tool_call_id, None)
tool_use_ids = self._tool_use_by_call.pop(tool_call_id, set())
for tool_use_id in tool_use_ids:
self._tool_use_map.pop(tool_use_id, None)
return
await super().on_tool_complete(tool_call_id, success, content, error)