chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,126 @@
# 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
from .web_search_display import WebSearchDisplayObserver
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(),
WebSearchDisplayObserver(),
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(),
WebSearchDisplayObserver(),
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",
"WebSearchDisplayObserver",
"build_default_observers",
"build_observers_with_planning",
]
@@ -0,0 +1,126 @@
# 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, AgentResponseUpdate, Content
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: AgentResponseUpdate,
agent: Agent,
session: Any,
) -> None:
"""Called for each response update chunk.
Override to inspect update-level metadata (such as ``response_id`` /
``message_id`` for message-boundary detection) or handle
provider-specific events in the raw representation.
Args:
ux: The UX state driver for UI updates.
update: The agent response 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,80 @@
# 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.\n\n"
"Note: for clarifications, the user will always also be presented with "
"a free form input option, so make sure that each choice provided here "
"is a valid input for the next turn.\n"
'E.g. if the question is "Which stock are you referring to?" then valid '
'choices might be ["AAPL", "MSFT", "GOOG"], and the user could also type '
"their own answer.\n"
'Invalid choices would be ["Enter tickers directly", "Paste tickers"], '
"since these conflict with the already existing freeform option, and "
"don't directly provide valid inputs for the next turn."
),
)
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,295 @@
# 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, AgentResponseUpdate, 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] = []
# Track the current response so that, when a run produces multiple model
# invocations for a structured-output request (for example after message
# injection), only the last response's text is retained for JSON parsing.
self._last_response_id: str | None = None
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_response_update(
self,
ux: IUXStateDriver,
update: AgentResponseUpdate,
agent: Agent,
session: Any,
) -> None:
"""Stream in execute mode; collect the last response's text in plan mode.
In planning mode a single agent run may produce multiple model
invocations for one structured-output request (for example message
injection triggers a follow-up response). Each model invocation is a new
response with a distinct, non-``None`` ``response_id`` (surfaced on the
provider's lifecycle events). When a new response begins, the previously
collected text is flushed to the UX as plain streamed text so that only
the final response's text is retained for JSON parsing.
Text-delta updates in the Responses/Foundry path carry ``response_id =
None``; those are simply accumulated and never treated as a boundary.
"""
# Execution mode: stream text straight through to the console.
if not self._is_planning_mode_from_ux(ux):
if update.text:
ux.write_text(escape(update.text))
return
# A new model invocation starts a new response with a different,
# non-None response_id. Flush the previously collected (earlier) message
# as plain text and reset the collector so only the latest response's
# text is parsed as structured output.
if update.response_id and update.response_id != self._last_response_id:
if self._last_response_id is not None:
collected_text = "".join(self._text_collector)
if collected_text.strip():
ux.write_text(escape(collected_text))
self._text_collector.clear()
self._last_response_id = update.response_id
if update.text:
self._text_collector.append(update.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()
self._reset_response_tracking()
return None
collected_text = "".join(self._text_collector)
self._text_collector.clear()
self._reset_response_tracking()
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:
# Thread the provider's own configuration (source id, default mode, and the set of
# available modes) so this read matches what the provider resolves in ``before_run``.
# ``get_agent_mode`` persists the resolved default into session state, so reading with
# the built-in default here would wrongly store ``plan`` and override the provider's
# configured default (e.g. ``execute``) before the agent ever runs.
current_mode = get_agent_mode(
session,
source_id=self._mode_provider.source_id,
default_mode=self._mode_provider.default_mode,
available_modes=self._mode_provider.available_modes,
)
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 _reset_response_tracking(self) -> None:
"""Reset response-boundary tracking for the next stream."""
self._last_response_id = None
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,
source_id=self._mode_provider.source_id,
available_modes=self._mode_provider.available_modes,
)
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,154 @@
# 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}"
approve_once = "Approve this call"
always_tool = "Always approve this tool (any arguments)"
always_tool_args = "Always approve this tool with these arguments"
deny = "Deny"
choices = [approve_once, always_tool, always_tool_args, deny]
async def continuation(
selection: str,
ux: IUXStateDriver,
) -> Message | None:
from agent_framework import (
Message,
create_always_approve_tool_response,
create_always_approve_tool_with_arguments_response,
)
if selection == deny:
response_content = request.to_function_approval_response(approved=False)
action_label = "❌ Denied"
color = "red"
elif selection == always_tool:
response_content = create_always_approve_tool_response(
request, reason="User chose to always approve this tool"
)
action_label = "✅ Always approved (any args)"
color = "green"
elif selection == always_tool_args:
response_content = create_always_approve_tool_with_arguments_response(
request, reason="User chose to always approve this tool with these arguments"
)
action_label = "✅ Always approved (these args)"
color = "green"
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,170 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tool call display observer using formatters."""
from __future__ import annotations
import json
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 ..app_state import FollowUpAction
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.
Streaming clients (e.g. the OpenAI/Foundry Responses API) emit a separate
``function_call`` content item for every ``arguments`` delta — each sharing
the same ``call_id`` and ``name`` but carrying only a partial fragment of the
JSON arguments. Printing one line per content item therefore repeats a single
tool call many times (scaling with argument size). To avoid that, this
observer buffers the argument fragments per ``call_id`` and emits exactly one
line once the accumulated arguments are complete (i.e. parse as valid JSON,
or arrive already-coalesced as a mapping). Any call that never reaches a
complete state is flushed when streaming completes.
"""
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()
# call_id -> {"name": str, "arguments": str | dict}
self._pending: dict[str, dict[str, Any]] = {}
# call_ids already displayed in the current stream (avoid duplicates).
self._displayed: set[str] = set()
async def on_content(
self,
ux: IUXStateDriver,
content: Content,
agent: Agent,
session: Any,
) -> None:
"""Buffer streamed function-call fragments and display each call once.
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.
"""
if content.type != "function_call":
return
# Streamed fragments are coalesced by call_id. If a provider omits the
# call_id, fragments cannot be reliably grouped, so fall back to the
# original behavior — display the item as-is — rather than risk merging
# (and then dropping) distinct calls under a shared synthetic key.
call_id = content.call_id
if not call_id:
self._display(ux, content)
return
if call_id in self._displayed:
return
entry = self._pending.setdefault(call_id, {"name": content.name, "arguments": ""})
if content.name and not entry["name"]:
entry["name"] = content.name
args = content.arguments
if isinstance(args, str):
# Streaming delta fragment — concatenate.
entry["arguments"] = (entry["arguments"] or "") + args
elif args is not None:
# Already-coalesced arguments (e.g. a mapping) — use directly.
entry["arguments"] = args
if self._is_complete(entry["arguments"]):
self._flush(ux, call_id)
async def on_stream_complete(
self,
ux: IUXStateDriver,
agent: Agent,
session: Any,
) -> list[FollowUpAction] | None:
"""Flush buffered calls that never reached a complete state, then reset.
Args:
ux: The UX state driver for UI updates.
agent: The AI agent.
session: The agent session.
Returns:
Always None; this observer produces no follow-up actions.
"""
for call_id in list(self._pending):
self._flush(ux, call_id)
self._pending.clear()
self._displayed.clear()
return None
@staticmethod
def _is_complete(arguments: Any) -> bool:
"""Return True when the accumulated arguments form a complete payload.
A mapping is already complete. A string is complete once it parses as
JSON (partial fragments of a streamed JSON object will not parse until
the closing brace arrives; a no-argument call streams ``"{}"`` which
parses immediately).
"""
if isinstance(arguments, str):
stripped = arguments.strip()
if not stripped:
return False
# Cheap structural gate: a complete JSON object/array opens and
# closes with matching brackets. This rejects growing partial
# fragments in O(1) so json.loads only runs on a plausibly-complete
# payload, avoiding O(n^2) re-parsing across many streamed deltas.
if not ((stripped[0] == "{" and stripped[-1] == "}") or (stripped[0] == "[" and stripped[-1] == "]")):
return False
try:
json.loads(stripped)
except (json.JSONDecodeError, TypeError):
return False
return True
# Non-string (mapping / None handled by caller) is treated as complete.
return arguments is not None
def _flush(self, ux: IUXStateDriver, call_id: str) -> None:
"""Format and display a buffered call exactly once."""
entry = self._pending.pop(call_id, None)
if entry is None or call_id in self._displayed:
return
self._displayed.add(call_id)
from agent_framework import Content
# Preserve an empty mapping ("{}") as-is; only treat an empty *string*
# (no arguments were ever streamed) as "no arguments".
arguments = entry["arguments"]
if arguments == "":
arguments = None
call = Content.from_function_call(
call_id=call_id,
name=entry["name"] or "Unknown",
arguments=arguments,
)
self._display(ux, call)
def _display(self, ux: IUXStateDriver, call: Content) -> None:
"""Format and write a single tool-call line."""
formatted = format_tool_call(self._formatters, call)
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:,}"
@@ -0,0 +1,145 @@
# Copyright (c) Microsoft. All rights reserved.
"""Web search display observer for showing search activity in the console.
Displays web search activity as it streams in from the API, showing search
queries, page opens, and find-in-page actions with 🌐 prefix.
The actual details (queries, URLs, sources) come from the ``search_tool_result``
content emitted when the search completes (``response.output_item.done``).
The initial ``search_tool_call`` is emitted when the item is first added and
typically has an empty or incomplete action.
"""
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
_MAX_QUERY_DISPLAY_LENGTH = 120
class WebSearchDisplayObserver(ConsoleObserver):
"""Displays web search activity in the scroll area.
Shows search queries, page opens, and find-in-page actions. Details are
extracted from ``search_tool_result`` content (the completed action), which
contains the full action type, queries, URLs, and sources.
"""
async def on_content(
self,
ux: IUXStateDriver,
content: Content,
agent: Agent,
session: Any,
) -> None:
"""Display web search activity from search content items.
Args:
ux: The UX state driver for UI updates.
content: The content item to check for search activity.
agent: The AI agent.
session: The agent session.
"""
if content.type == "search_tool_result":
self._display_search_result(ux, content)
def _display_search_result(self, ux: IUXStateDriver, content: Content) -> None:
"""Display a completed search tool result with action details."""
tool_name = getattr(content, "tool_name", None) or "web_search"
if tool_name != "web_search":
return
result = getattr(content, "result", None)
if not isinstance(result, dict):
ux.append_info_line("🌐 Web Search", "cyan")
return
action = result.get("action")
if not isinstance(action, dict):
ux.append_info_line("🌐 Web Search", "cyan")
return
action_type = action.get("type")
if action_type == "search":
self._display_search_action(ux, action)
elif action_type == "open_page":
self._display_open_page_action(ux, action)
elif action_type == "find_in_page":
self._display_find_in_page_action(ux, action)
else:
ux.append_info_line("🌐 Web Search", "cyan")
def _display_search_action(self, ux: IUXStateDriver, action: dict) -> None:
"""Display a search action with queries and optional sources."""
queries = action.get("queries") or []
if not queries:
# Fall back to the single "query" field
query = action.get("query")
if query:
queries = [query]
if not queries:
ux.append_info_line("🌐 Web Search: search", "cyan")
return
sources = action.get("sources") or []
has_sources = len(sources) > 0
lines = ["🌐 Web Search: search"]
for i, query in enumerate(queries):
connector = "├─" if (i < len(queries) - 1 or has_sources) else "└─"
query_text = escape(_truncate(str(query), _MAX_QUERY_DISPLAY_LENGTH))
lines.append(f'\n {connector} "{query_text}"')
if has_sources:
lines.append("\n")
for i, source in enumerate(sources):
connector = "├─" if i < len(sources) - 1 else "└─"
line = _format_source(source)
lines.append(f"\n {connector} {line}")
ux.append_info_line("".join(lines), "cyan")
def _display_open_page_action(self, ux: IUXStateDriver, action: dict) -> None:
"""Display an open page action."""
url = escape(str(action.get("url") or "(unknown)"))
ux.append_info_line(
f"🌐 Web Search: open page\n └─ {url}",
"cyan",
)
def _display_find_in_page_action(self, ux: IUXStateDriver, action: dict) -> None:
"""Display a find-in-page action."""
url = escape(str(action.get("url") or "(unknown)"))
pattern = escape(_truncate(str(action.get("pattern") or "(unknown)"), _MAX_QUERY_DISPLAY_LENGTH))
ux.append_info_line(
f'🌐 Web Search: find in page\n ├─ "{pattern}"\n └─ {url}',
"cyan",
)
def _truncate(text: str, max_length: int) -> str:
"""Truncate text to max length with ellipsis."""
return text if len(text) <= max_length else text[: max_length - 1] + ""
def _format_source(source: Any) -> str:
"""Format a source entry for display."""
if isinstance(source, dict):
url = escape(str(source.get("url") or source.get("uri") or "(unknown)"))
title = source.get("title")
if title:
return f"{escape(_truncate(str(title), _MAX_QUERY_DISPLAY_LENGTH))}{url}"
return url
return escape(str(source))