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
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:
@@ -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))
|
||||
Reference in New Issue
Block a user