Files
Giles Odigwe 93cbf6b3f0 Python: Parse MCP CallToolResult.structuredContent field to prevent tool results returning None (#6421)
* Parse structuredContent from MCP CallToolResult (#3313)

The _parse_tool_result_from_mcp method only iterated over the content
field from CallToolResult, ignoring the structuredContent field entirely.
MCP servers that return JSON data via structuredContent (e.g., Power BI
MCP) appeared to return None.

Add handling for structuredContent: when present, serialize it as JSON
text and append it to the result list. This preserves the data for the
LLM while maintaining backward compatibility with existing behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Parse MCP CallToolResult.structuredContent field to prevent tool results returning None

Fixes #3313

* Address review feedback: add default=str to json.dumps and remove .checkpoints/

- Add default=str to json.dumps for structuredContent serialization so
  non-JSON-serializable values (e.g. bytes) degrade gracefully instead
  of raising TypeError
- Remove all .checkpoints/ runtime artifacts from the repository
- Add **/.checkpoints/ to .gitignore to prevent future accidental commits
- Add test for non-serializable structuredContent values

Fixes #3313

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #3313: Python: MCP CallToolResult.structuredContent field is not parsed, causing tool results to return None

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 12:51:09 +00:00

536 lines
17 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Main Textual application for the harness console.
This module provides the HarnessApp - the main Textual application that
composes all UI components and integrates with the agent runner.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Vertical
from textual.css.query import NoMatches
from textual.widgets import Input, Static
from .app_state import (
BottomPanelMode,
HarnessAppState,
OutputEntryType,
)
from .components import (
AgentModeAndHelp,
AgentStatus,
HarnessListSelection,
HarnessScrollPanel,
HarnessTextInput,
PromptRule,
)
from .textual_state_driver import HarnessConsoleUXStateDriver
if TYPE_CHECKING:
from agent_framework import Agent, AgentSession
from .agent_runner import HarnessAgentRunner
from .commands import CommandHandler
from .observers.base import ConsoleObserver
class HarnessApp(App[None]):
"""Main Textual application for the harness console.
Composes the scroll panel (conversation history), status bar (spinner, usage),
mode/help display, and bottom panel (text input, list selection, or streaming
indicator). Routes user input to the agent runner.
"""
CSS = """
Screen {
background: $background;
}
#scroll-panel {
height: 1fr;
padding: 0 1;
background: transparent;
}
#bottom-panel {
height: auto;
}
#text-input-container {
height: 1;
display: block;
}
#list-selection-container {
height: auto;
max-height: 12;
display: none;
}
#streaming-indicator {
height: 1;
display: none;
}
#status-bar {
height: 1;
}
#mode-help {
height: 1;
}
#top-rule {
height: 1;
}
#bottom-rule {
height: 1;
}
#separator-rule {
height: 1;
}
#text-input {
height: 1;
}
.hidden {
display: none;
}
.visible {
display: block;
}
.input-field {
border: none;
padding: 0;
min-height: 1;
height: 1;
background: transparent;
}
.input-field:focus {
border: none;
background: transparent;
}
.prompt-container {
height: 1;
}
.prompt-label {
width: 2;
min-width: 2;
height: 1;
}
"""
BINDINGS = [
Binding("ctrl+c", "quit", "Quit", show=False),
Binding("ctrl+q", "quit", "Quit", show=False),
]
def __init__(
self,
agent: Agent,
observers: list[ConsoleObserver],
session: AgentSession | None = None,
mode_colors: dict[str, str] | None = None,
initial_mode: str | None = None,
placeholder: str = "Type a message and press Enter...",
title: str = "Harness Console",
max_context_window_tokens: int | None = None,
max_output_tokens: int | None = None,
command_handlers: list[CommandHandler] | None = None,
) -> None:
"""Initialize the harness console application.
Args:
agent: The agent to run.
observers: List of console observers.
session: Optional agent session.
mode_colors: Optional mode color mapping.
initial_mode: Initial agent mode.
placeholder: Input placeholder text.
title: Application title.
max_context_window_tokens: Optional max context window tokens for usage display.
max_output_tokens: Optional max output tokens for usage display.
command_handlers: Optional list of command handlers. If None, auto-detected.
"""
super().__init__()
self.title = title
self._agent = agent
self._observers = observers
self._session = session
self._mode_colors = mode_colors
self._initial_mode = initial_mode
self._placeholder = placeholder
self._max_context_window_tokens = max_context_window_tokens
self._max_output_tokens = max_output_tokens
# Build command handlers
if command_handlers is None:
from .commands import build_default_command_handlers
self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors)
else:
self._command_handlers = command_handlers
# Compute help text from command handlers
help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None]
help_text = ", ".join(help_parts) if help_parts else None
# State and driver
self._app_state = HarnessAppState(
placeholder=placeholder,
mode_text=initial_mode,
help_text=help_text,
)
self._ux_driver = HarnessConsoleUXStateDriver(
app_state=self._app_state,
on_state_changed=self._on_state_changed,
mode_colors=mode_colors,
)
# Agent runner (created after init)
self._runner: HarnessAgentRunner | None = None
@property
def ux_driver(self) -> HarnessConsoleUXStateDriver:
"""Get the UX state driver."""
return self._ux_driver
@property
def runner(self) -> HarnessAgentRunner | None:
"""Get the agent runner."""
return self._runner
def compose(self) -> ComposeResult:
"""Compose the application layout."""
with Vertical():
# Main scroll panel for conversation history
yield HarnessScrollPanel(id="scroll-panel")
# Blank line separating scroll content from status area
yield Static(" ", id="separator-rule")
# Status bar (spinner + usage)
yield AgentStatus(id="status-bar")
# Top rule (mode-colored)
yield PromptRule(id="top-rule")
# Bottom panel - switches between text input, list selection, streaming
with Container(id="bottom-panel"):
# Text input (default)
with Container(id="text-input-container"):
text_input = HarnessTextInput(id="text-input")
text_input.placeholder = self._placeholder
yield text_input
# List selection (for follow-up questions)
with Container(id="list-selection-container"):
yield HarnessListSelection(id="list-selection")
# Bottom rule (mode-colored)
yield PromptRule(id="bottom-rule")
# Mode and help
yield AgentModeAndHelp(id="mode-help")
def on_mount(self) -> None:
"""Initialize after mount."""
# Create agent runner now that everything is set up
from .agent_runner import HarnessAgentRunner
self._runner = HarnessAgentRunner(
agent=self._agent,
observers=self._observers,
state_driver=self._ux_driver,
max_context_window_tokens=self._max_context_window_tokens,
max_output_tokens=self._max_output_tokens,
)
# Set initial mode
if self._initial_mode:
self._ux_driver.current_mode = self._initial_mode
# Focus the text input
try:
text_input = self.query_one("#text-input", HarnessTextInput)
text_input.focus_input()
except NoMatches:
pass
# Set initial rule colors and mode display
self._sync_mode_help()
# --- Event handlers ---
@on(HarnessTextInput.Submitted)
def on_text_submitted(self, event: HarnessTextInput.Submitted) -> None:
"""Handle text input submission."""
text = event.value.strip()
if not text:
return
if self._app_state.pending_questions:
# Answer the current follow-up question
self._handle_follow_up_answer(text)
elif self._app_state.mode == BottomPanelMode.STREAMING:
# Input during streaming (message injection placeholder)
pass
elif text.startswith("/"):
# Try command handlers
self._try_command_handlers(text)
else:
# Normal user input - run agent turn
self._run_agent_turn(text)
@work(exclusive=True, thread=False)
async def _try_command_handlers(self, text: str) -> None:
"""Try each command handler; fall through to agent if none match."""
session = self._session
if session is None:
# No session — fall through to agent turn
self._run_agent_turn(text)
return
for handler in self._command_handlers:
if await handler.try_handle(text, session, self._ux_driver):
# Command handled — check for shutdown/session swap signals
self._process_command_signals()
return
# No handler matched — treat as normal agent input
self._run_agent_turn(text)
def _process_command_signals(self) -> None:
"""Check and process signals set by command handlers."""
if self._app_state.shutdown_requested:
self.exit()
return
if self._app_state.replaced_session is not None:
self._session = self._app_state.replaced_session # type: ignore[assignment]
self._app_state.replaced_session = None
self._ux_driver.append_info_line("Session replaced.")
self._sync_ui_from_state()
@on(HarnessListSelection.Selected)
def on_list_selected(self, event: HarnessListSelection.Selected) -> None:
"""Handle list selection."""
self._handle_follow_up_answer(event.value)
# --- Agent turn ---
@work(exclusive=True, thread=False)
async def _run_agent_turn(self, text: str) -> None:
"""Run an agent turn in a background worker."""
if self._runner is None:
return
await self._runner.run_turn(text, session=self._session)
# After turn completes, check for follow-up questions
self._sync_ui_from_state()
# --- Follow-up question handling ---
@work(exclusive=True, thread=False)
async def _handle_follow_up_answer(self, answer: str) -> None:
"""Handle a user's answer to a follow-up question."""
if not self._app_state.pending_questions:
return
question = self._app_state.pending_questions[0]
# Call the continuation
result_message = await question.continuation(answer, self._ux_driver)
# Add result to accumulated responses
if result_message is not None:
self._ux_driver.add_follow_up_response(result_message)
# Advance to next question
self._ux_driver.advance_follow_up_question()
# If no more questions, resume the agent with accumulated responses
if not self._app_state.pending_questions:
responses = self._ux_driver.take_follow_up_responses()
if responses and self._runner:
await self._runner.start_agent_turn(responses, session=self._session)
self._sync_ui_from_state()
# --- State synchronization ---
def _on_state_changed(self) -> None:
"""Called by state driver when state changes - schedule UI sync.
Since the agent runner uses @work(thread=False), state changes happen
on the main event loop. We use call_later to batch updates.
"""
self.call_later(self._sync_ui_from_state)
def _sync_ui_from_state(self) -> None:
"""Synchronize UI components with current application state."""
state = self._app_state
# Update scroll panel with new entries
self._sync_scroll_panel()
# Update bottom panel mode
self._sync_bottom_panel(state.mode)
# Hide status bar and mode/help during list selection (matching C#)
is_list_mode = state.mode == BottomPanelMode.LIST_SELECTION
self._sync_chrome_visibility(not is_list_mode)
# Update status bar
self._sync_status_bar()
# Update mode/help display
self._sync_mode_help()
def _sync_scroll_panel(self) -> None:
"""Sync the scroll panel with output entries."""
try:
panel = self.query_one("#scroll-panel", HarnessScrollPanel)
except NoMatches:
return
entries = self._app_state.output_entries
rendered_count = getattr(self, "_rendered_entry_count", 0)
if rendered_count < len(entries):
# There are new entries to render
for entry in entries[rendered_count:]:
if entry.type == OutputEntryType.STREAMING_TEXT:
panel.set_streaming_entry(entry)
else:
# End any active streaming before appending other entry types
panel.end_streaming()
panel.append_entry(entry)
self._rendered_entry_count = len(entries)
elif rendered_count == len(entries) and entries:
# Same count — check if the last entry is a streaming entry that was mutated
last_entry = entries[-1]
if last_entry.type == OutputEntryType.STREAMING_TEXT:
panel.set_streaming_entry(last_entry)
def _sync_bottom_panel(self, mode: BottomPanelMode) -> None:
"""Switch the bottom panel between text input, list, and streaming."""
try:
text_container = self.query_one("#text-input-container")
list_container = self.query_one("#list-selection-container")
except NoMatches:
return
if mode == BottomPanelMode.TEXT_INPUT:
text_container.display = True
list_container.display = False
# Restore focus to text input
try:
text_input = self.query_one("#text-input", HarnessTextInput)
text_input.focus_input()
except NoMatches:
pass
elif mode == BottomPanelMode.LIST_SELECTION:
text_container.display = False
list_container.display = True
self._sync_list_selection()
elif mode == BottomPanelMode.STREAMING:
text_container.display = True
list_container.display = False
def _sync_list_selection(self) -> None:
"""Sync the list selection widget with state."""
try:
list_widget = self.query_one("#list-selection", HarnessListSelection)
except NoMatches:
return
state = self._app_state
list_widget.title = state.list_selection_title or ""
list_widget.options = list(state.list_selection_options)
list_widget.allow_custom_text = state.list_selection_custom_text_placeholder is not None
if state.list_selection_custom_text_placeholder:
try:
custom_input = list_widget.query_one("#custom-input", Input)
custom_input.placeholder = state.list_selection_custom_text_placeholder
except Exception:
pass
# Focus the option list so keyboard navigation works immediately
list_widget.focus_list()
def _sync_status_bar(self) -> None:
"""Sync the status bar with state."""
try:
status = self.query_one("#status-bar", AgentStatus)
except NoMatches:
return
state = self._app_state
status.show_spinner = state.show_spinner
status.usage_text = state.usage_text or ""
def _sync_mode_help(self) -> None:
"""Sync the mode/help display and rule colors with state."""
try:
mode_help = self.query_one("#mode-help", AgentModeAndHelp)
except NoMatches:
return
state = self._app_state
mode_help.mode = state.mode_text or ""
mode_help.mode_color = state.mode_color or "blue"
mode_help.help_text = state.help_text or ""
# Sync rule colors to match mode
color = state.mode_color or "cyan"
try:
top_rule = self.query_one("#top-rule", PromptRule)
top_rule.rule_color = color
except NoMatches:
pass
try:
bottom_rule = self.query_one("#bottom-rule", PromptRule)
bottom_rule.rule_color = color
except NoMatches:
pass
def _sync_chrome_visibility(self, visible: bool) -> None:
"""Show or hide chrome elements (status bar, mode/help).
During list selection mode, these are hidden to give more vertical
space to the scroll panel and list picker.
Args:
visible: Whether chrome elements should be visible.
"""
import contextlib
with contextlib.suppress(NoMatches):
self.query_one("#status-bar", AgentStatus).display = visible
with contextlib.suppress(NoMatches):
self.query_one("#mode-help", AgentModeAndHelp).display = visible
# --- Rendering count tracking ---
_rendered_entry_count: int = 0