Files
microsoft--agent-framework/python/samples/02-agents/harness/console/observers/planning_output.py
T
wehub-resource-sync db620d33df
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

296 lines
11 KiB
Python

# 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,
)