mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
93cbf6b3f0
* 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>
536 lines
17 KiB
Python
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
|