mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Harness console for python (#6312)
* 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>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Console observers for agent streaming lifecycle.
|
||||
|
||||
This module provides observers that display events during agent streaming
|
||||
and collect follow-up actions. All observers use the IUXStateDriver interface
|
||||
to update the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ConsoleObserver
|
||||
from .error_display import ErrorDisplayObserver
|
||||
from .planning_output import PlanningOutputObserver
|
||||
from .reasoning_display import ReasoningDisplayObserver
|
||||
from .text_output import TextOutputObserver
|
||||
from .tool_approval import ToolApprovalObserver
|
||||
from .tool_call_display import ToolCallDisplayObserver
|
||||
from .usage_display import UsageDisplayObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
|
||||
def build_default_observers() -> list[ConsoleObserver]:
|
||||
"""Build the default set of observers for the harness console.
|
||||
|
||||
Returns a standard observer list covering:
|
||||
- Text output (streaming text display)
|
||||
- Tool call display (formatted tool invocations)
|
||||
- Error display (error messages)
|
||||
- Usage display (token counts)
|
||||
- Reasoning display (reasoning/thinking blocks)
|
||||
- Tool approval (user approval for tool calls)
|
||||
|
||||
Note: PlanningOutputObserver is NOT included here because it requires
|
||||
a mode_provider. Use build_observers_with_planning() for agents that
|
||||
have an AgentModeProvider (i.e. agents created with create_harness_agent).
|
||||
|
||||
Returns:
|
||||
List of default console observers.
|
||||
"""
|
||||
return [
|
||||
TextOutputObserver(),
|
||||
ToolCallDisplayObserver(),
|
||||
ErrorDisplayObserver(),
|
||||
UsageDisplayObserver(),
|
||||
ReasoningDisplayObserver(),
|
||||
ToolApprovalObserver(),
|
||||
]
|
||||
|
||||
|
||||
def build_observers_with_planning(
|
||||
agent: Agent,
|
||||
plan_mode_name: str = "plan",
|
||||
execution_mode_name: str = "execute",
|
||||
*,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> list[ConsoleObserver]:
|
||||
"""Build observers with planning support (structured output in plan mode).
|
||||
|
||||
Replaces TextOutputObserver with PlanningOutputObserver, which configures
|
||||
structured JSON output via response_format when in plan mode. This enables
|
||||
the list picker UI for clarification and approval questions.
|
||||
|
||||
Requires that the agent has an AgentModeProvider in its context_providers
|
||||
(automatically added by create_harness_agent).
|
||||
|
||||
Args:
|
||||
agent: The agent to resolve the AgentModeProvider from.
|
||||
plan_mode_name: The mode name that represents planning mode.
|
||||
execution_mode_name: The mode name to switch to on approval.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
|
||||
Returns:
|
||||
List of observers with planning support.
|
||||
|
||||
Raises:
|
||||
ValueError: If the agent has no AgentModeProvider.
|
||||
"""
|
||||
from agent_framework import AgentModeProvider
|
||||
|
||||
mode_provider = next(
|
||||
(p for p in agent.context_providers if isinstance(p, AgentModeProvider)),
|
||||
None,
|
||||
)
|
||||
if mode_provider is None:
|
||||
msg = (
|
||||
"Planning observers require an AgentModeProvider on the agent. "
|
||||
"Use create_harness_agent() or add AgentModeProvider to context_providers."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
return [
|
||||
ToolCallDisplayObserver(),
|
||||
ToolApprovalObserver(),
|
||||
ErrorDisplayObserver(),
|
||||
ReasoningDisplayObserver(),
|
||||
UsageDisplayObserver(),
|
||||
PlanningOutputObserver(
|
||||
mode_provider,
|
||||
plan_mode_name,
|
||||
execution_mode_name,
|
||||
mode_colors=mode_colors,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConsoleObserver",
|
||||
"ErrorDisplayObserver",
|
||||
"PlanningOutputObserver",
|
||||
"ReasoningDisplayObserver",
|
||||
"TextOutputObserver",
|
||||
"ToolApprovalObserver",
|
||||
"ToolCallDisplayObserver",
|
||||
"UsageDisplayObserver",
|
||||
"build_default_observers",
|
||||
"build_observers_with_planning",
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Base class for console observers.
|
||||
|
||||
Observers participate in the agent streaming lifecycle, displaying events
|
||||
and optionally returning follow-up actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content, Message
|
||||
|
||||
from ..app_state import FollowUpAction
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ConsoleObserver:
|
||||
"""Base class for console observers.
|
||||
|
||||
Observers participate in the agent streaming lifecycle, displaying
|
||||
events (tool calls, errors, reasoning, etc.) and optionally returning
|
||||
follow-up actions (questions, approval requests).
|
||||
|
||||
All methods have default no-op implementations, so subclasses only
|
||||
override the methods they need.
|
||||
"""
|
||||
|
||||
def configure_run_options(
|
||||
self,
|
||||
options: dict[str, Any],
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Configure run options before agent invocation.
|
||||
|
||||
Override to set options such as response_format, max_tokens, etc.
|
||||
|
||||
Args:
|
||||
options: Dictionary of chat options to modify.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_response_update(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
update: Message,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Called for each response update chunk.
|
||||
|
||||
Override to inspect update-level metadata or handle provider-specific
|
||||
events in the raw representation.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
update: The message update chunk.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Called for each content item in the response.
|
||||
|
||||
Override to handle specific content types (function calls, errors, etc.).
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item from the response.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_text(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
text: str,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Called for each text chunk in the response.
|
||||
|
||||
Override to accumulate and display streaming text.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
text: The text chunk.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list[FollowUpAction] | None:
|
||||
"""Called when streaming completes.
|
||||
|
||||
Override to return follow-up actions (questions to ask the user,
|
||||
messages to inject into the next turn, etc.).
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
|
||||
Returns:
|
||||
Optional list of follow-up actions to queue, or None.
|
||||
"""
|
||||
return None
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Error display observer for showing errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ErrorDisplayObserver(ConsoleObserver):
|
||||
"""Displays error content from the agent response.
|
||||
|
||||
Shows errors with an ❌ prefix in red to make them easily visible.
|
||||
"""
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Display error content.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check for errors.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
# Check if this is an error content type
|
||||
# The exact content type check depends on the agent framework's Content class
|
||||
if hasattr(content, "type") and content.type == "error":
|
||||
error_text = self._format_error(content)
|
||||
ux.append_info_line(error_text, "red")
|
||||
elif getattr(content, "error", None):
|
||||
error_text = f"❌ Error: {content.error}" # type: ignore[reportAttributeAccessIssue]
|
||||
ux.append_info_line(error_text, "red")
|
||||
|
||||
def _format_error(self, content: Content) -> str:
|
||||
"""Format error content for display.
|
||||
|
||||
Args:
|
||||
content: The error content.
|
||||
|
||||
Returns:
|
||||
Formatted error string.
|
||||
"""
|
||||
error_text = "❌ Error"
|
||||
|
||||
# Try to extract error message
|
||||
if hasattr(content, "message"):
|
||||
error_text += f": {content.message}"
|
||||
elif hasattr(content, "text"):
|
||||
error_text += f": {content.text}"
|
||||
|
||||
# Try to add error code if available
|
||||
if hasattr(content, "error_code") and content.error_code:
|
||||
error_text += f" (code: {content.error_code})"
|
||||
|
||||
# Try to add details if available
|
||||
if hasattr(content, "details") and getattr(content, "details", None):
|
||||
error_text += f" — {content.details}" # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
return error_text
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Pydantic models for structured planning output.
|
||||
|
||||
These models define the JSON schema that the agent produces when in planning
|
||||
mode via `response_format`. The schema enables consistent rendering of
|
||||
clarification questions and approval requests in the console UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlanningResponseType(str, Enum):
|
||||
"""Type of planning response from the agent."""
|
||||
|
||||
CLARIFICATION = "clarification"
|
||||
"""The agent needs clarification and presents options for the user to choose from."""
|
||||
|
||||
APPROVAL = "approval"
|
||||
"""The agent is seeking approval to proceed with execution."""
|
||||
|
||||
|
||||
class PlanningQuestion(BaseModel):
|
||||
"""A single question or item within a PlanningResponse.
|
||||
|
||||
For clarification: contains the question text and optional choices.
|
||||
For approval: contains the plan summary for the user to approve.
|
||||
"""
|
||||
|
||||
message: str = Field(
|
||||
description=(
|
||||
"For clarifications, this has the question that needs to be clarified "
|
||||
"with the user. For approvals, this would contain a summary of the "
|
||||
"execution plan that the user needs to approve."
|
||||
),
|
||||
)
|
||||
choices: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"For clarifications, this has a list of options that the user can "
|
||||
"choose from. null for approvals."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PlanningResponse(BaseModel):
|
||||
"""Structured response from the agent while in planning mode.
|
||||
|
||||
Used with structured output (`response_format`) to enable consistent
|
||||
rendering of clarification questions and approval requests.
|
||||
"""
|
||||
|
||||
type: PlanningResponseType = Field(
|
||||
description=(
|
||||
"Use 'clarification' when you need clarification around the user "
|
||||
"request and you want to present the user with options to choose from. "
|
||||
"Use 'approval' when you are ready to start execution, but need "
|
||||
"approval to start executing."
|
||||
),
|
||||
)
|
||||
questions: list[PlanningQuestion] = Field(
|
||||
description=(
|
||||
"For clarifications, this has one or more questions to ask the user "
|
||||
"(each with choices). For approvals, this has exactly one item "
|
||||
"containing the plan summary for the user to approve."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,242 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Planning output observer for structured agent responses in plan mode.
|
||||
|
||||
In planning mode, this observer configures structured JSON output via
|
||||
response_format, collects streamed text silently, then deserializes the
|
||||
result as a PlanningResponse to present clarification/approval questions.
|
||||
|
||||
In execution mode, text is streamed through directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from ..app_state import (
|
||||
ChoiceFollowUpQuestion,
|
||||
FollowUpAction,
|
||||
TextFollowUpQuestion,
|
||||
)
|
||||
from .base import ConsoleObserver
|
||||
from .planning_models import PlanningResponse, PlanningResponseType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentModeProvider, Message
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class PlanningOutputObserver(ConsoleObserver):
|
||||
"""Mode-aware observer that uses structured output in plan mode.
|
||||
|
||||
In planning mode:
|
||||
- Configures response_format to PlanningResponse schema
|
||||
- Collects streamed text silently
|
||||
- Deserializes JSON into PlanningResponse
|
||||
- Builds follow-up questions (clarification or approval)
|
||||
|
||||
In execution mode:
|
||||
- Streams text directly to the UX driver
|
||||
|
||||
If JSON parsing fails, falls back to rendering the raw text as regular
|
||||
output so the user always sees what the agent produced.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode_provider: AgentModeProvider,
|
||||
plan_mode_name: str,
|
||||
execution_mode_name: str,
|
||||
*,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the planning output observer.
|
||||
|
||||
Args:
|
||||
mode_provider: The mode provider for reading/switching modes.
|
||||
plan_mode_name: The mode name that represents planning mode.
|
||||
execution_mode_name: The mode name to switch to on approval.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
"""
|
||||
self._mode_provider = mode_provider
|
||||
self._plan_mode_name = plan_mode_name
|
||||
self._execution_mode_name = execution_mode_name
|
||||
self._mode_colors = mode_colors or {}
|
||||
self._text_collector: list[str] = []
|
||||
|
||||
def configure_run_options(
|
||||
self,
|
||||
options: dict[str, Any],
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Set response_format to PlanningResponse when in plan mode."""
|
||||
if self._is_planning_mode(session):
|
||||
options["response_format"] = PlanningResponse
|
||||
|
||||
async def on_text(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
text: str,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Collect text in plan mode; stream through in execute mode."""
|
||||
if self._is_planning_mode_from_ux(ux):
|
||||
self._text_collector.append(text)
|
||||
else:
|
||||
ux.write_text(escape(text))
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list[FollowUpAction] | None:
|
||||
"""Parse collected text as PlanningResponse and build follow-up actions."""
|
||||
if not self._is_planning_mode_from_ux(ux):
|
||||
self._text_collector.clear()
|
||||
return None
|
||||
|
||||
collected_text = "".join(self._text_collector)
|
||||
self._text_collector.clear()
|
||||
|
||||
if not collected_text.strip():
|
||||
return None
|
||||
|
||||
# Attempt to deserialize structured response
|
||||
try:
|
||||
planning_response = PlanningResponse.model_validate_json(collected_text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# JSON parsing failed — fall back to rendering as regular text
|
||||
ux.write_text(escape(collected_text))
|
||||
return None
|
||||
|
||||
if planning_response.type == PlanningResponseType.CLARIFICATION:
|
||||
return self._build_clarification_actions(planning_response)
|
||||
|
||||
if planning_response.type == PlanningResponseType.APPROVAL:
|
||||
if not planning_response.questions:
|
||||
ux.append_info_line("(approval response had no content)", "yellow")
|
||||
return None
|
||||
question = planning_response.questions[0]
|
||||
return [self._build_approval_action(question, session)]
|
||||
|
||||
# Unexpected type — fall back to rendering as regular text
|
||||
ux.write_text(escape(collected_text))
|
||||
return None
|
||||
|
||||
def _is_planning_mode(self, session: Any) -> bool:
|
||||
"""Check if session is in planning mode."""
|
||||
from agent_framework import get_agent_mode
|
||||
|
||||
try:
|
||||
current_mode = get_agent_mode(session)
|
||||
except (AttributeError, TypeError):
|
||||
return True # No mode provider → treat as planning
|
||||
return current_mode.lower() == self._plan_mode_name.lower()
|
||||
|
||||
def _is_planning_mode_from_ux(self, ux: IUXStateDriver) -> bool:
|
||||
"""Check if UX is in planning mode."""
|
||||
current = ux.current_mode
|
||||
if current is None:
|
||||
return True
|
||||
return current.lower() == self._plan_mode_name.lower()
|
||||
|
||||
def _build_clarification_actions(
|
||||
self,
|
||||
response: PlanningResponse,
|
||||
) -> list[FollowUpAction]:
|
||||
"""Build follow-up questions for clarification."""
|
||||
actions: list[FollowUpAction] = []
|
||||
|
||||
for question in response.questions:
|
||||
prompt = question.message
|
||||
cont = self._make_clarification_continuation(prompt)
|
||||
|
||||
if question.choices and len(question.choices) > 0:
|
||||
actions.append(
|
||||
ChoiceFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
choices=question.choices,
|
||||
allow_custom_text=True,
|
||||
continuation=cont,
|
||||
)
|
||||
)
|
||||
else:
|
||||
actions.append(
|
||||
TextFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
continuation=cont,
|
||||
)
|
||||
)
|
||||
|
||||
return actions
|
||||
|
||||
@staticmethod
|
||||
def _make_clarification_continuation(prompt: str):
|
||||
"""Create a clarification continuation closure capturing the prompt."""
|
||||
|
||||
async def continuation(
|
||||
answer: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> Message | None:
|
||||
if not answer.strip():
|
||||
ux.append_info_line(f"🔹 {prompt}\n └─ (no answer)", "dim")
|
||||
return None
|
||||
|
||||
ux.append_info_line(f"🔹 {prompt}\n └─ [green]{answer}[/green]", "dim")
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
return Message(role="user", contents=[f"Q: {prompt}\nA: {answer}"])
|
||||
|
||||
return continuation
|
||||
|
||||
def _build_approval_action(
|
||||
self,
|
||||
question: Any,
|
||||
session: Any,
|
||||
) -> ChoiceFollowUpQuestion:
|
||||
"""Build the approval follow-up question."""
|
||||
approve_option = "Approve and switch to execute mode"
|
||||
prompt = question.message
|
||||
|
||||
async def continuation(
|
||||
selection: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> Message | None:
|
||||
ux.append_info_line(
|
||||
f"🔹 {prompt}\n └─ [green]{selection}[/green]",
|
||||
"dim",
|
||||
)
|
||||
|
||||
if selection == approve_option:
|
||||
from agent_framework import set_agent_mode
|
||||
|
||||
set_agent_mode(session, self._execution_mode_name)
|
||||
exec_color = self._mode_colors.get(self._execution_mode_name)
|
||||
ux.set_mode(self._execution_mode_name, exec_color)
|
||||
ux.append_info_line(
|
||||
f"✅ Switched to {self._execution_mode_name} mode.",
|
||||
exec_color,
|
||||
)
|
||||
from agent_framework import Message
|
||||
|
||||
return Message(role="user", contents=["Approved"])
|
||||
|
||||
# Custom freeform input — treat as suggested changes
|
||||
from agent_framework import Message
|
||||
|
||||
return Message(role="user", contents=[selection])
|
||||
|
||||
return ChoiceFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
choices=[approve_option],
|
||||
allow_custom_text=True,
|
||||
continuation=continuation,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Reasoning display observer for showing thinking content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ReasoningDisplayObserver(ConsoleObserver):
|
||||
"""Displays reasoning/thinking content from the agent.
|
||||
|
||||
Some models (like o1) provide reasoning steps that show their
|
||||
internal thought process. This observer displays them with a 💭 prefix
|
||||
in a dimmed style.
|
||||
"""
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Display reasoning content.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check for reasoning.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
reasoning_text = self._extract_reasoning(content)
|
||||
if reasoning_text:
|
||||
# Display reasoning in dim style to differentiate from main output
|
||||
ux.append_info_line(f"💭 {escape(reasoning_text)}", "dim")
|
||||
|
||||
def _extract_reasoning(self, content: Content) -> str | None:
|
||||
"""Extract reasoning text from content.
|
||||
|
||||
Args:
|
||||
content: The content item to extract reasoning from.
|
||||
|
||||
Returns:
|
||||
The reasoning text, or None if no reasoning is present.
|
||||
"""
|
||||
# Check for reasoning content type
|
||||
if hasattr(content, "type") and content.type in {"text_reasoning", "reasoning"}:
|
||||
if hasattr(content, "text"):
|
||||
return content.text
|
||||
content_attr = getattr(content, "content", None)
|
||||
if content_attr:
|
||||
return str(content_attr)
|
||||
|
||||
# Check for reasoning attribute
|
||||
reasoning = getattr(content, "reasoning", None)
|
||||
if reasoning is not None:
|
||||
if isinstance(reasoning, str):
|
||||
return reasoning
|
||||
if hasattr(reasoning, "text"):
|
||||
return reasoning.text
|
||||
|
||||
# Check for thinking attribute (alternative name)
|
||||
thinking = getattr(content, "thinking", None)
|
||||
if thinking is not None:
|
||||
if isinstance(thinking, str):
|
||||
return thinking
|
||||
if hasattr(thinking, "text"):
|
||||
return thinking.text
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Text output observer for streaming agent text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class TextOutputObserver(ConsoleObserver):
|
||||
"""Displays streaming text output from the agent.
|
||||
|
||||
Writes text chunks incrementally to the UX state driver as they arrive,
|
||||
allowing real-time display during streaming.
|
||||
"""
|
||||
|
||||
async def on_text(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
text: str,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Write each text chunk directly to the UX driver.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
text: The text chunk to display.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
ux.write_text(escape(text))
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list | None:
|
||||
"""No-op on stream complete (state managed by UX driver).
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
|
||||
Returns:
|
||||
None (no follow-up actions).
|
||||
"""
|
||||
return None
|
||||
@@ -0,0 +1,139 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tool approval observer for user confirmation of tool calls.
|
||||
|
||||
Detects function_approval_request content items during streaming, displays
|
||||
approval notifications, and after the stream completes presents one
|
||||
ChoiceFollowUpQuestion per pending approval request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..app_state import ChoiceFollowUpQuestion, FollowUpAction
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content, Message
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ToolApprovalObserver(ConsoleObserver):
|
||||
"""Asks user to approve tool calls before execution.
|
||||
|
||||
Collects `function_approval_request` content during streaming and presents
|
||||
a multi-choice approval question for each after the stream completes.
|
||||
The continuation builds a `function_approval_response` Content to inject
|
||||
into the next agent turn.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the tool approval observer."""
|
||||
self._approval_requests: list[Content] = []
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Collect function_approval_request content for approval.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
if content.type == "function_approval_request":
|
||||
self._approval_requests.append(content)
|
||||
tool_name = self._format_tool_name(content)
|
||||
ux.append_info_line(f"⚠️ Approval needed: {tool_name}", "yellow")
|
||||
|
||||
async def on_stream_complete(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> list[FollowUpAction] | None:
|
||||
"""Build approval questions for collected requests.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
|
||||
Returns:
|
||||
List of ChoiceFollowUpQuestions, one per approval request.
|
||||
"""
|
||||
if not self._approval_requests:
|
||||
return None
|
||||
|
||||
actions: list[FollowUpAction] = []
|
||||
for request in self._approval_requests:
|
||||
actions.append(self._build_approval_question(request))
|
||||
|
||||
self._approval_requests.clear()
|
||||
return actions
|
||||
|
||||
def _build_approval_question(self, request: Content) -> ChoiceFollowUpQuestion:
|
||||
"""Build a multi-choice approval question for a single request."""
|
||||
tool_name = self._format_tool_name(request)
|
||||
prompt = f"🔐 Tool approval: {tool_name}"
|
||||
|
||||
# TODO(westey-m): Add "Always approve" options when the framework supports
|
||||
# CreateAlwaysApproveToolResponse / CreateAlwaysApproveToolWithArgumentsResponse.
|
||||
choices = [
|
||||
"Approve this call",
|
||||
"Deny",
|
||||
]
|
||||
|
||||
async def continuation(
|
||||
selection: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> Message | None:
|
||||
from agent_framework import Message
|
||||
|
||||
if selection == "Deny":
|
||||
response_content = request.to_function_approval_response(approved=False)
|
||||
action_label = "❌ Denied"
|
||||
color = "red"
|
||||
else:
|
||||
response_content = request.to_function_approval_response(approved=True)
|
||||
action_label = "✅ Approved"
|
||||
color = "green"
|
||||
|
||||
ux.append_info_line(
|
||||
f"🔹 {prompt}\n └─ [{color}]{action_label}[/{color}]",
|
||||
"dim",
|
||||
)
|
||||
|
||||
return Message(role="user", contents=[response_content])
|
||||
|
||||
return ChoiceFollowUpQuestion(
|
||||
prompt=prompt,
|
||||
choices=choices,
|
||||
allow_custom_text=False,
|
||||
continuation=continuation,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_tool_name(content: Content) -> str:
|
||||
"""Extract a readable tool name from approval request content."""
|
||||
# The function_call is stored on the approval request content
|
||||
function_call = getattr(content, "function_call", None)
|
||||
if function_call is not None:
|
||||
from ..formatters import build_default_formatters, format_tool_call
|
||||
|
||||
try:
|
||||
return format_tool_call(build_default_formatters(), function_call)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
# Fall back to name attribute
|
||||
name = getattr(function_call, "name", None)
|
||||
if name:
|
||||
return str(name)
|
||||
return "unknown tool"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tool call display observer using formatters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..formatters import build_default_formatters, format_tool_call
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, Content
|
||||
|
||||
from ..formatters import ToolCallFormatter
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ToolCallDisplayObserver(ConsoleObserver):
|
||||
"""Displays tool call notifications using formatters.
|
||||
|
||||
Shows tool calls with a 🔧 prefix and uses the formatter system to
|
||||
display them in a user-friendly format.
|
||||
"""
|
||||
|
||||
def __init__(self, formatters: list[ToolCallFormatter] | None = None) -> None:
|
||||
"""Initialize the tool call display observer.
|
||||
|
||||
Args:
|
||||
formatters: Optional list of tool formatters. If None, uses
|
||||
default formatters from build_default_formatters().
|
||||
"""
|
||||
self._formatters = formatters or build_default_formatters()
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Content,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Display function call content.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: The content item to check for function calls.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
# Check if this is a function call content type
|
||||
if content.type == "function_call":
|
||||
formatted = format_tool_call(self._formatters, content)
|
||||
ux.append_info_line(f"🔧 {formatted}", "yellow")
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Usage display observer for token usage statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .base import ConsoleObserver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class UsageDisplayObserver(ConsoleObserver):
|
||||
"""Displays token usage as a proportion of the context window.
|
||||
|
||||
Shows current token usage as reported by the API immediately when
|
||||
usage information becomes available (via Content items or the final response).
|
||||
The display shows input/output/total relative to configured budgets.
|
||||
"""
|
||||
|
||||
async def on_content(
|
||||
self,
|
||||
ux: IUXStateDriver,
|
||||
content: Any,
|
||||
agent: Agent,
|
||||
session: Any,
|
||||
) -> None:
|
||||
"""Update usage display immediately when usage content arrives.
|
||||
|
||||
Args:
|
||||
ux: The UX state driver for UI updates.
|
||||
content: A content item from the response.
|
||||
agent: The AI agent.
|
||||
session: The agent session.
|
||||
"""
|
||||
if getattr(content, "type", None) == "usage":
|
||||
usage_details = getattr(content, "usage_details", None)
|
||||
if isinstance(usage_details, dict):
|
||||
# Pass through to state driver — the runner handles formatting
|
||||
ux.set_usage_text(self._format_from_details(usage_details))
|
||||
|
||||
@staticmethod
|
||||
def _format_from_details(usage: dict) -> str:
|
||||
"""Format usage details dict into display text.
|
||||
|
||||
This is a fallback formatter for when usage arrives as Content
|
||||
before the runner's final response processing.
|
||||
"""
|
||||
input_tokens = usage.get("input_token_count", 0) or 0
|
||||
output_tokens = usage.get("output_token_count", 0) or 0
|
||||
total_tokens = usage.get("total_token_count", 0) or input_tokens + output_tokens
|
||||
return f"📊 Tokens — input: {input_tokens:,} | output: {output_tokens:,} | total: {total_tokens:,}"
|
||||
Reference in New Issue
Block a user