mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
bad05a2bdc
* Add initial harness console for python * Add textual to project * Add planning and approval flows with list selector * Address PR comments * Fix list selection bug * Fix PR #6312 round 2 review comments - Escape untrusted agent text with rich.markup.escape() in observers (text_output, planning_output, reasoning_display) to prevent markup injection - Remove non-functional 'Always approve' choices from tool_approval.py (framework lacks CreateAlwaysApproveToolResponse support) - Remove textual from root pyproject.toml dev deps (sample-specific) - Add PEP 723 inline script metadata to harness_research.py - Narrow except Exception to except NoMatches in list_selection.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix build error * Fix build errors --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
542 lines
17 KiB
Python
542 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
|