chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
# Harness Agent Samples
|
||||
|
||||
This folder demonstrates `create_harness_agent` — a factory function that builds a
|
||||
pre-configured, batteries-included agent by assembling the full agent pipeline
|
||||
from a chat client.
|
||||
|
||||
## What is `create_harness_agent`?
|
||||
|
||||
`create_harness_agent` bundles the following features into a single `Agent` instance:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Function invocation | Automatic tool calling loop |
|
||||
| Per-service-call persistence | History persisted after every model call |
|
||||
| Compaction | Context-window management (sliding window + tool result compaction) |
|
||||
| TodoProvider | Todo list management for planning and tracking |
|
||||
| AgentModeProvider | Plan/execute mode tracking |
|
||||
| MemoryContextProvider | File-based durable memory (when `memory_store` provided) |
|
||||
| SkillsProvider | File-based skill discovery and progressive loading |
|
||||
| Shell tool | Shell command execution + environment probing (when `shell_executor` provided) |
|
||||
| Tool approval | "Don't ask again" standing rules + heuristic auto-approval (enabled by default) |
|
||||
| Looping | Re-invoke the agent until a `loop_should_continue` predicate is satisfied (when provided) |
|
||||
| OpenTelemetry | Built-in observability |
|
||||
|
||||
Each feature can be disabled or customized via keyword arguments.
|
||||
|
||||
## Samples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `harness_research.py` | Interactive research assistant with web search, a plan/execute workflow, and an execute-mode loop that re-invokes the agent until every todo is complete |
|
||||
| `harness_data_processing.py` | Data-processing assistant over a folder of CSV files, demonstrating file-access tools and tool approval |
|
||||
| [`build_your_own_claw/`](./build_your_own_claw/README.md) | *Build your own claw* blog series — a personal finance assistant built step by step |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Set your Foundry environment variables
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name"
|
||||
export FOUNDRY_MODEL="your-model-deployment-name"
|
||||
|
||||
# Authenticate with Azure (required for AzureCliCredential)
|
||||
az login
|
||||
|
||||
# Run a sample against the released agent-framework (PEP 723 isolated env)
|
||||
uv run samples/02-agents/harness/harness_research.py
|
||||
```
|
||||
|
||||
### Running against the local repo
|
||||
|
||||
To run a sample against your **local** `agent-framework` checkout (so it picks
|
||||
up uncommitted changes), use the workspace environment instead of the isolated
|
||||
PEP 723 env. From the `python/` directory, run the script with `uv run python`
|
||||
and add the `textual` UI dependency the harness console needs:
|
||||
|
||||
```bash
|
||||
uv run --with textual python samples/02-agents/harness/harness_research.py
|
||||
uv run --with textual python samples/02-agents/harness/harness_data_processing.py
|
||||
```
|
||||
|
||||
The workspace environment already provides the editable `agent-framework`
|
||||
packages plus the samples' other dependencies (`rich`, `python-dotenv`,
|
||||
`azure-identity`); only `textual` needs to be supplied with `--with`.
|
||||
|
||||
> Note: invoking `uv run python <script>` (with `python`) bypasses the PEP 723
|
||||
> metadata and uses the workspace env; `uv run <script>` (without `python`)
|
||||
> uses the isolated env with the released package.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Minimal Setup
|
||||
|
||||
`create_harness_agent` requires only a chat client:
|
||||
|
||||
```python
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=FoundryChatClient(credential=AzureCliCredential()),
|
||||
)
|
||||
```
|
||||
|
||||
### With Compaction
|
||||
|
||||
Provide token budget parameters to enable automatic context-window compaction:
|
||||
|
||||
```python
|
||||
agent = create_harness_agent(
|
||||
client=FoundryChatClient(credential=AzureCliCredential()),
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
```
|
||||
|
||||
### Further Customization
|
||||
|
||||
Disable or customize any feature:
|
||||
|
||||
```python
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
name="my-agent",
|
||||
agent_instructions="Custom instructions here.",
|
||||
disable_todo=True, # Skip todo management
|
||||
disable_mode=True, # Skip plan/execute modes
|
||||
disable_compaction=True, # Skip compaction
|
||||
)
|
||||
```
|
||||
|
||||
### Plan/Execute Workflow
|
||||
|
||||
The `AgentModeProvider` enables a two-phase workflow:
|
||||
1. **Plan mode** — Interactive: the agent asks questions, creates todos, gets approval
|
||||
2. **Execute mode** — Autonomous: the agent works through todos independently
|
||||
|
||||
### Shell Tool
|
||||
|
||||
Pass a shell executor (e.g. `LocalShellTool` from `agent-framework-tools`) to enable shell
|
||||
command execution plus automatic environment probing via a `ShellEnvironmentProvider`. The
|
||||
tool is only wired when the chat client supports shell tools; otherwise a warning is logged
|
||||
and the shell tool/provider are skipped. The caller owns the executor's lifecycle.
|
||||
|
||||
```python
|
||||
from agent_framework_tools.shell import LocalShellTool, ShellEnvironmentProviderOptions
|
||||
|
||||
async with LocalShellTool(acknowledge_unsafe=True) as shell:
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
shell_executor=shell,
|
||||
# Optional: customize environment probing.
|
||||
shell_environment_provider_options=ShellEnvironmentProviderOptions(probe_tools=("git", "python")),
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Several harness capabilities extend the agent's trust boundary to external systems the developer
|
||||
configures. Each is opt-in and requires explicit configuration by the developer, who is responsible
|
||||
for vetting the external service, agent, skill source, or provider before enabling it:
|
||||
|
||||
- **`background_agents`** (`BackgroundAgentsProvider`) — delegates work to developer-supplied agents,
|
||||
which receive input from the parent and whose output is fed back into its context. A compromised
|
||||
agent could exfiltrate data or inject adversarial content via indirect prompt injection. Vet all
|
||||
supplied agents.
|
||||
- **External skill sources** (`skills_provider` with e.g. `MCPSkillsSource`) — load skill content,
|
||||
and potentially scripts, from a remote source. A compromised source could return adversarial skills
|
||||
(indirect prompt injection) or exfiltrate data. Only enable sources you trust.
|
||||
- **`AgentLoopMiddleware.with_judge`** — sends the request and the agent's latest response to a second,
|
||||
external judge chat client on every iteration. A compromised judge could exfiltrate that data or
|
||||
return manipulated feedback. Trust the judge as much as the primary model.
|
||||
- **`SummarizationStrategy`** (via `before_compaction_strategy` / `after_compaction_strategy`) — calls
|
||||
out to an LLM whose output permanently becomes chat history. A compromised summarization service
|
||||
could inject unsafe, persistent instructions. Only use a service you trust as much as the primary
|
||||
model.
|
||||
- **Telemetry** — when observability is enabled, telemetry destinations are developer-configured.
|
||||
Default telemetry is metadata only; enabling sensitive data additionally emits raw message content,
|
||||
tool arguments, and tool results. See the [observability samples](../observability/README.md).
|
||||
@@ -0,0 +1,177 @@
|
||||
# Build your own claw and agent harness — Python samples
|
||||
|
||||
Runnable Python samples for the [**"Build your own claw and agent harness with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series. Each step builds a personal finance / investing assistant on top of
|
||||
`create_harness_agent`, reusing the shared harness `console` package in the parent `harness/`
|
||||
directory.
|
||||
|
||||
- **Part 1 — `claw_step01_meet_your_claw.py`** — the minimal harness.
|
||||
- **Part 2 — `claw_step02_working_with_data.py`** — file access, approvals, and durable memory.
|
||||
- **Part 3 — `claw_step03_scaling_capabilities.py`** — skills, shell, CodeAct, and background agents.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model.
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
export FOUNDRY_MODEL="your-model-deployment-name"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Meet your claw
|
||||
|
||||
Builds the foundation of the assistant on top of `create_harness_agent`.
|
||||
|
||||
### What this sample demonstrates
|
||||
|
||||
- **`create_harness_agent`** — a factory that builds a batteries-included agent: function
|
||||
invocation, per-service-call history persistence, planning (`TodoProvider` +
|
||||
`AgentModeProvider`), and web search.
|
||||
- **A custom function tool** — `get_stock_price`, exposing local data to the agent. Prices are
|
||||
illustrative mock data, not real quotes.
|
||||
- **Web search** — provided automatically by the harness for market news and commentary.
|
||||
- **Planning & modes** — the agent breaks a multi-step request ("review my watchlist") into a todo
|
||||
list and switches between *plan* and *execute* modes.
|
||||
- **Shared harness console** — interactive streaming UI (reused from the parent `harness/console`
|
||||
package) with `/todos`, `/mode`, and `/exit` commands.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# From the repository root, using a PEP 723 compatible runner:
|
||||
uv run python/samples/02-agents/harness/build_your_own_claw/claw_step01_meet_your_claw.py
|
||||
```
|
||||
|
||||
### What to expect
|
||||
|
||||
The sample starts an interactive loop. Try these in order:
|
||||
|
||||
1. `/mode execute` — switch out of the default plan mode; quick lookups don't need a plan.
|
||||
2. `What's the price of MSFT?` — the agent calls the `get_stock_price` tool.
|
||||
3. `Any recent news on NVDA?` — the agent uses web search.
|
||||
4. `Add MSFT, NVDA and SPY to my watch list` — saved to `watchlist.md` in the session's memory.
|
||||
5. `/mode plan` — switch back to plan mode for a bigger, multi-step task.
|
||||
6. `Review my watchlist and recommend some stocks to add` — the agent plans, then executes. Type
|
||||
`/todos` to see the list and `/mode` to inspect the current mode.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Working with your data, safely
|
||||
|
||||
Teaches the assistant to work with *your* data safely.
|
||||
|
||||
### What this sample demonstrates
|
||||
|
||||
- **File access** — the agent reads a pre-populated `working/portfolio.csv` and writes reports
|
||||
with the `file_access_*` tools. File access is on by default; the sample points its store at the
|
||||
sample's `working/` folder via `create_harness_agent(file_access_store=...)`.
|
||||
- **Approvals** — file-access tools require approval by default, but the sample wires the built-in
|
||||
`read_only_tools_auto_approval_rule` so reads/lists/searches are frictionless while saving and
|
||||
deleting still pause for approval. The `place_trade` tool is marked
|
||||
`approval_mode="always_require"`, so the harness asks you to approve or deny before any trade
|
||||
runs. The trade is simulated.
|
||||
- **Durable memory, two ways:**
|
||||
- **File memory** (coarse-grained, explicit) — the agent reads/writes files such as
|
||||
`watchlist.md`. File memory is on by default; its files live on disk under
|
||||
`{cwd}/agent-file-memory/<session-id>/`, so they persist across runs on this machine. A new
|
||||
session starts empty; use `/session-export` and `/session-import` to preserve the session id so a
|
||||
relaunch re-links to its memory files (no fixed folder or owner id required).
|
||||
- **Foundry memory** (fine-grained, automatic) — Microsoft Foundry extracts durable facts from
|
||||
the conversation. Opt-in; see below.
|
||||
|
||||
### Additional environment variables (optional — enable Foundry memory)
|
||||
|
||||
```bash
|
||||
export FOUNDRY_MEMORY_STORE="claw-finance-memory"
|
||||
export FOUNDRY_EMBEDDING_MODEL="text-embedding-3-small"
|
||||
```
|
||||
|
||||
When these are not set, the sample runs with file memory only and prints a note.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
uv run python/samples/02-agents/harness/build_your_own_claw/claw_step02_working_with_data.py
|
||||
```
|
||||
|
||||
### What to expect
|
||||
|
||||
Try these in order (the sample starts in **execute** mode — quick lookups don't need a plan):
|
||||
|
||||
1. `What's in my portfolio?` — the agent reads `portfolio.csv` with the file_access tools.
|
||||
2. `Write me a short report on my portfolio and save it.` — the agent writes a Markdown file under
|
||||
`working/`; saving is a write, so **you are prompted to approve** before the file is created.
|
||||
3. `I'm a conservative investor saving for a house in two years.` — a durable fact (recalled later
|
||||
by Foundry memory when enabled).
|
||||
4. `Buy 10 shares of MSFT.` — the agent calls `place_trade`; **you are prompted to approve or
|
||||
deny** before it runs.
|
||||
5. `Add SPY to my watchlist.` — saved to `watchlist.md` in file memory.
|
||||
|
||||
Foundry memory (when enabled) recalls facts about you in any new session. File memory (the
|
||||
watchlist) lives on disk keyed by session id, so `/session-export` before you quit and
|
||||
`/session-import` after relaunching to re-link the relaunched session to its files, then ask
|
||||
*"What's on my watchlist?"* or *"What do you know about me?"*.
|
||||
|
||||
## Part 3 — Scaling its capabilities
|
||||
|
||||
Makes the assistant *more capable* along four axes.
|
||||
|
||||
### What this sample demonstrates
|
||||
|
||||
- **Skills** — finance know-how (`valuation`, `risk-scoring`) is packaged as discoverable
|
||||
`SKILL.md` files under `skills/`, which the agent loads on demand. The sample builds a
|
||||
`FileSkillsSource(..., script_runner=subprocess_script_runner)` so the skills' Python
|
||||
scripts can run. Optionally folds in centrally-managed **Foundry skills** served from a
|
||||
Foundry Toolbox MCP endpoint via `MCPSkillsSource` (opt-in; see below).
|
||||
- **Shell** — a `LocalShellTool` confined to the trade-confirmation vault
|
||||
(`working/confirmations/`) lets the agent tidy the accumulated confirmation files (reorganize into
|
||||
`year/month`, rename to `YYYY-MM-DD_TICKER_BUY|SELL.txt`). Guarded by a `ShellPolicy` deny-list
|
||||
**and** a confined working directory; left at the default
|
||||
`approval_mode="always_require"` so each command is surfaced for approval.
|
||||
- **CodeAct** — a `MontyCodeActProvider` gives the agent a sandboxed, cross-platform Python
|
||||
interpreter to crunch portfolio numbers by writing and running code.
|
||||
- **Background agents** — a lean, web-search-only `TickerResearchAgent` is registered via
|
||||
`create_harness_agent(background_agents=[...])`, so the main agent can fan out per-ticker research
|
||||
concurrently and aggregate the findings.
|
||||
|
||||
### Additional environment variables (optional)
|
||||
|
||||
```bash
|
||||
# Enable centrally-managed Foundry skills (Foundry Toolbox MCP endpoint URL):
|
||||
export FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://<your-project>.services.ai.azure.com/.../toolboxes/<toolbox>/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
When this is not set, the sample runs with the local file skills only, and prints a note.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
uv run python/samples/02-agents/harness/build_your_own_claw/claw_step03_scaling_capabilities.py
|
||||
```
|
||||
|
||||
### What to expect
|
||||
|
||||
Try these in order (the sample starts in **execute** mode — quick lookups don't need a plan):
|
||||
|
||||
1. `Value MSFT for me.` — the agent loads the `valuation` skill and follows its instructions
|
||||
(reading references and running its script).
|
||||
2. `Score the risk of my portfolio.` — the agent reads `portfolio.csv` and loads the `risk-scoring`
|
||||
skill.
|
||||
3. `/mode plan`, then `Tidy up my trade confirmations.` — switching to plan mode first makes the
|
||||
agent inspect `working/confirmations/` and propose a reorganization plan before touching anything;
|
||||
once you approve it switches to execute and uses the shell to reorganize and rename the files,
|
||||
**prompting you to approve** each command.
|
||||
4. `Work out the total value of my portfolio.` — the agent writes and runs Python via CodeAct.
|
||||
5. `Research MSFT, NVDA and SPY and summarize the latest news.` — the agent fans the tickers out to
|
||||
the background research agent and aggregates the results.
|
||||
6. `What's the capital of France?` — with a `financial-agent-rules` skill published to your Foundry
|
||||
toolbox and Foundry skills enabled (`FOUNDRY_TOOLBOX_MCP_SERVER_URL`), the agent loads it,
|
||||
recognizes the question is off-topic, and politely declines, steering you back to finance.
|
||||
|
||||
See the [Part 3 blog post](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/)
|
||||
for more on the `financial-agent-rules` skill — including the SKILL.md to publish to your Foundry toolbox.
|
||||
@@ -0,0 +1,148 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-foundry",
|
||||
# "textual>=6.2.1",
|
||||
# "rich>=13.7.1",
|
||||
# "azure-identity",
|
||||
# "python-dotenv",
|
||||
# ]
|
||||
# ///
|
||||
# Run with any PEP 723 compatible runner, e.g.:
|
||||
# uv run python/samples/02-agents/harness/build_your_own_claw/claw_step01_meet_your_claw.py
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Meet your agent harness and claw (Post 1) — Python.
|
||||
|
||||
The first runnable sample from the "Build your own claw with Microsoft Agent Framework" blog
|
||||
series. See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
|
||||
It builds the foundation of a personal finance / investing assistant on top of
|
||||
``create_harness_agent``.
|
||||
|
||||
``create_harness_agent`` is a factory that wires up a batteries-included agent: function
|
||||
invocation, per-service-call history persistence, planning (TodoProvider +
|
||||
AgentModeProvider), and web search. All we add here is finance-focused
|
||||
instructions and a custom ``get_stock_price`` tool.
|
||||
|
||||
This sample reuses the shared harness ``console`` package that lives in the parent
|
||||
``harness/`` directory.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Reuse the shared harness console that lives in the parent ``harness/`` directory.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from console import build_observers_with_planning, run_agent_async # noqa: E402
|
||||
|
||||
FINANCE_INSTRUCTIONS = """\
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their watchlist
|
||||
and the markets. When asked about a stock, look up its current price with the get_stock_price
|
||||
tool, and use web search for recent news, earnings, or analyst commentary.
|
||||
|
||||
### Working style
|
||||
|
||||
- Always verify numbers with a tool rather than relying on memory. Stock prices change.
|
||||
- Cite web sources inline when you use them.
|
||||
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
|
||||
watchlist, and update it whenever the user adds or removes a ticker.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you must
|
||||
not present your output as personalized investment advice. Remind the user to do their own
|
||||
research before making decisions.
|
||||
"""
|
||||
|
||||
# A tiny in-memory price book so the sample runs without any external dependency.
|
||||
# These are illustrative mock prices, not real market quotes.
|
||||
_PRICE_BOOK: dict[str, float] = {
|
||||
"MSFT": 462.97,
|
||||
"AAPL": 229.35,
|
||||
"GOOGL": 178.12,
|
||||
"AMZN": 201.45,
|
||||
"NVDA": 134.81,
|
||||
}
|
||||
|
||||
|
||||
# <get_stock_price>
|
||||
def get_stock_price(
|
||||
symbol: Annotated[str, "The stock ticker symbol, e.g. MSFT or AAPL."],
|
||||
) -> dict[str, object]:
|
||||
"""Get the latest (delayed, illustrative) stock price for a ticker symbol."""
|
||||
ticker = symbol.upper()
|
||||
price = _PRICE_BOOK.get(ticker)
|
||||
if price is None:
|
||||
# Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
|
||||
# Derive a stable seed from the characters — the built-in hash() is randomized per
|
||||
# process (PYTHONHASHSEED), so it would give different prices on every run.
|
||||
seed = 0
|
||||
for ch in ticker:
|
||||
seed = (seed * 31 + ord(ch)) % 1_000_000
|
||||
price = 50.0 + (seed % 45000) / 100.0
|
||||
|
||||
return {
|
||||
"symbol": ticker,
|
||||
"price": round(price, 2),
|
||||
"currency": "USD",
|
||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# </get_stock_price>
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_dotenv()
|
||||
|
||||
# <create_client>
|
||||
# Construct a chat client. FoundryChatClient reads FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
|
||||
# from the environment; AzureCliCredential handles auth (run `az login`, or swap in another
|
||||
# credential). The harness works with ANY chat client — see the providers samples for OpenAI,
|
||||
# Azure OpenAI, Anthropic, Ollama, and more.
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
# </create_client>
|
||||
|
||||
# <create_agent>
|
||||
# Turn the chat client into a harness agent with finance instructions and our custom
|
||||
# stock-price tool. Planning (todo + mode) and web search are configured automatically.
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
agent_instructions=FINANCE_INSTRUCTIONS,
|
||||
tools=get_stock_price,
|
||||
)
|
||||
# </create_agent>
|
||||
|
||||
# <run>
|
||||
# Run the interactive console session using the shared harness console helper.
|
||||
await run_agent_async(
|
||||
agent,
|
||||
session=agent.create_session(),
|
||||
observers=build_observers_with_planning(agent),
|
||||
initial_mode="plan",
|
||||
title="💹 Finance Assistant",
|
||||
placeholder="Ask about a stock or say 'review my watchlist'...",
|
||||
)
|
||||
# </run>
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-foundry",
|
||||
# "azure-ai-projects",
|
||||
# "textual>=6.2.1",
|
||||
# "rich>=13.7.1",
|
||||
# "azure-identity",
|
||||
# "python-dotenv",
|
||||
# ]
|
||||
# ///
|
||||
# Run with any PEP 723 compatible runner, e.g.:
|
||||
# uv run python/samples/02-agents/harness/build_your_own_claw/claw_step02_working_with_data.py
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Working with your data, safely (Post 2) — Python.
|
||||
|
||||
The second runnable sample from the "Build your own claw and agent harness with Microsoft Agent
|
||||
Framework" blog series. See: https://devblogs.microsoft.com/agent-framework/agent-harness-working-with-your-data-safely.
|
||||
It builds on Post 1's personal finance assistant and adds three abilities:
|
||||
|
||||
1. File access — read the user's ``portfolio.csv`` and write report files (file_access_* tools),
|
||||
on by default in the harness. Read-only file tools are auto-approved; writes
|
||||
still prompt.
|
||||
2. Approvals — the ``place_trade`` tool is marked ``approval_mode="always_require"`` so the
|
||||
harness asks for human approval before it runs.
|
||||
3. Durable memory, two complementary kinds:
|
||||
* File memory (coarse-grained, explicit) — the agent reads/writes files like
|
||||
``watchlist.md``. On by default. Its files live on disk under
|
||||
``{cwd}/agent-file-memory/<session-id>/``, so they persist across runs on this
|
||||
machine. A new session starts empty; ``/session-export`` + ``/session-import``
|
||||
preserve the session id so a relaunched session re-links to its memory files.
|
||||
* Foundry memory (fine-grained, automatic) — Microsoft Foundry extracts durable facts (e.g.
|
||||
the user's risk tolerance) from the conversation. Opt-in: enabled only when
|
||||
FOUNDRY_MEMORY_STORE and FOUNDRY_EMBEDDING_MODEL are set.
|
||||
|
||||
This sample reuses the shared harness ``console`` package in the parent ``harness/`` directory.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name (defaults to gpt-5.4)
|
||||
FOUNDRY_MEMORY_STORE — (optional) Foundry memory store name; enables Foundry memory
|
||||
FOUNDRY_EMBEDDING_MODEL — (optional) embedding deployment; required for Foundry memory
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import (
|
||||
AgentModeProvider,
|
||||
FileAccessProvider,
|
||||
FileSystemAgentFileStore,
|
||||
create_harness_agent,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient, FoundryMemoryProvider
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
|
||||
# Reuse the shared harness console that lives in the parent ``harness/`` directory.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from console import build_observers_with_planning, run_agent_async # noqa: E402
|
||||
|
||||
# Fixed folder so file access (portfolio.csv, reports) lives next to this script. File memory uses its
|
||||
# on-disk default ({cwd}/agent-file-memory/<session-id>/), so memory files persist across runs on this
|
||||
# machine; /session-export + /session-import preserve the session id so a relaunch re-links to them.
|
||||
_SAMPLE_DIR = Path(__file__).resolve().parent
|
||||
_WORKING_DIR = _SAMPLE_DIR / "working"
|
||||
# Foundry memory is scoped to a single logical user here, so its facts are recalled across sessions.
|
||||
# In a real world scenario, "claw-sample-user" should be replaced with a unique identifier
|
||||
# for the active user.
|
||||
# To tie memories to the session instead, just don't pass a scope, and the provider will default
|
||||
# to session scoped.
|
||||
_MEMORY_SCOPE = "claw-sample-user"
|
||||
|
||||
FINANCE_INSTRUCTIONS = """\
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their portfolio
|
||||
and watchlist, and you can place trades on their behalf.
|
||||
|
||||
### Working style
|
||||
|
||||
- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools
|
||||
before answering questions about their portfolio, and never modify it unless asked.
|
||||
- When asked for a report or analysis, write it to a Markdown file with the file_access tools
|
||||
(e.g. reports/portfolio-review.md) and tell the user where you saved it.
|
||||
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
|
||||
watchlist, and update it whenever the user adds or removes a ticker.
|
||||
- To buy or sell, use the place_trade tool. This takes a real action, so the user will be asked to
|
||||
approve it before it runs — explain what you are about to do first.
|
||||
- Remember durable facts the user tells you about themselves (risk tolerance, goals, preferences)
|
||||
and take them into account when giving analysis.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you must
|
||||
not present your output as personalized investment advice. Remind the user to do their own
|
||||
research before making decisions.
|
||||
"""
|
||||
|
||||
# A tiny in-memory price book so the sample runs without any external dependency.
|
||||
# These are illustrative mock prices, not real market quotes.
|
||||
_PRICE_BOOK: dict[str, float] = {
|
||||
"MSFT": 462.97,
|
||||
"AAPL": 229.35,
|
||||
"GOOGL": 178.12,
|
||||
"AMZN": 201.45,
|
||||
"NVDA": 134.81,
|
||||
"SPY": 612.40,
|
||||
}
|
||||
|
||||
|
||||
# <get_stock_price>
|
||||
def get_stock_price(
|
||||
symbol: Annotated[str, "The stock ticker symbol, e.g. MSFT or AAPL."],
|
||||
) -> dict[str, object]:
|
||||
"""Get the latest (delayed, illustrative) stock price for a ticker symbol."""
|
||||
ticker = symbol.upper()
|
||||
price = _PRICE_BOOK.get(ticker)
|
||||
if price is None:
|
||||
# Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
|
||||
# Derive a stable seed from the characters — the built-in hash() is randomized per
|
||||
# process (PYTHONHASHSEED), so it would give different prices on every run.
|
||||
seed = 0
|
||||
for ch in ticker:
|
||||
seed = (seed * 31 + ord(ch)) % 1_000_000
|
||||
price = 50.0 + (seed % 45000) / 100.0
|
||||
|
||||
return {
|
||||
"symbol": ticker,
|
||||
"price": round(price, 2),
|
||||
"currency": "USD",
|
||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# </get_stock_price>
|
||||
|
||||
|
||||
# <place_trade>
|
||||
@tool(approval_mode="always_require")
|
||||
def place_trade(
|
||||
symbol: Annotated[str, "The stock ticker symbol to trade, e.g. MSFT."],
|
||||
action: Annotated[Literal["buy", "sell"], "Either 'buy' or 'sell'."],
|
||||
quantity: Annotated[int, Field(gt=0, description="The number of shares to trade.")],
|
||||
) -> str:
|
||||
"""Place a (simulated) buy or sell order. Marked approval-required, so the harness asks the
|
||||
user to approve before this ever runs. No real order is placed.
|
||||
|
||||
``action`` and ``quantity`` are validated by the framework (pydantic) from their type hints:
|
||||
the model can only pass 'buy'/'sell' and a quantity greater than zero.
|
||||
"""
|
||||
verb = "Sold" if action == "sell" else "Bought"
|
||||
confirmation = f"TRADE-{uuid.uuid4().hex[:8].upper()}"
|
||||
return f"{verb} {quantity} share(s) of {symbol.upper()}. Confirmation: {confirmation}."
|
||||
|
||||
|
||||
# </place_trade>
|
||||
|
||||
|
||||
# <memory>
|
||||
async def _maybe_enable_foundry_memory(stack: AsyncExitStack) -> FoundryMemoryProvider | None:
|
||||
"""Enable fine-grained Foundry memory when configured, otherwise return None.
|
||||
|
||||
Foundry memory needs a memory store and an embedding model, so it is opt-in. When the required
|
||||
environment variables are present we (best-effort) create the store and return a provider
|
||||
scoped to a single user, so extracted facts are recalled across sessions.
|
||||
"""
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
store_name = os.environ.get("FOUNDRY_MEMORY_STORE")
|
||||
embedding_model = os.environ.get("FOUNDRY_EMBEDDING_MODEL")
|
||||
chat_model = os.environ.get("FOUNDRY_MODEL", "gpt-5.4")
|
||||
|
||||
if not (endpoint and store_name and embedding_model):
|
||||
print("Foundry memory disabled. Set FOUNDRY_MEMORY_STORE and FOUNDRY_EMBEDDING_MODEL to enable it.")
|
||||
return None
|
||||
|
||||
# Imported lazily so the common (file-memory-only) path has no async-project dependency.
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import MemoryStoreDefaultDefinition, MemoryStoreDefaultOptions
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
|
||||
|
||||
credential = await stack.enter_async_context(AsyncAzureCliCredential())
|
||||
project_client = await stack.enter_async_context(AIProjectClient(endpoint=endpoint, credential=credential))
|
||||
|
||||
# Create the memory store only if it does not already exist.
|
||||
try:
|
||||
await project_client.beta.memory_stores.get(name=store_name)
|
||||
print(f"Using existing memory store '{store_name}'.")
|
||||
except ResourceNotFoundError:
|
||||
definition = MemoryStoreDefaultDefinition(
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
options=MemoryStoreDefaultOptions(chat_summary_enabled=False, user_profile_enabled=True),
|
||||
)
|
||||
await project_client.beta.memory_stores.create(
|
||||
name=store_name,
|
||||
description="Durable memory for the Build-your-own-claw finance assistant.",
|
||||
definition=definition,
|
||||
)
|
||||
print(f"Created memory store '{store_name}'.")
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=project_client,
|
||||
memory_store_name=store_name,
|
||||
scope=_MEMORY_SCOPE,
|
||||
update_delay=0, # Update memories immediately (demo). In production, batch with a delay.
|
||||
)
|
||||
print(f"Foundry memory enabled (store: {store_name}).")
|
||||
return provider
|
||||
|
||||
|
||||
# </memory>
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_dotenv()
|
||||
_WORKING_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# <create_client>
|
||||
# Construct a chat client (see Post 1). FoundryChatClient reads FOUNDRY_PROJECT_ENDPOINT and
|
||||
# FOUNDRY_MODEL from the environment; AzureCliCredential handles auth (run `az login`).
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
# </create_client>
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
# <foundry_memory>
|
||||
# Fine-grained, automatic memory (when configured) is just another context provider.
|
||||
context_providers: list[Any] = []
|
||||
foundry_memory = await _maybe_enable_foundry_memory(stack)
|
||||
if foundry_memory is not None:
|
||||
context_providers.append(foundry_memory)
|
||||
# </foundry_memory>
|
||||
|
||||
# <create_agent>
|
||||
# Turn the chat client into a harness agent. On top of Post 1's defaults we point file
|
||||
# access at a folder next to this script, add our approval-gated place_trade tool,
|
||||
# auto-approve the read-only file tools (so reading is frictionless while writes and
|
||||
# trades still prompt), and optionally add the Foundry memory provider. File memory keeps its
|
||||
# on-disk default store, and we don't point it at a custom folder here. We default the agent to
|
||||
# execute mode (autonomous); the user can still switch to plan with the `mode_set` tool.
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
agent_instructions=FINANCE_INSTRUCTIONS,
|
||||
tools=[get_stock_price, place_trade],
|
||||
file_access_store=FileSystemAgentFileStore(str(_WORKING_DIR)),
|
||||
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
|
||||
context_providers=context_providers or None,
|
||||
mode_provider=AgentModeProvider(default_mode="execute"),
|
||||
)
|
||||
# </create_agent>
|
||||
|
||||
# <run>
|
||||
session = agent.create_session()
|
||||
|
||||
# Run the interactive console session. The default planning observers already include a
|
||||
# tool approval observer, so the place_trade approval prompt is surfaced automatically.
|
||||
await run_agent_async(
|
||||
agent,
|
||||
session=session,
|
||||
observers=build_observers_with_planning(agent),
|
||||
initial_mode="execute",
|
||||
title="💹 Finance Assistant",
|
||||
placeholder="Review your portfolio, draft a report, update your watchlist, or place a trade...",
|
||||
)
|
||||
# </run>
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-foundry",
|
||||
# "agent-framework-tools",
|
||||
# "agent-framework-monty",
|
||||
# "mcp",
|
||||
# "httpx",
|
||||
# "textual>=6.2.1",
|
||||
# "rich>=13.7.1",
|
||||
# "azure-identity",
|
||||
# "python-dotenv",
|
||||
# ]
|
||||
# ///
|
||||
# Run with any PEP 723 compatible runner, e.g.:
|
||||
# uv run python/samples/02-agents/harness/build_your_own_claw/claw_step03_scaling_capabilities.py
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Scaling its capabilities (Post 3) — Python.
|
||||
|
||||
The third runnable sample from the "Build your own claw and agent harness with Microsoft Agent
|
||||
Framework" blog series. See: https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/.
|
||||
It builds on Post 2's personal finance assistant and makes it *more capable* in four ways:
|
||||
|
||||
1. Skills — package finance know-how (valuation, risk-scoring) as discoverable SKILL.md
|
||||
files the agent loads on demand. Optionally fold in centrally-managed Foundry
|
||||
skills served from a Foundry Toolbox MCP endpoint (opt-in via
|
||||
FOUNDRY_TOOLBOX_MCP_SERVER_URL).
|
||||
2. Shell — a sandboxed shell, confined to the trade-confirmation vault, that the agent uses
|
||||
to reorganize the accumulated confirmation files (year/month, rename, archive).
|
||||
Guarded by an allow/deny-list policy and a confined working directory.
|
||||
3. CodeAct — the agent writes and runs Python to crunch portfolio numbers, using the
|
||||
cross-platform Monty interpreter.
|
||||
4. Background agents — fan out a per-ticker research sub-agent so several tickers are researched
|
||||
concurrently, then aggregated.
|
||||
|
||||
This sample reuses the shared harness ``console`` package in the parent ``harness/`` directory.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name (defaults to gpt-5.4)
|
||||
FOUNDRY_TOOLBOX_MCP_SERVER_URL — (optional) Foundry Toolbox MCP endpoint URL; enables Foundry skills
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentModeProvider,
|
||||
AggregatingSkillsSource,
|
||||
DeduplicatingSkillsSource,
|
||||
FileAccessProvider,
|
||||
FileSkillsSource,
|
||||
FileSystemAgentFileStore,
|
||||
MCPSkillsSource,
|
||||
SkillsProvider,
|
||||
SkillsSource,
|
||||
create_harness_agent,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_monty import MontyCodeActProvider
|
||||
from agent_framework_tools.shell import LocalShellTool, ShellPolicy
|
||||
from azure.identity import AzureCliCredential, get_bearer_token_provider
|
||||
from dotenv import load_dotenv
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from pydantic import Field
|
||||
|
||||
# Reuse the shared harness console that lives in the parent ``harness/`` directory, and the local
|
||||
# subprocess script runner used to execute file-based skill scripts.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from console import build_observers_with_planning, run_agent_async # noqa: E402
|
||||
from subprocess_script_runner import subprocess_script_runner # noqa: E402
|
||||
|
||||
_SAMPLE_DIR = Path(__file__).resolve().parent
|
||||
_WORKING_DIR = _SAMPLE_DIR / "working"
|
||||
_VAULT_DIR = _WORKING_DIR / "confirmations"
|
||||
_SKILLS_DIR = _SAMPLE_DIR / "skills"
|
||||
|
||||
FINANCE_INSTRUCTIONS = """\
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their portfolio
|
||||
and watchlist, value individual stocks, gauge portfolio risk, research the market, and keep their
|
||||
records tidy.
|
||||
|
||||
### Working style
|
||||
|
||||
- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools
|
||||
before answering questions about their portfolio, and never modify it unless asked.
|
||||
- You have skills for valuation and risk-scoring. When a question matches a skill, load it and
|
||||
follow its instructions (read its references, run its scripts) rather than guessing.
|
||||
- When asked to research several tickers, delegate each one to the background research agent so
|
||||
they run concurrently, then summarize the findings together.
|
||||
- The user's trade confirmations accumulate in the working/confirmations folder. When asked to tidy
|
||||
or reorganize them, use the run_shell tool: inspect the folder first, then move files into a
|
||||
year/month layout and rename them to YYYY-MM-DD_TICKER_BUY|SELL.txt. Explain your plan before
|
||||
running commands that change anything.
|
||||
- To buy or sell, use the place_trade tool. This takes a real action, so the user will be asked to
|
||||
approve it before it runs — explain what you are about to do first.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you must
|
||||
not present your output as personalized investment advice. Remind the user to do their own
|
||||
research before making decisions.
|
||||
"""
|
||||
|
||||
# A tiny in-memory book of (price, trailing EPS) so the sample runs without any external dependency.
|
||||
# These are illustrative mock values, not real market data.
|
||||
_PRICE_BOOK: dict[str, tuple[float, float]] = {
|
||||
"MSFT": (462.97, 11.80),
|
||||
"AAPL": (229.35, 6.13),
|
||||
"GOOGL": (178.12, 7.54),
|
||||
"AMZN": (201.45, 4.18),
|
||||
"NVDA": (134.81, 2.95),
|
||||
"SPY": (612.40, 23.10),
|
||||
}
|
||||
|
||||
|
||||
# <get_stock_price>
|
||||
def get_stock_price(
|
||||
symbol: Annotated[str, "The stock ticker symbol, e.g. MSFT or AAPL."],
|
||||
) -> dict[str, object]:
|
||||
"""Get the latest (delayed, illustrative) stock price and trailing EPS for a ticker symbol."""
|
||||
ticker = symbol.upper()
|
||||
data = _PRICE_BOOK.get(ticker)
|
||||
if data is None:
|
||||
# Deterministic pseudo-values for unknown symbols so the sample stays self-contained.
|
||||
# The built-in hash() is randomized per process (PYTHONHASHSEED), so derive a stable seed.
|
||||
seed = 0
|
||||
for ch in ticker:
|
||||
seed = (seed * 31 + ord(ch)) % 1_000_000
|
||||
price = 50.0 + (seed % 45000) / 100.0
|
||||
data = (price, round(price / 20.0, 2))
|
||||
|
||||
return {
|
||||
"symbol": ticker,
|
||||
"price": round(data[0], 2),
|
||||
"trailing_eps": round(data[1], 2),
|
||||
"currency": "USD",
|
||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# </get_stock_price>
|
||||
|
||||
|
||||
# <place_trade>
|
||||
@tool(approval_mode="always_require")
|
||||
def place_trade(
|
||||
symbol: Annotated[str, "The stock ticker symbol to trade, e.g. MSFT."],
|
||||
action: Annotated[Literal["buy", "sell"], "Either 'buy' or 'sell'."],
|
||||
quantity: Annotated[int, Field(gt=0, description="The number of shares to trade.")],
|
||||
) -> str:
|
||||
"""Place a (simulated) buy or sell order. Marked approval-required, so the harness asks the
|
||||
user to approve before this ever runs. No real order is placed.
|
||||
|
||||
``action`` and ``quantity`` are validated by the framework (pydantic) from their type hints:
|
||||
the model can only pass 'buy'/'sell' and a quantity greater than zero.
|
||||
"""
|
||||
verb = "Sold" if action == "sell" else "Bought"
|
||||
confirmation = f"TRADE-{uuid.uuid4().hex[:8].upper()}"
|
||||
return f"{verb} {quantity} share(s) of {symbol.upper()}. Confirmation: {confirmation}."
|
||||
|
||||
|
||||
# </place_trade>
|
||||
|
||||
|
||||
# <skills>
|
||||
async def _build_skills_provider(stack: AsyncExitStack) -> SkillsProvider:
|
||||
"""Build a skills provider over the local skills/ folder, plus optional Foundry-managed skills.
|
||||
|
||||
File-based skills (valuation, risk-scoring) always load. When FOUNDRY_TOOLBOX_MCP_SERVER_URL is
|
||||
set we also connect to a Foundry Toolbox MCP endpoint and surface its skills, so they can be
|
||||
managed and updated centrally without changing this agent.
|
||||
"""
|
||||
# subprocess_script_runner lets the file-based skills run their Python scripts.
|
||||
sources: list[SkillsSource] = [FileSkillsSource(str(_SKILLS_DIR), script_runner=subprocess_script_runner)]
|
||||
|
||||
toolbox_url = os.environ.get("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
|
||||
if toolbox_url:
|
||||
session = await _connect_foundry_toolbox(stack, toolbox_url)
|
||||
sources.append(MCPSkillsSource(client=session))
|
||||
print("Foundry skills enabled (Toolbox MCP).")
|
||||
else:
|
||||
print("Foundry skills disabled. Set FOUNDRY_TOOLBOX_MCP_SERVER_URL to enable them.")
|
||||
|
||||
source: SkillsSource = sources[0] if len(sources) == 1 else AggregatingSkillsSource(sources)
|
||||
return SkillsProvider(DeduplicatingSkillsSource(source))
|
||||
|
||||
|
||||
class _ToolboxAuth(httpx.Auth):
|
||||
"""Attach a fresh Foundry bearer token to every request."""
|
||||
|
||||
def __init__(self, token_provider: Callable[[], str]):
|
||||
self._get_token = token_provider
|
||||
|
||||
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
request.headers["Authorization"] = f"Bearer {self._get_token()}"
|
||||
yield request
|
||||
|
||||
|
||||
async def _connect_foundry_toolbox(stack: AsyncExitStack, url: str) -> ClientSession:
|
||||
"""Open an MCP session against a Foundry Toolbox endpoint, tied to ``stack``'s lifetime."""
|
||||
token_provider = get_bearer_token_provider(AzureCliCredential(), "https://ai.azure.com/.default")
|
||||
http_client = await stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
auth=_ToolboxAuth(token_provider),
|
||||
headers={"Foundry-Features": "Toolboxes=V1Preview"},
|
||||
timeout=httpx.Timeout(30.0, read=300.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
)
|
||||
read, write, _ = await stack.enter_async_context(streamable_http_client(url=url, http_client=http_client))
|
||||
session = await stack.enter_async_context(ClientSession(read, write))
|
||||
await session.initialize()
|
||||
return session
|
||||
|
||||
|
||||
# </skills>
|
||||
|
||||
|
||||
# <background>
|
||||
def _build_research_agent(client: FoundryChatClient) -> Any:
|
||||
"""Build the lean, web-search-only chat agent used for per-ticker research."""
|
||||
# This sub-agent doesn't need any harness machinery - it's a plain chat agent with a single
|
||||
# tool: the same hosted web search the harness would have added. The parent still exposes the
|
||||
# background_agents_* tools because it receives this agent via background_agents.
|
||||
return Agent(
|
||||
client=client,
|
||||
name="TickerResearchAgent",
|
||||
description="Searches the web for recent news and commentary about a single stock ticker.",
|
||||
tools=[client.get_web_search_tool()],
|
||||
instructions=(
|
||||
"You research a single stock ticker. Use the web search tool to find the most recent, "
|
||||
"relevant news and commentary, then return a short, factual summary (3-4 bullet points) "
|
||||
"with no preamble."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# </background>
|
||||
|
||||
|
||||
# <shell>
|
||||
def _build_shell() -> LocalShellTool:
|
||||
"""A sandboxed shell, confined to the trade-confirmation vault.
|
||||
|
||||
``confine_workdir`` re-anchors every command to the vault, and the deny-list pre-filters
|
||||
obviously destructive command shapes. (Patterns are a UX guardrail, not a security boundary —
|
||||
for hard isolation use DockerShellTool.) Left at the default ``approval_mode="always_require"``
|
||||
so each command is surfaced for approval.
|
||||
"""
|
||||
return LocalShellTool(
|
||||
mode="persistent",
|
||||
workdir=str(_VAULT_DIR),
|
||||
confine_workdir=True,
|
||||
policy=ShellPolicy(
|
||||
denylist=[
|
||||
r"\brm\s+-rf\b",
|
||||
r"\bsudo\b",
|
||||
r":\(\)\s*\{", # fork-bomb shape
|
||||
r"\bmkfs\b",
|
||||
r">\s*/dev/sd",
|
||||
],
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
|
||||
# </shell>
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_dotenv()
|
||||
_WORKING_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# <create_client>
|
||||
# Construct a chat client (see Post 1). FoundryChatClient reads FOUNDRY_PROJECT_ENDPOINT and
|
||||
# FOUNDRY_MODEL from the environment; AzureCliCredential handles auth (run `az login`).
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
# </create_client>
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
skills_provider = await _build_skills_provider(stack)
|
||||
research_agent = _build_research_agent(client)
|
||||
shell = _build_shell()
|
||||
|
||||
# <codeact>
|
||||
# CodeAct: a sandboxed Python interpreter the model can write and run code in to crunch
|
||||
# numbers. Monty is a pure, cross-platform interpreter, so it needs no extra setup.
|
||||
context_providers: list[Any] = [MontyCodeActProvider(approval_mode="never_require")]
|
||||
print("CodeAct enabled (Monty).")
|
||||
# </codeact>
|
||||
|
||||
# <create_agent>
|
||||
# Turn the chat client into a harness agent. On top of Post 2's file access and approvals we
|
||||
# add the four "scaling" capabilities: skills (our own provider), background agents, a
|
||||
# confined shell, and optional CodeAct. Read-only file tools are auto-approved so reading the
|
||||
# portfolio is frictionless while writes, trades, and shell commands still prompt.
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
agent_instructions=FINANCE_INSTRUCTIONS,
|
||||
tools=[get_stock_price, place_trade],
|
||||
file_access_store=FileSystemAgentFileStore(str(_WORKING_DIR)),
|
||||
skills_provider=skills_provider,
|
||||
background_agents=[research_agent],
|
||||
shell_executor=shell,
|
||||
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
|
||||
context_providers=context_providers,
|
||||
mode_provider=AgentModeProvider(default_mode="execute"),
|
||||
)
|
||||
# </create_agent>
|
||||
|
||||
# <run>
|
||||
session = agent.create_session()
|
||||
|
||||
# Run the interactive console session. The default planning observers already include a tool
|
||||
# approval observer, so the place_trade and run_shell approval prompts are surfaced
|
||||
# automatically.
|
||||
await run_agent_async(
|
||||
agent,
|
||||
session=session,
|
||||
observers=build_observers_with_planning(agent),
|
||||
initial_mode="execute",
|
||||
title="💹 Finance Assistant",
|
||||
placeholder="Value a stock, score your portfolio risk, research tickers, or tidy your confirmations...",
|
||||
)
|
||||
# </run>
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: risk-scoring
|
||||
description: Score how concentrated and risky a portfolio is on a 0-100 scale from its position weights. Use when the user asks how risky their portfolio is, whether it is too concentrated, or for a diversification check.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks about portfolio risk or concentration:
|
||||
|
||||
1. Read `references/risk-bands.md` to understand the score bands and what drives them.
|
||||
2. Compute each holding's market value (shares × price) — use the `get_stock_price` tool for current
|
||||
prices if you do not already have them.
|
||||
3. Run `scripts/risk_score.py` with one `--position VALUE` argument per holding,
|
||||
e.g. `--position 18518 --position 17201 --position 16177`.
|
||||
4. Report the 0-100 score, the band it falls in, and the largest single-position weight, then suggest
|
||||
(in general terms) whether the portfolio looks well diversified or concentrated.
|
||||
|
||||
Remind the user this is a crude concentration measure, not a complete risk model, and not advice.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Risk-scoring guide (illustrative)
|
||||
|
||||
This skill scores **concentration risk** — how much a portfolio depends on its largest positions —
|
||||
on a 0-100 scale, where higher means riskier.
|
||||
|
||||
## How the score is built
|
||||
|
||||
1. Convert each position to a weight: `weight = position_value / total_value`.
|
||||
2. Compute the Herfindahl-Hirschman Index (HHI): `HHI = sum(weight^2)`.
|
||||
- A perfectly even portfolio of *n* holdings has `HHI = 1/n` (low).
|
||||
- A single-stock portfolio has `HHI = 1` (maximum concentration).
|
||||
3. Scale to 0-100: `score = round(HHI * 100)`.
|
||||
|
||||
## Score bands
|
||||
|
||||
| Score | Band | Interpretation |
|
||||
|---------|--------------------|-------------------------------------------------|
|
||||
| 0-20 | Well diversified | No single holding dominates. |
|
||||
| 21-40 | Moderately diversified | Some tilt, but broadly spread. |
|
||||
| 41-60 | Concentrated | A few positions carry most of the risk. |
|
||||
| 61-100 | Highly concentrated| Heavily dependent on one or two positions. |
|
||||
|
||||
Also watch the **largest single-position weight**: above ~25% is usually worth flagging regardless
|
||||
of the overall score.
|
||||
|
||||
This measures concentration only — it ignores volatility, correlation, sector exposure, and leverage,
|
||||
so it is a starting point, not a verdict.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# Portfolio risk-scoring script
|
||||
# Scores concentration risk on a 0-100 scale using the Herfindahl-Hirschman Index (HHI).
|
||||
#
|
||||
# weight_i = position_i / total
|
||||
# HHI = sum(weight_i ^ 2)
|
||||
# score = round(HHI * 100) # higher = more concentrated = riskier
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/risk_score.py --position 18518 --position 17201 --position 16177
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Score portfolio concentration risk (0-100).")
|
||||
parser.add_argument(
|
||||
"--position",
|
||||
type=float,
|
||||
action="append",
|
||||
required=True,
|
||||
help="Market value of one holding. Pass once per position.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
positions = args.position
|
||||
if any(p <= 0 for p in positions):
|
||||
print(json.dumps({"error": "Each position value must be a positive market value."}))
|
||||
return
|
||||
|
||||
total = sum(positions)
|
||||
if total <= 0:
|
||||
print(json.dumps({"error": "Total portfolio value must be positive."}))
|
||||
return
|
||||
|
||||
weights = [p / total for p in positions]
|
||||
hhi = sum(w * w for w in weights)
|
||||
score = round(hhi * 100)
|
||||
|
||||
if score <= 20:
|
||||
band = "Well diversified"
|
||||
elif score <= 40:
|
||||
band = "Moderately diversified"
|
||||
elif score <= 60:
|
||||
band = "Concentrated"
|
||||
else:
|
||||
band = "Highly concentrated"
|
||||
|
||||
print(
|
||||
json.dumps({
|
||||
"positions": len(positions),
|
||||
"score": score,
|
||||
"band": band,
|
||||
"largest_weight_pct": round(max(weights) * 100, 1),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: valuation
|
||||
description: Estimate whether a stock looks cheap or expensive using a price-to-earnings (P/E) based fair-value method. Use when the user asks if a stock is over- or under-valued, or for a fair-value / target price.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks whether a stock is fairly valued, over-valued, or under-valued:
|
||||
|
||||
1. Read `references/valuation-guide.md` to pick a sensible target P/E for the company's sector.
|
||||
2. Run `scripts/valuation_metrics.py` with the current price, trailing EPS, and the target P/E,
|
||||
e.g. `--price 462.97 --eps 11.80 --target-pe 32`.
|
||||
3. Report the computed P/E, the fair-value estimate, and the percentage upside/downside, then state
|
||||
plainly whether the stock looks cheap or expensive on this measure.
|
||||
|
||||
Always remind the user that a single P/E heuristic is not investment advice and ignores growth,
|
||||
debt, and many other factors.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Valuation guide (illustrative)
|
||||
|
||||
A quick price-to-earnings (P/E) sanity check:
|
||||
|
||||
- **P/E = price ÷ trailing earnings per share (EPS)**
|
||||
- **Fair value = trailing EPS × target P/E**
|
||||
- **Upside/downside = (fair value − price) ÷ price**
|
||||
|
||||
## Typical target P/E by sector
|
||||
|
||||
These are rough, illustrative anchors only — not live market multiples.
|
||||
|
||||
| Sector | Conservative target P/E | Growth target P/E |
|
||||
|-----------------------|-------------------------|-------------------|
|
||||
| Mega-cap technology | 28 | 35 |
|
||||
| Semiconductors | 25 | 40 |
|
||||
| Consumer staples | 18 | 22 |
|
||||
| Financials / banks | 11 | 14 |
|
||||
| Broad market (index) | 19 | 21 |
|
||||
|
||||
## How to read the result
|
||||
|
||||
- Fair value **well above** the current price ⇒ the stock looks **cheap** on this measure.
|
||||
- Fair value **well below** the current price ⇒ the stock looks **expensive** on this measure.
|
||||
- Within ~5% ⇒ roughly **fairly valued**.
|
||||
|
||||
This is one crude lens. It ignores growth rates, balance-sheet strength, and cash flow, so never
|
||||
present it as a recommendation.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Valuation metrics script
|
||||
# Computes a simple price-to-earnings (P/E) based fair-value estimate.
|
||||
#
|
||||
# fair_value = eps * target_pe
|
||||
# pe = price / eps
|
||||
# upside = (fair_value - price) / price
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/valuation_metrics.py --price 462.97 --eps 11.80 --target-pe 32
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Compute a P/E based fair-value estimate.")
|
||||
parser.add_argument("--price", type=float, required=True, help="Current share price.")
|
||||
parser.add_argument("--eps", type=float, required=True, help="Trailing earnings per share.")
|
||||
parser.add_argument("--target-pe", type=float, required=True, help="Target P/E from the guide.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.eps <= 0:
|
||||
print(json.dumps({"error": "EPS must be positive to compute a P/E ratio."}))
|
||||
return
|
||||
|
||||
if args.price <= 0:
|
||||
print(json.dumps({"error": "Price must be positive to compute valuation metrics."}))
|
||||
return
|
||||
|
||||
if args.target_pe <= 0:
|
||||
print(json.dumps({"error": "Target P/E must be positive."}))
|
||||
return
|
||||
|
||||
pe = args.price / args.eps
|
||||
fair_value = args.eps * args.target_pe
|
||||
upside = (fair_value - args.price) / args.price
|
||||
|
||||
if upside > 0.05:
|
||||
verdict = "looks cheap"
|
||||
elif upside < -0.05:
|
||||
verdict = "looks expensive"
|
||||
else:
|
||||
verdict = "roughly fairly valued"
|
||||
|
||||
print(
|
||||
json.dumps({
|
||||
"price": round(args.price, 2),
|
||||
"eps": round(args.eps, 2),
|
||||
"target_pe": round(args.target_pe, 2),
|
||||
"pe": round(pe, 2),
|
||||
"fair_value": round(fair_value, 2),
|
||||
"upside_pct": round(upside * 100, 1),
|
||||
"verdict": verdict,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Sample subprocess-based skill script runner.
|
||||
Executes file-based skill scripts as local Python subprocesses.
|
||||
This is provided for demonstration purposes only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Uncomment this filter to suppress the experimental Skills warning before
|
||||
# using the sample's Skills APIs.
|
||||
# import warnings
|
||||
# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import FileSkill, FileSkillScript
|
||||
|
||||
|
||||
def subprocess_script_runner(
|
||||
skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | list[str] | None = None
|
||||
) -> str:
|
||||
"""Run a skill script as a local Python subprocess.
|
||||
Uses ``FileSkillScript.full_path`` as the script path, converts the
|
||||
``args`` to CLI arguments, and returns captured output.
|
||||
Args:
|
||||
skill: The file-based skill that owns the script.
|
||||
script: The file-based script to run.
|
||||
args: Optional arguments. A ``list[str]`` is forwarded as
|
||||
positional CLI arguments. Passing a ``dict`` or any other
|
||||
type raises :class:`TypeError` — file-based scripts expect
|
||||
positional arguments as a JSON array of strings.
|
||||
Returns:
|
||||
The combined stdout/stderr output, or an error message.
|
||||
Raises:
|
||||
TypeError: If ``args`` is not a ``list[str]`` or ``None``, or if
|
||||
any list element is not a string.
|
||||
"""
|
||||
script_path = Path(script.full_path)
|
||||
if not script_path.is_file():
|
||||
return f"Error: Script file not found: {script_path}"
|
||||
cmd = [sys.executable, str(script_path)]
|
||||
if isinstance(args, list):
|
||||
for item in args:
|
||||
if not isinstance(item, str):
|
||||
raise TypeError(
|
||||
f"File-based skill scripts only accept string CLI arguments "
|
||||
f"but received a {type(item).__name__}. "
|
||||
f"All array elements must be strings."
|
||||
)
|
||||
cmd.extend(args)
|
||||
elif args is not None:
|
||||
raise TypeError(
|
||||
f"Expected a list of CLI arguments but received {type(args).__name__}. "
|
||||
f"File-based skill scripts expect positional arguments as a list of strings."
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=str(script_path.parent),
|
||||
)
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output += f"\nStderr:\n{result.stderr}"
|
||||
if result.returncode != 0:
|
||||
output += f"\nScript exited with code {result.returncode}"
|
||||
return output.strip() or "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"Error: Script '{script.name}' timed out after 30 seconds."
|
||||
except OSError as e:
|
||||
return f"Error: Failed to execute script '{script.name}': {e}"
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-55AA44BB
|
||||
Date: 2025-06-21
|
||||
Symbol: NVDA
|
||||
Action: SELL
|
||||
Quantity: 20
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-77CC88DD
|
||||
Date: 2024-05-08
|
||||
Symbol: SPY
|
||||
Action: SELL
|
||||
Quantity: 15
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-9F8E7D6C
|
||||
Date: 2024-11-03
|
||||
Symbol: AAPL
|
||||
Action: BUY
|
||||
Quantity: 75
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-1234ABCD
|
||||
Date: 2025-09-12
|
||||
Symbol: AMZN
|
||||
Action: BUY
|
||||
Quantity: 30
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-EE11FF22
|
||||
Date: 2025-01-30
|
||||
Symbol: GOOGL
|
||||
Action: BUY
|
||||
Quantity: 25
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-A1B2C3D4
|
||||
Date: 2024-02-14
|
||||
Symbol: MSFT
|
||||
Action: BUY
|
||||
Quantity: 40
|
||||
@@ -0,0 +1,7 @@
|
||||
symbol,shares,cost_basis,purchase_date
|
||||
MSFT,40,312.50,2023-02-14
|
||||
AAPL,75,168.20,2022-11-03
|
||||
NVDA,120,42.80,2021-06-21
|
||||
AMZN,30,142.10,2023-09-12
|
||||
GOOGL,25,128.45,2024-01-30
|
||||
SPY,60,418.90,2024-05-08
|
||||
|
@@ -0,0 +1,94 @@
|
||||
# Harness Console
|
||||
|
||||
A Textual-based terminal UI for running and observing AI agents built with the Agent Framework.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from console import run_agent_async, build_default_observers
|
||||
|
||||
await run_agent_async(
|
||||
agent=my_agent,
|
||||
session=my_session,
|
||||
observers=build_default_observers(),
|
||||
)
|
||||
```
|
||||
|
||||
See [`harness_research.py`](../harness_research.py) for a complete example.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
console/
|
||||
├── __init__.py # Public API exports
|
||||
├── harness_console.py # run_agent_async() entry point
|
||||
├── app.py # HarnessApp (Textual application)
|
||||
├── app_state.py # HarnessAppState, enums, data types
|
||||
├── agent_runner.py # HarnessAgentRunner (streaming orchestration)
|
||||
├── state_driver.py # IUXStateDriver protocol
|
||||
├── textual_state_driver.py # Textual implementation of IUXStateDriver
|
||||
├── formatters.py # Tool call formatters
|
||||
├── observers/ # Lifecycle observers
|
||||
│ ├── base.py # ConsoleObserver abstract base
|
||||
│ ├── text_output.py # Streaming text display
|
||||
│ ├── tool_call_display.py # Tool call formatting
|
||||
│ ├── tool_approval.py # User approval for tool calls
|
||||
│ ├── error_display.py # Error messages
|
||||
│ ├── usage_display.py # Token usage tracking
|
||||
│ └── reasoning_display.py # Reasoning/thinking blocks
|
||||
├── components/ # Textual UI widgets
|
||||
│ ├── scroll_panel.py # Conversation history
|
||||
│ ├── text_input.py # User text input
|
||||
│ ├── list_selection.py # Multiple choice selector
|
||||
│ ├── agent_status.py # Spinner + usage display
|
||||
│ └── agent_mode_help.py # Mode indicator + help text
|
||||
└── commands/ # Slash command handlers
|
||||
├── base.py # CommandHandler abstract base
|
||||
├── exit_handler.py # /exit
|
||||
├── mode_handler.py # /mode [plan|execute]
|
||||
├── todo_handler.py # /todos
|
||||
└── session_handler.py # /session-export, /session-import
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `run_agent_async` | Main entry point — runs the Textual app with an agent |
|
||||
| `build_default_observers` | Factory for the standard observer set |
|
||||
| `build_default_command_handlers` | Factory for slash command handlers |
|
||||
| `ConsoleObserver` | Base class for custom observers |
|
||||
| `ToolCallFormatter` | Base class for custom tool formatters |
|
||||
| `CommandHandler` | Base class for custom slash commands |
|
||||
|
||||
## Architecture
|
||||
|
||||
The console follows a unidirectional data flow:
|
||||
|
||||
```
|
||||
AgentRunner → Observers → StateDriver → AppState → Textual UI
|
||||
↑
|
||||
User Input (app.py)
|
||||
```
|
||||
|
||||
- **AgentRunner** streams responses from the agent and dispatches events to observers.
|
||||
- **Observers** process events (text chunks, tool calls, errors) and update the state driver.
|
||||
- **StateDriver** (`IUXStateDriver`) mutates `HarnessAppState` and notifies the UI.
|
||||
- **Textual App** reads state and syncs widgets on each notification.
|
||||
|
||||
### Key Design Choices
|
||||
|
||||
| Concern | Approach |
|
||||
|---------|----------|
|
||||
| Rendering | Textual widgets + Rich markup (no manual ANSI) |
|
||||
| State | Single `HarnessAppState` dataclass, mutated by driver |
|
||||
| Streaming text | Truncate-and-rewrite on RichLog for flicker-free updates |
|
||||
| Extensibility | Custom observers, formatters, and commands via base classes |
|
||||
| Follow-up questions | Observer returns `FollowUpQuestion` → UI shows prompt/choices |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `textual` — TUI framework
|
||||
- `rich` — Text formatting
|
||||
- `agent-framework` — Core agent framework
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness Console - A Textual-based TUI for AI agent interactions.
|
||||
|
||||
This package provides a rich terminal interface for running and observing
|
||||
AI agents, with streaming output, tool call display, follow-up questions,
|
||||
and token usage tracking.
|
||||
"""
|
||||
|
||||
from .commands import CommandHandler, build_default_command_handlers
|
||||
from .formatters import ToolCallFormatter
|
||||
from .harness_console import run_agent_async
|
||||
from .observers import (
|
||||
ConsoleObserver,
|
||||
build_default_observers,
|
||||
build_observers_with_planning,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CommandHandler",
|
||||
"ConsoleObserver",
|
||||
"ToolCallFormatter",
|
||||
"build_default_command_handlers",
|
||||
"build_default_observers",
|
||||
"build_observers_with_planning",
|
||||
"run_agent_async",
|
||||
]
|
||||
@@ -0,0 +1,427 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent runner orchestration for the harness console.
|
||||
|
||||
This module provides the HarnessAgentRunner class, which orchestrates agent
|
||||
invocations with observer lifecycle management. It handles:
|
||||
- User input dispatch
|
||||
- Agent streaming with observer notifications
|
||||
- Follow-up action collection
|
||||
- Streaming state management
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentSession, MessageInjectionMiddleware
|
||||
from agent_framework import Message as FrameworkMessage
|
||||
|
||||
from .app_state import FollowUpAction
|
||||
from .observers.base import ConsoleObserver
|
||||
from .state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class HarnessAgentRunner:
|
||||
"""Orchestrates agent invocations driven by user-input events from the UI.
|
||||
|
||||
The component invokes the runner's input handlers (run_turn) directly;
|
||||
the runner mutates UI state through the supplied IUXStateDriver.
|
||||
|
||||
When the underlying agent has a ``MessageInjectionMiddleware`` wired in
|
||||
(as ``create_harness_agent`` does by default), the runner supports message
|
||||
injection: input submitted while a turn is streaming is enqueued via
|
||||
``on_streaming_input`` and drained into the ongoing run by the middleware.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
observers: list[ConsoleObserver],
|
||||
state_driver: IUXStateDriver,
|
||||
*,
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the agent runner.
|
||||
|
||||
Args:
|
||||
agent: The agent to orchestrate.
|
||||
observers: List of console observers for lifecycle events.
|
||||
state_driver: The UI state driver for observer updates.
|
||||
max_context_window_tokens: Optional max context window size for usage display.
|
||||
max_output_tokens: Optional max output tokens for usage display.
|
||||
"""
|
||||
self._agent = agent
|
||||
self._observers = observers
|
||||
self._ux = state_driver
|
||||
self._max_context_window_tokens = max_context_window_tokens
|
||||
self._max_output_tokens = max_output_tokens
|
||||
self._input_gate = asyncio.Semaphore(1) # Single turn at a time
|
||||
|
||||
# Resolve the message-injection middleware (if any) so streaming-time
|
||||
# input can be enqueued into the ongoing run. Absent => injection no-ops.
|
||||
from agent_framework import MessageInjectionMiddleware
|
||||
|
||||
self._message_injector: MessageInjectionMiddleware | None = next(
|
||||
(m for m in (agent.middleware or []) if isinstance(m, MessageInjectionMiddleware)),
|
||||
None,
|
||||
)
|
||||
# Snapshot of pending injected messages, used to detect consumption
|
||||
# during streaming. Safe as instance state because _input_gate
|
||||
# serialises turns.
|
||||
self._last_pending_messages: list[FrameworkMessage] = []
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession | None = None,
|
||||
) -> None:
|
||||
"""Run a single agent turn with the given user input.
|
||||
|
||||
Echoes the input, then delegates to the agent loop.
|
||||
|
||||
Args:
|
||||
user_input: The user's input text.
|
||||
session: Optional agent session for conversation history.
|
||||
"""
|
||||
async with self._input_gate:
|
||||
self._ux.write_user_input_echo(user_input)
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
messages = [Message(role="user", contents=[user_input])]
|
||||
await self._run_agent_loop(messages, session)
|
||||
|
||||
async def start_agent_turn(
|
||||
self,
|
||||
messages: list,
|
||||
session: AgentSession | None = None,
|
||||
) -> None:
|
||||
"""Resume the agent loop with pre-built messages (from follow-up responses).
|
||||
|
||||
Called by the app after the user finishes answering follow-up questions.
|
||||
If messages is empty, just completes the turn.
|
||||
|
||||
Args:
|
||||
messages: List of Message objects to send to the agent.
|
||||
session: Optional agent session.
|
||||
"""
|
||||
async with self._input_gate:
|
||||
if not messages:
|
||||
self._complete_turn()
|
||||
return
|
||||
await self._run_agent_loop(messages, session)
|
||||
|
||||
def on_streaming_input(
|
||||
self,
|
||||
text: str,
|
||||
session: AgentSession | None = None,
|
||||
) -> None:
|
||||
"""Handle user input submitted while an agent turn is streaming.
|
||||
|
||||
The text is enqueued via the ``MessageInjectionMiddleware`` so the agent
|
||||
can pick it up on its next opportunity within the ongoing run. No-op if
|
||||
the agent has no injection middleware or there is no active session.
|
||||
|
||||
Args:
|
||||
text: The user's input text.
|
||||
session: The active agent session.
|
||||
"""
|
||||
if self._message_injector is None or session is None:
|
||||
return
|
||||
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
self._message_injector.enqueue_messages(session, Message(role="user", contents=[text]))
|
||||
pending = self._message_injector.get_pending_messages(session)
|
||||
self._ux.set_queued_messages([m.text for m in pending])
|
||||
|
||||
def _sync_queued_message_display(self, session: AgentSession | None) -> None:
|
||||
"""Sync the queued-items display with the injector's pending messages.
|
||||
|
||||
Messages that have been consumed (drained by the middleware) since the
|
||||
last sync are echoed to the output area as regular user-input entries.
|
||||
No-op if there is no injection middleware or active session.
|
||||
|
||||
Args:
|
||||
session: The active agent session.
|
||||
"""
|
||||
if self._message_injector is None or session is None:
|
||||
return
|
||||
|
||||
pending = self._message_injector.get_pending_messages(session)
|
||||
|
||||
# The injection middleware drains the whole queue at once, so a message
|
||||
# is consumed when it is no longer present in the pending list. Compare
|
||||
# by object identity (snapshots share the same Message objects until the
|
||||
# queue is cleared) so consumed messages are echoed correctly even if a
|
||||
# drain is followed by a new enqueue before the next sync.
|
||||
current_ids = {id(m) for m in pending}
|
||||
for msg in self._last_pending_messages:
|
||||
if id(msg) not in current_ids:
|
||||
self._ux.write_user_input_echo(msg.text or "")
|
||||
|
||||
self._last_pending_messages = pending
|
||||
self._ux.set_queued_messages([m.text for m in pending])
|
||||
|
||||
async def _run_agent_loop(
|
||||
self,
|
||||
messages: list,
|
||||
session: AgentSession | None,
|
||||
) -> None:
|
||||
"""Run the agent loop, re-invoking as needed for follow-up messages.
|
||||
|
||||
Loops while there are messages to send. After each stream:
|
||||
- Collects follow-up actions from observers
|
||||
- If questions exist → queue them and return (UI will collect answers)
|
||||
- If only direct messages → loop with those messages
|
||||
- If nothing → complete the turn
|
||||
|
||||
Args:
|
||||
messages: Initial messages to send.
|
||||
session: Optional agent session.
|
||||
"""
|
||||
next_messages = messages
|
||||
|
||||
# Seed the pending-message snapshot so consumed injected messages can be
|
||||
# detected and echoed during streaming.
|
||||
self._last_pending_messages = (
|
||||
self._message_injector.get_pending_messages(session)
|
||||
if self._message_injector is not None and session is not None
|
||||
else []
|
||||
)
|
||||
|
||||
while next_messages:
|
||||
# Configure run options
|
||||
options = self._configure_run_options(session)
|
||||
|
||||
# Begin streaming
|
||||
self._ux.begin_streaming()
|
||||
self._ux.begin_streaming_output()
|
||||
self._ux.set_show_spinner(True)
|
||||
|
||||
try:
|
||||
await self._stream_response_messages(next_messages, session, options)
|
||||
except Exception as ex:
|
||||
self._ux.append_info_line(
|
||||
f"❌ Stream error: {ex.__class__.__name__}:\n{ex}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
# Final sync after streaming (echo any messages consumed on the last update).
|
||||
self._sync_queued_message_display(session)
|
||||
|
||||
# Stop spinner and end streaming output
|
||||
self._ux.set_show_spinner(False)
|
||||
|
||||
# Collect follow-up actions from observers
|
||||
follow_up_actions = await self._collect_follow_up_actions(session)
|
||||
|
||||
# Separate direct messages from questions
|
||||
has_follow_ups = len(follow_up_actions) > 0
|
||||
|
||||
# Write no-text warning if applicable
|
||||
await self._ux.write_no_text_warning(has_follow_ups)
|
||||
|
||||
# Enqueue all follow-up actions
|
||||
for action in follow_up_actions:
|
||||
self._ux.enqueue_follow_up_action(action)
|
||||
|
||||
# Check if there are pending questions (UI needs user input)
|
||||
if self._ux.has_pending_questions():
|
||||
# Pause — the UI will collect answers and call start_agent_turn
|
||||
return
|
||||
|
||||
# No questions — drain any accumulated direct messages and loop
|
||||
drained = self._ux.take_follow_up_responses()
|
||||
next_messages = drained if drained else None
|
||||
|
||||
self._complete_turn()
|
||||
|
||||
def _complete_turn(self) -> None:
|
||||
"""Complete the current turn (end streaming)."""
|
||||
self._ux.end_streaming()
|
||||
|
||||
def _configure_run_options(
|
||||
self,
|
||||
session: AgentSession | None,
|
||||
) -> dict:
|
||||
"""Configure run options via observers.
|
||||
|
||||
Each observer can modify the options dict to influence agent behavior.
|
||||
|
||||
Args:
|
||||
session: Optional agent session.
|
||||
|
||||
Returns:
|
||||
Options dict for agent.run().
|
||||
"""
|
||||
options = {}
|
||||
for observer in self._observers:
|
||||
observer.configure_run_options(options, self._agent, session)
|
||||
return options
|
||||
|
||||
async def _stream_response(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession | None,
|
||||
options: dict,
|
||||
) -> None:
|
||||
"""Stream agent response from a text input and dispatch to observers.
|
||||
|
||||
Args:
|
||||
user_input: The user's input text.
|
||||
session: Optional agent session.
|
||||
options: Run options configured by observers.
|
||||
"""
|
||||
# Stream response using agent.run(stream=True)
|
||||
stream = self._agent.run(
|
||||
user_input,
|
||||
stream=True,
|
||||
session=session,
|
||||
options=options,
|
||||
)
|
||||
|
||||
# Process each update chunk
|
||||
async for update in stream:
|
||||
await self._dispatch_update(update, session)
|
||||
|
||||
# Extract usage from the final response
|
||||
self._extract_usage(stream)
|
||||
|
||||
async def _stream_response_messages(
|
||||
self,
|
||||
messages: list,
|
||||
session: AgentSession | None,
|
||||
options: dict,
|
||||
) -> None:
|
||||
"""Stream agent response from Message objects and dispatch to observers.
|
||||
|
||||
Args:
|
||||
messages: List of Message objects to send.
|
||||
session: Optional agent session.
|
||||
options: Run options configured by observers.
|
||||
"""
|
||||
stream = self._agent.run(
|
||||
messages,
|
||||
stream=True,
|
||||
session=session,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async for update in stream:
|
||||
await self._dispatch_update(update, session)
|
||||
|
||||
self._extract_usage(stream)
|
||||
|
||||
def _extract_usage(self, stream) -> None:
|
||||
"""Extract token usage from a completed stream."""
|
||||
try:
|
||||
get_final = getattr(stream, "get_final_response", None)
|
||||
if not get_final:
|
||||
return
|
||||
|
||||
import inspect
|
||||
|
||||
if inspect.iscoroutinefunction(get_final):
|
||||
return
|
||||
|
||||
final_response = get_final()
|
||||
if final_response is None:
|
||||
return
|
||||
|
||||
usage = getattr(final_response, "usage_details", None)
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
|
||||
input_tokens = usage.get("input_token_count", 0) or 0
|
||||
output_tokens = usage.get("output_token_count", 0) or 0
|
||||
if input_tokens or output_tokens:
|
||||
self._ux.set_usage_text(self._format_usage(input_tokens, output_tokens))
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
async def _dispatch_update(
|
||||
self,
|
||||
update, # AgentResponseUpdate
|
||||
session: AgentSession | None,
|
||||
) -> None:
|
||||
"""Dispatch a single update to all observers.
|
||||
|
||||
Calls observer lifecycle methods in order:
|
||||
1. on_response_update (once per update)
|
||||
2. on_content (for each content item)
|
||||
3. on_text (if text is present)
|
||||
|
||||
Args:
|
||||
update: The agent response update.
|
||||
session: Optional agent session.
|
||||
"""
|
||||
# on_response_update
|
||||
for observer in self._observers:
|
||||
await observer.on_response_update(self._ux, update, self._agent, session)
|
||||
|
||||
# on_content for each content item
|
||||
if hasattr(update, "contents") and update.contents:
|
||||
for content in update.contents:
|
||||
for observer in self._observers:
|
||||
await observer.on_content(self._ux, content, self._agent, session)
|
||||
|
||||
# on_text for text chunks
|
||||
if hasattr(update, "text") and update.text:
|
||||
for observer in self._observers:
|
||||
await observer.on_text(self._ux, update.text, self._agent, session)
|
||||
|
||||
# Echo any injected messages consumed by the agent on this update.
|
||||
self._sync_queued_message_display(session)
|
||||
|
||||
async def _collect_follow_up_actions(
|
||||
self,
|
||||
session: AgentSession | None,
|
||||
) -> list[FollowUpAction]:
|
||||
"""Collect follow-up actions from all observers.
|
||||
|
||||
Called after streaming completes to gather any follow-up questions
|
||||
or messages from observers.
|
||||
|
||||
Args:
|
||||
session: Optional agent session.
|
||||
|
||||
Returns:
|
||||
List of follow-up actions from all observers.
|
||||
"""
|
||||
actions: list[FollowUpAction] = []
|
||||
for observer in self._observers:
|
||||
observer_actions = await observer.on_stream_complete(self._ux, self._agent, session)
|
||||
if observer_actions:
|
||||
actions.extend(observer_actions)
|
||||
return actions
|
||||
|
||||
def _format_usage(self, input_tokens: int, output_tokens: int) -> str:
|
||||
"""Format token counts matching C# harness style: 📊 Tokens — input: X | output: Y | total: Z."""
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
input_budget = None
|
||||
if self._max_context_window_tokens and self._max_output_tokens:
|
||||
input_budget = self._max_context_window_tokens - self._max_output_tokens
|
||||
|
||||
return (
|
||||
f"📊 Tokens — input: {self._format_token_count(input_tokens, input_budget)}"
|
||||
f" | output: {self._format_token_count(output_tokens, self._max_output_tokens)}"
|
||||
f" | total: {self._format_token_count(total_tokens, self._max_context_window_tokens)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_token_count(count: int, budget: int | None) -> str:
|
||||
"""Format a token count, optionally showing budget percentage."""
|
||||
if budget and budget > 0:
|
||||
pct = count / budget * 100
|
||||
return f"{count:,}/{budget:,} ({pct:.1f}%)"
|
||||
return f"{count:,}"
|
||||
@@ -0,0 +1,546 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Main Textual application for the harness console.
|
||||
|
||||
This module provides the HarnessApp - the main Textual application that
|
||||
composes all UI components and integrates with the agent runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual import on, work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
from .app_state import (
|
||||
BottomPanelMode,
|
||||
HarnessAppState,
|
||||
OutputEntryType,
|
||||
)
|
||||
from .components import (
|
||||
AgentModeAndHelp,
|
||||
AgentStatus,
|
||||
HarnessListSelection,
|
||||
HarnessScrollPanel,
|
||||
HarnessTextInput,
|
||||
PromptRule,
|
||||
)
|
||||
from .textual_state_driver import HarnessConsoleUXStateDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentSession
|
||||
|
||||
from .agent_runner import HarnessAgentRunner
|
||||
from .commands import CommandHandler
|
||||
from .observers.base import ConsoleObserver
|
||||
|
||||
|
||||
class HarnessApp(App[None]):
|
||||
"""Main Textual application for the harness console.
|
||||
|
||||
Composes the scroll panel (conversation history), status bar (spinner, usage),
|
||||
mode/help display, and bottom panel (text input, list selection, or streaming
|
||||
indicator). Routes user input to the agent runner.
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
#scroll-panel {
|
||||
height: 1fr;
|
||||
padding: 0 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#bottom-panel {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#text-input-container {
|
||||
height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#list-selection-container {
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#streaming-indicator {
|
||||
height: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#mode-help {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#top-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#bottom-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#separator-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#text-input {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-height: 1;
|
||||
height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prompt-container {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.prompt-label {
|
||||
width: 2;
|
||||
min-width: 2;
|
||||
height: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "Quit", show=False),
|
||||
Binding("ctrl+q", "quit", "Quit", show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
observers: list[ConsoleObserver],
|
||||
session: AgentSession | None = None,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
initial_mode: str | None = None,
|
||||
placeholder: str = "Type a message and press Enter...",
|
||||
title: str = "Harness Console",
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
command_handlers: list[CommandHandler] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the harness console application.
|
||||
|
||||
Args:
|
||||
agent: The agent to run.
|
||||
observers: List of console observers.
|
||||
session: Optional agent session.
|
||||
mode_colors: Optional mode color mapping.
|
||||
initial_mode: Initial agent mode.
|
||||
placeholder: Input placeholder text.
|
||||
title: Application title.
|
||||
max_context_window_tokens: Optional max context window tokens for usage display.
|
||||
max_output_tokens: Optional max output tokens for usage display.
|
||||
command_handlers: Optional list of command handlers. If None, auto-detected.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self._agent = agent
|
||||
self._observers = observers
|
||||
self._session = session
|
||||
self._mode_colors = mode_colors
|
||||
self._initial_mode = initial_mode
|
||||
self._placeholder = placeholder
|
||||
self._max_context_window_tokens = max_context_window_tokens
|
||||
self._max_output_tokens = max_output_tokens
|
||||
|
||||
# Build command handlers
|
||||
if command_handlers is None:
|
||||
from .commands import build_default_command_handlers
|
||||
|
||||
self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors)
|
||||
else:
|
||||
self._command_handlers = command_handlers
|
||||
|
||||
# Compute help text from command handlers
|
||||
help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None]
|
||||
help_text = ", ".join(help_parts) if help_parts else None
|
||||
|
||||
# State and driver
|
||||
self._app_state = HarnessAppState(
|
||||
placeholder=placeholder,
|
||||
mode_text=initial_mode,
|
||||
help_text=help_text,
|
||||
)
|
||||
self._ux_driver = HarnessConsoleUXStateDriver(
|
||||
app_state=self._app_state,
|
||||
on_state_changed=self._on_state_changed,
|
||||
mode_colors=mode_colors,
|
||||
)
|
||||
|
||||
# Agent runner (created after init)
|
||||
self._runner: HarnessAgentRunner | None = None
|
||||
|
||||
@property
|
||||
def ux_driver(self) -> HarnessConsoleUXStateDriver:
|
||||
"""Get the UX state driver."""
|
||||
return self._ux_driver
|
||||
|
||||
@property
|
||||
def runner(self) -> HarnessAgentRunner | None:
|
||||
"""Get the agent runner."""
|
||||
return self._runner
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the application layout."""
|
||||
with Vertical():
|
||||
# Main scroll panel for conversation history
|
||||
yield HarnessScrollPanel(id="scroll-panel")
|
||||
|
||||
# Blank line separating scroll content from status area
|
||||
yield Static(" ", id="separator-rule")
|
||||
|
||||
# Status bar (spinner + usage)
|
||||
yield AgentStatus(id="status-bar")
|
||||
|
||||
# Top rule (mode-colored)
|
||||
yield PromptRule(id="top-rule")
|
||||
|
||||
# Bottom panel - switches between text input, list selection, streaming
|
||||
with Container(id="bottom-panel"):
|
||||
# Text input (default)
|
||||
with Container(id="text-input-container"):
|
||||
text_input = HarnessTextInput(id="text-input")
|
||||
text_input.placeholder = self._placeholder
|
||||
yield text_input
|
||||
|
||||
# List selection (for follow-up questions)
|
||||
with Container(id="list-selection-container"):
|
||||
yield HarnessListSelection(id="list-selection")
|
||||
|
||||
# Bottom rule (mode-colored)
|
||||
yield PromptRule(id="bottom-rule")
|
||||
|
||||
# Mode and help
|
||||
yield AgentModeAndHelp(id="mode-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize after mount."""
|
||||
# Create agent runner now that everything is set up
|
||||
from .agent_runner import HarnessAgentRunner
|
||||
|
||||
self._runner = HarnessAgentRunner(
|
||||
agent=self._agent,
|
||||
observers=self._observers,
|
||||
state_driver=self._ux_driver,
|
||||
max_context_window_tokens=self._max_context_window_tokens,
|
||||
max_output_tokens=self._max_output_tokens,
|
||||
)
|
||||
|
||||
# Set initial mode
|
||||
if self._initial_mode:
|
||||
self._ux_driver.current_mode = self._initial_mode
|
||||
|
||||
# Focus the text input
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.focus_input()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
# Set initial rule colors and mode display
|
||||
self._sync_mode_help()
|
||||
|
||||
# --- Event handlers ---
|
||||
|
||||
@on(HarnessTextInput.Submitted)
|
||||
def on_text_submitted(self, event: HarnessTextInput.Submitted) -> None:
|
||||
"""Handle text input submission."""
|
||||
text = event.value.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if self._app_state.pending_questions:
|
||||
# Answer the current follow-up question
|
||||
self._handle_follow_up_answer(text)
|
||||
elif self._app_state.mode == BottomPanelMode.STREAMING:
|
||||
# Input submitted while the agent is streaming — enqueue it for
|
||||
# injection into the ongoing run. Handled synchronously so it does
|
||||
# not cancel the exclusive turn worker.
|
||||
if self._runner is not None:
|
||||
self._runner.on_streaming_input(text, self._session)
|
||||
elif text.startswith("/"):
|
||||
# Try command handlers
|
||||
self._try_command_handlers(text)
|
||||
else:
|
||||
# Normal user input - run agent turn
|
||||
self._run_agent_turn(text)
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _try_command_handlers(self, text: str) -> None:
|
||||
"""Try each command handler; fall through to agent if none match."""
|
||||
session = self._session
|
||||
if session is None:
|
||||
# No session — fall through to agent turn
|
||||
self._run_agent_turn(text)
|
||||
return
|
||||
|
||||
for handler in self._command_handlers:
|
||||
if await handler.try_handle(text, session, self._ux_driver):
|
||||
# Command handled — check for shutdown/session swap signals
|
||||
self._process_command_signals()
|
||||
return
|
||||
|
||||
# No handler matched — treat as normal agent input
|
||||
self._run_agent_turn(text)
|
||||
|
||||
def _process_command_signals(self) -> None:
|
||||
"""Check and process signals set by command handlers."""
|
||||
if self._app_state.shutdown_requested:
|
||||
self.exit()
|
||||
return
|
||||
|
||||
if self._app_state.replaced_session is not None:
|
||||
self._session = self._app_state.replaced_session # type: ignore[assignment]
|
||||
self._app_state.replaced_session = None
|
||||
self._ux_driver.append_info_line("Session replaced.")
|
||||
|
||||
self._sync_ui_from_state()
|
||||
|
||||
@on(HarnessListSelection.Selected)
|
||||
def on_list_selected(self, event: HarnessListSelection.Selected) -> None:
|
||||
"""Handle list selection."""
|
||||
self._handle_follow_up_answer(event.value)
|
||||
|
||||
# --- Agent turn ---
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _run_agent_turn(self, text: str) -> None:
|
||||
"""Run an agent turn in a background worker."""
|
||||
if self._runner is None:
|
||||
return
|
||||
|
||||
await self._runner.run_turn(text, session=self._session)
|
||||
|
||||
# After turn completes, check for follow-up questions
|
||||
self._sync_ui_from_state()
|
||||
|
||||
# --- Follow-up question handling ---
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _handle_follow_up_answer(self, answer: str) -> None:
|
||||
"""Handle a user's answer to a follow-up question."""
|
||||
if not self._app_state.pending_questions:
|
||||
return
|
||||
|
||||
question = self._app_state.pending_questions[0]
|
||||
|
||||
# Call the continuation
|
||||
result_message = await question.continuation(answer, self._ux_driver)
|
||||
|
||||
# Add result to accumulated responses
|
||||
if result_message is not None:
|
||||
self._ux_driver.add_follow_up_response(result_message)
|
||||
|
||||
# Advance to next question
|
||||
self._ux_driver.advance_follow_up_question()
|
||||
|
||||
# If no more questions, resume the agent with accumulated responses
|
||||
if not self._app_state.pending_questions:
|
||||
responses = self._ux_driver.take_follow_up_responses()
|
||||
if responses and self._runner:
|
||||
await self._runner.start_agent_turn(responses, session=self._session)
|
||||
|
||||
self._sync_ui_from_state()
|
||||
|
||||
# --- State synchronization ---
|
||||
|
||||
def _on_state_changed(self) -> None:
|
||||
"""Called by state driver when state changes - schedule UI sync.
|
||||
|
||||
Since the agent runner uses @work(thread=False), state changes happen
|
||||
on the main event loop. We use call_later to batch updates.
|
||||
"""
|
||||
self.call_later(self._sync_ui_from_state)
|
||||
|
||||
def _sync_ui_from_state(self) -> None:
|
||||
"""Synchronize UI components with current application state."""
|
||||
state = self._app_state
|
||||
|
||||
# Update scroll panel with new entries
|
||||
self._sync_scroll_panel()
|
||||
|
||||
# Update bottom panel mode
|
||||
self._sync_bottom_panel(state.mode)
|
||||
|
||||
# Hide status bar and mode/help during list selection (matching C#)
|
||||
is_list_mode = state.mode == BottomPanelMode.LIST_SELECTION
|
||||
self._sync_chrome_visibility(not is_list_mode)
|
||||
|
||||
# Update status bar
|
||||
self._sync_status_bar()
|
||||
|
||||
# Update mode/help display
|
||||
self._sync_mode_help()
|
||||
|
||||
def _sync_scroll_panel(self) -> None:
|
||||
"""Sync the scroll panel with output entries."""
|
||||
try:
|
||||
panel = self.query_one("#scroll-panel", HarnessScrollPanel)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
entries = self._app_state.output_entries
|
||||
rendered_count = getattr(self, "_rendered_entry_count", 0)
|
||||
|
||||
if rendered_count < len(entries):
|
||||
# There are new entries to render
|
||||
for entry in entries[rendered_count:]:
|
||||
if entry.type == OutputEntryType.STREAMING_TEXT:
|
||||
panel.set_streaming_entry(entry)
|
||||
else:
|
||||
# End any active streaming before appending other entry types
|
||||
panel.end_streaming()
|
||||
panel.append_entry(entry)
|
||||
self._rendered_entry_count = len(entries)
|
||||
elif rendered_count == len(entries) and entries:
|
||||
# Same count — check if the last entry is a streaming entry that was mutated
|
||||
last_entry = entries[-1]
|
||||
if last_entry.type == OutputEntryType.STREAMING_TEXT:
|
||||
panel.set_streaming_entry(last_entry)
|
||||
|
||||
def _sync_bottom_panel(self, mode: BottomPanelMode) -> None:
|
||||
"""Switch the bottom panel between text input, list, and streaming."""
|
||||
try:
|
||||
text_container = self.query_one("#text-input-container")
|
||||
list_container = self.query_one("#list-selection-container")
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
if mode == BottomPanelMode.TEXT_INPUT:
|
||||
text_container.display = True
|
||||
list_container.display = False
|
||||
# Restore the normal placeholder and focus to text input
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.placeholder = self._placeholder
|
||||
text_input.focus_input()
|
||||
except NoMatches:
|
||||
pass
|
||||
elif mode == BottomPanelMode.LIST_SELECTION:
|
||||
text_container.display = False
|
||||
list_container.display = True
|
||||
self._sync_list_selection()
|
||||
elif mode == BottomPanelMode.STREAMING:
|
||||
text_container.display = True
|
||||
list_container.display = False
|
||||
# Hint that typed input will be queued for injection into the run.
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.placeholder = "type to queue a message for the agent…"
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _sync_list_selection(self) -> None:
|
||||
"""Sync the list selection widget with state."""
|
||||
try:
|
||||
list_widget = self.query_one("#list-selection", HarnessListSelection)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
list_widget.title = state.list_selection_title or ""
|
||||
list_widget.options = list(state.list_selection_options)
|
||||
list_widget.allow_custom_text = state.list_selection_custom_text_placeholder is not None
|
||||
|
||||
if state.list_selection_custom_text_placeholder:
|
||||
try:
|
||||
custom_input = list_widget.query_one("#custom-input", Input)
|
||||
custom_input.placeholder = state.list_selection_custom_text_placeholder
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Focus the option list so keyboard navigation works immediately
|
||||
list_widget.focus_list()
|
||||
|
||||
def _sync_status_bar(self) -> None:
|
||||
"""Sync the status bar with state."""
|
||||
try:
|
||||
status = self.query_one("#status-bar", AgentStatus)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
status.show_spinner = state.show_spinner
|
||||
status.usage_text = state.usage_text or ""
|
||||
status.queued_text = " ".join(state.queued_items)
|
||||
|
||||
def _sync_mode_help(self) -> None:
|
||||
"""Sync the mode/help display and rule colors with state."""
|
||||
try:
|
||||
mode_help = self.query_one("#mode-help", AgentModeAndHelp)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
mode_help.mode = state.mode_text or ""
|
||||
mode_help.mode_color = state.mode_color or "blue"
|
||||
mode_help.help_text = state.help_text or ""
|
||||
|
||||
# Sync rule colors to match mode
|
||||
color = state.mode_color or "cyan"
|
||||
try:
|
||||
top_rule = self.query_one("#top-rule", PromptRule)
|
||||
top_rule.rule_color = color
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
try:
|
||||
bottom_rule = self.query_one("#bottom-rule", PromptRule)
|
||||
bottom_rule.rule_color = color
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _sync_chrome_visibility(self, visible: bool) -> None:
|
||||
"""Show or hide chrome elements (status bar, mode/help).
|
||||
|
||||
During list selection mode, these are hidden to give more vertical
|
||||
space to the scroll panel and list picker.
|
||||
|
||||
Args:
|
||||
visible: Whether chrome elements should be visible.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one("#status-bar", AgentStatus).display = visible
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one("#mode-help", AgentModeAndHelp).display = visible
|
||||
|
||||
# --- Rendering count tracking ---
|
||||
|
||||
_rendered_entry_count: int = 0
|
||||
@@ -0,0 +1,260 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Application state and core data types for the harness console.
|
||||
|
||||
This module defines enums, dataclasses, follow-up action types, and the
|
||||
HarnessAppState dataclass which holds all UI state that may change during
|
||||
application execution. The state driver mutates this state to coordinate
|
||||
between the agent runner and the Textual UI components.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Message
|
||||
|
||||
from .state_driver import IUXStateDriver
|
||||
|
||||
|
||||
# region Enums
|
||||
|
||||
|
||||
class OutputEntryType(Enum):
|
||||
"""Type of output entry in the console conversation."""
|
||||
|
||||
USER_INPUT = "user_input"
|
||||
"""User input echo (e.g., 'You: hello')."""
|
||||
|
||||
STREAMING_TEXT = "streaming_text"
|
||||
"""In-progress streaming text from the agent (accumulated chunk by chunk)."""
|
||||
|
||||
INFO_LINE = "info_line"
|
||||
"""Informational line (tool calls, errors, usage, approval requests, etc.)."""
|
||||
|
||||
STREAM_FOOTER = "stream_footer"
|
||||
"""Stream footer (e.g., '(no text response from agent)')."""
|
||||
|
||||
PENDING_MESSAGE = "pending_message"
|
||||
"""Pending injected message notification."""
|
||||
|
||||
|
||||
class BottomPanelMode(Enum):
|
||||
"""Mode of the bottom panel UI."""
|
||||
|
||||
TEXT_INPUT = "text_input"
|
||||
"""Show text input for user messages."""
|
||||
|
||||
LIST_SELECTION = "list_selection"
|
||||
"""Show choice list for user selection."""
|
||||
|
||||
STREAMING = "streaming"
|
||||
"""Show 'streaming...' indicator while agent is generating."""
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Output Entry
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputEntry:
|
||||
"""A single output entry in the console conversation history.
|
||||
|
||||
Used internally by the state driver to track conversation output,
|
||||
including streaming text, tool calls, errors, and user input echoes.
|
||||
|
||||
Args:
|
||||
type: The type of output entry.
|
||||
text: The text content of the entry.
|
||||
color: Optional Rich color string (e.g., "cyan", "red", "dim").
|
||||
"""
|
||||
|
||||
type: OutputEntryType
|
||||
text: str
|
||||
color: str | None = None
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Follow-Up Actions
|
||||
|
||||
|
||||
class FollowUpAction:
|
||||
"""Base class for follow-up actions returned by observers.
|
||||
|
||||
Follow-up actions describe either a question to ask the user
|
||||
(via FollowUpQuestion subclasses) or a message to add directly
|
||||
to the next agent input (FollowUpMessage).
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FollowUpQuestion(FollowUpAction):
|
||||
"""A question to ask the user with a continuation.
|
||||
|
||||
The continuation delegate is invoked with the user's answer and the
|
||||
UX state driver, and returns an optional Message to add to the next
|
||||
agent invocation.
|
||||
|
||||
Args:
|
||||
prompt: The question text shown to the user.
|
||||
continuation: Async function invoked with the user's answer and state driver.
|
||||
Returns an optional Message to add to the next agent input.
|
||||
"""
|
||||
|
||||
prompt: str
|
||||
continuation: Callable[[str, IUXStateDriver], Awaitable[Message | None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextFollowUpQuestion(FollowUpQuestion):
|
||||
"""A free-form text question.
|
||||
|
||||
The user may type any response. This is the base FollowUpQuestion type
|
||||
with no additional constraints.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceFollowUpQuestion(FollowUpQuestion):
|
||||
"""A multiple choice question.
|
||||
|
||||
The user picks from the provided choices, with an optional ability to
|
||||
enter custom text when allow_custom_text is True.
|
||||
|
||||
Args:
|
||||
prompt: The question text shown to the user.
|
||||
choices: List of pre-defined choices.
|
||||
allow_custom_text: If True, the user may type a custom response in
|
||||
addition to the listed choices.
|
||||
continuation: Async function invoked with the user's choice/text and
|
||||
state driver. Returns an optional Message to add to the next agent input.
|
||||
"""
|
||||
|
||||
choices: list[str]
|
||||
allow_custom_text: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FollowUpMessage(FollowUpAction):
|
||||
"""A message to add directly to the next agent invocation without prompting.
|
||||
|
||||
Used when an observer wants to inject a message into the conversation
|
||||
without user interaction (e.g., automatic tool results, system messages).
|
||||
|
||||
Args:
|
||||
message: The Message to add to the conversation.
|
||||
"""
|
||||
|
||||
message: Message
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Application State
|
||||
|
||||
|
||||
@dataclass
|
||||
class HarnessAppState:
|
||||
"""All UI state for the harness console application.
|
||||
|
||||
This state is mutated by the UX state driver and read by the Textual
|
||||
app to update the UI.
|
||||
"""
|
||||
|
||||
# --- Bottom panel mode ---
|
||||
|
||||
mode: BottomPanelMode = BottomPanelMode.TEXT_INPUT
|
||||
"""Which component is shown in the bottom panel."""
|
||||
|
||||
# --- Follow-up question queue ---
|
||||
|
||||
pending_questions: list[FollowUpQuestion] = field(default_factory=list)
|
||||
"""Queue of follow-up questions waiting for user answers.
|
||||
|
||||
The head ([0]) is the question currently being displayed; subsequent items
|
||||
are dispatched in order as each is answered.
|
||||
"""
|
||||
|
||||
accumulated_follow_up_responses: list[Message] = field(default_factory=list)
|
||||
"""Accumulated follow-up response messages collected during the current agent turn.
|
||||
|
||||
Both direct FollowUpMessages emitted by observers and continuation results
|
||||
from answered questions. Consumed by the runner via take_follow_up_responses().
|
||||
"""
|
||||
|
||||
# --- Text input (active in TextInput / Streaming modes) ---
|
||||
|
||||
prompt: str = "> "
|
||||
"""The prompt string for text input mode."""
|
||||
|
||||
placeholder: str = ""
|
||||
"""Placeholder text shown when the input is empty."""
|
||||
|
||||
input_text: str = ""
|
||||
"""The current input text being typed."""
|
||||
|
||||
input_enabled: bool = True
|
||||
"""Whether input is enabled (disabled during streaming without injection)."""
|
||||
|
||||
streaming_prompt: str = "(agent is running...)"
|
||||
"""The prompt to show during streaming when input is disabled."""
|
||||
|
||||
# --- List selection (active in ListSelection mode) ---
|
||||
|
||||
list_selection_title: str | None = None
|
||||
"""Title text displayed above the list selection."""
|
||||
|
||||
list_selection_options: list[str] = field(default_factory=list)
|
||||
"""The list selection options."""
|
||||
|
||||
list_selection_index: int = 0
|
||||
"""The highlighted option index in list selection mode."""
|
||||
|
||||
list_selection_custom_text_placeholder: str | None = None
|
||||
"""Placeholder text for the custom text input option in the list."""
|
||||
|
||||
list_selection_custom_input_text: str = ""
|
||||
"""Current text being typed into the list's custom text option."""
|
||||
|
||||
# --- Scroll / output area ---
|
||||
|
||||
output_entries: list[OutputEntry] = field(default_factory=list)
|
||||
"""Output entries in the scroll area conversation history."""
|
||||
|
||||
queued_items: list[str] = field(default_factory=list)
|
||||
"""Queued input items to display (pending injected messages)."""
|
||||
|
||||
# --- Agent mode + status display ---
|
||||
|
||||
mode_color: str | None = None
|
||||
"""Rich color string for the rule borders and mode label."""
|
||||
|
||||
mode_text: str | None = None
|
||||
"""Current mode name displayed (e.g., 'plan', 'execute')."""
|
||||
|
||||
help_text: str | None = None
|
||||
"""Help text displayed below the bottom rule (available commands)."""
|
||||
|
||||
show_spinner: bool = False
|
||||
"""Whether the agent status spinner is visible."""
|
||||
|
||||
usage_text: str | None = None
|
||||
"""Formatted token usage text to display in the status bar."""
|
||||
|
||||
# --- Command handler signals ---
|
||||
|
||||
shutdown_requested: bool = False
|
||||
"""Set to True when /exit is invoked; the app should exit."""
|
||||
|
||||
replaced_session: object | None = None
|
||||
"""When set, the app should swap its session to this AgentSession."""
|
||||
@@ -0,0 +1,65 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Command handler package for the harness console.
|
||||
|
||||
Provides slash-command handling (e.g., /exit, /mode, /todos, /session-export)
|
||||
that intercepts user input before it reaches the agent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
from .exit_handler import ExitCommandHandler
|
||||
from .mode_handler import ModeCommandHandler
|
||||
from .session_handler import SessionCommandHandler
|
||||
from .todo_handler import TodoCommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
|
||||
__all__ = [
|
||||
"CommandHandler",
|
||||
"ExitCommandHandler",
|
||||
"ModeCommandHandler",
|
||||
"SessionCommandHandler",
|
||||
"TodoCommandHandler",
|
||||
"build_default_command_handlers",
|
||||
]
|
||||
|
||||
|
||||
def build_default_command_handlers(
|
||||
agent: Agent,
|
||||
*,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> list[CommandHandler]:
|
||||
"""Build the default set of command handlers by inspecting the agent.
|
||||
|
||||
Auto-detects TodoProvider and AgentModeProvider from the agent's
|
||||
context_providers list.
|
||||
|
||||
Args:
|
||||
agent: The agent to inspect for providers.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
|
||||
Returns:
|
||||
List of command handlers in evaluation order.
|
||||
"""
|
||||
from agent_framework import AgentModeProvider, TodoProvider
|
||||
|
||||
todo_provider: TodoProvider | None = None
|
||||
mode_provider: AgentModeProvider | None = None
|
||||
|
||||
for provider in getattr(agent, "context_providers", []):
|
||||
if isinstance(provider, TodoProvider) and todo_provider is None:
|
||||
todo_provider = provider
|
||||
elif isinstance(provider, AgentModeProvider) and mode_provider is None:
|
||||
mode_provider = provider
|
||||
|
||||
return [
|
||||
ExitCommandHandler(),
|
||||
TodoCommandHandler(todo_provider),
|
||||
ModeCommandHandler(mode_provider, mode_colors),
|
||||
SessionCommandHandler(),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Abstract base class for console command handlers.
|
||||
|
||||
Command handlers intercept user input starting with '/' and execute
|
||||
local commands before input reaches the agent. They are checked in order;
|
||||
the first handler that accepts the input prevents further handlers from
|
||||
being checked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class CommandHandler(ABC):
|
||||
"""Base class for console command handlers.
|
||||
|
||||
Subclasses implement get_help_text() for the mode bar and
|
||||
try_handle() to intercept matching commands.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Get the help text for this command.
|
||||
|
||||
Displayed in the mode-and-help bar. Return None if the
|
||||
command is not currently available.
|
||||
|
||||
Returns:
|
||||
Help text like '/todos (show todo list)', or None.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Attempt to handle the given user input.
|
||||
|
||||
Args:
|
||||
user_input: The raw user input string.
|
||||
session: The current agent session.
|
||||
ux: The UX state driver for rendering output.
|
||||
|
||||
Returns:
|
||||
True if this handler handled the input; False otherwise.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Exit command handler — /exit to quit the console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ExitCommandHandler(CommandHandler):
|
||||
"""Handle the /exit command to shut down the console application."""
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text for the exit command."""
|
||||
return "/exit (quit)"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle /exit by requesting shutdown."""
|
||||
if user_input.strip().lower() != "/exit":
|
||||
return False
|
||||
|
||||
ux.request_shutdown()
|
||||
return True
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mode command handler — /mode to show or switch agent mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentModeProvider, AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class ModeCommandHandler(CommandHandler):
|
||||
"""Handle the /mode command to display or switch the current agent mode."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode_provider: AgentModeProvider | None,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize with mode provider and color mapping.
|
||||
|
||||
Args:
|
||||
mode_provider: The mode provider, or None if not available.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
"""
|
||||
self._mode_provider = mode_provider
|
||||
self._mode_colors = mode_colors or {}
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text, or None if mode provider is unavailable."""
|
||||
if self._mode_provider is None:
|
||||
return None
|
||||
return "/mode [plan|execute] (show or switch mode)"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle /mode [name] command."""
|
||||
stripped = user_input.strip()
|
||||
lower = stripped.lower()
|
||||
|
||||
if not (lower == "/mode" or lower.startswith("/mode ")):
|
||||
return False
|
||||
|
||||
if self._mode_provider is None:
|
||||
ux.append_info_line("AgentModeProvider is not available.")
|
||||
return True
|
||||
|
||||
parts = stripped.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
# Show current mode
|
||||
from agent_framework import get_agent_mode
|
||||
|
||||
current = 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,
|
||||
)
|
||||
ux.append_info_line(f"Current mode: {current}")
|
||||
return True
|
||||
|
||||
# Switch mode
|
||||
new_mode = parts[1].strip()
|
||||
try:
|
||||
from agent_framework import set_agent_mode
|
||||
|
||||
normalized = set_agent_mode(
|
||||
session,
|
||||
new_mode,
|
||||
source_id=self._mode_provider.source_id,
|
||||
available_modes=self._mode_provider.available_modes,
|
||||
)
|
||||
color = self._mode_colors.get(normalized)
|
||||
ux.set_mode(normalized, color)
|
||||
ux.append_info_line(
|
||||
f"Switched to {normalized} mode.",
|
||||
color=color,
|
||||
)
|
||||
except ValueError as ex:
|
||||
ux.append_info_line(str(ex), color="red")
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Session command handler — /session-export and /session-import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class SessionCommandHandler(CommandHandler):
|
||||
"""Handle /session-export and /session-import commands."""
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text for session commands."""
|
||||
return "/session-export <file> | /session-import <file>"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle session export/import commands."""
|
||||
stripped = user_input.strip()
|
||||
command = stripped.split(None, 1)[0].lower() if stripped else ""
|
||||
|
||||
if command == "/session-export":
|
||||
await self._handle_export(stripped, session, ux)
|
||||
return True
|
||||
|
||||
if command == "/session-import":
|
||||
await self._handle_import(stripped, ux)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _handle_export(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> None:
|
||||
"""Export the current session to a JSON file."""
|
||||
parts = user_input.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
ux.append_info_line("Usage: /session-export <filename>")
|
||||
return
|
||||
|
||||
filename = parts[1].strip()
|
||||
try:
|
||||
serialized = session.to_dict()
|
||||
json_str = json.dumps(serialized, indent=2)
|
||||
self._write_file(filename, json_str)
|
||||
ux.append_info_line(f"Session exported to {filename}")
|
||||
except Exception as ex:
|
||||
ux.append_info_line(
|
||||
f"Failed to export session to {filename}: {ex}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
async def _handle_import(
|
||||
self,
|
||||
user_input: str,
|
||||
ux: IUXStateDriver,
|
||||
) -> None:
|
||||
"""Import a session from a JSON file."""
|
||||
parts = user_input.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
ux.append_info_line("Usage: /session-import <filename>")
|
||||
return
|
||||
|
||||
filename = parts[1].strip()
|
||||
try:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
json_str = self._read_file(filename)
|
||||
data = json.loads(json_str)
|
||||
new_session = AgentSession.from_dict(data)
|
||||
ux.replace_session(new_session)
|
||||
ux.append_info_line(f"Session imported from {filename}")
|
||||
except FileNotFoundError:
|
||||
ux.append_info_line(f"File not found: {filename}", color="red")
|
||||
except Exception as ex:
|
||||
ux.append_info_line(
|
||||
f"Failed to import session from {filename}: {ex}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write_file(filename: str, content: str) -> None:
|
||||
"""Write content to a file (sync helper to satisfy ASYNC230)."""
|
||||
with open(filename, "w", encoding="utf-8") as f: # noqa: ASYNC230
|
||||
f.write(content)
|
||||
|
||||
@staticmethod
|
||||
def _read_file(filename: str) -> str:
|
||||
"""Read content from a file (sync helper to satisfy ASYNC230)."""
|
||||
with open(filename, encoding="utf-8") as f: # noqa: ASYNC230
|
||||
return f.read()
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Todo command handler — /todos to display the todo list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import CommandHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession, TodoProvider
|
||||
|
||||
from ..state_driver import IUXStateDriver
|
||||
|
||||
|
||||
class TodoCommandHandler(CommandHandler):
|
||||
"""Handle the /todos command to display the current todo list."""
|
||||
|
||||
def __init__(self, todo_provider: TodoProvider | None) -> None:
|
||||
"""Initialize with the todo provider.
|
||||
|
||||
Args:
|
||||
todo_provider: The todo provider, or None if not available.
|
||||
"""
|
||||
self._todo_provider = todo_provider
|
||||
|
||||
def get_help_text(self) -> str | None:
|
||||
"""Return help text, or None if todo provider is unavailable."""
|
||||
if self._todo_provider is None:
|
||||
return None
|
||||
return "/todos (show todo list)"
|
||||
|
||||
async def try_handle(
|
||||
self,
|
||||
user_input: str,
|
||||
session: AgentSession,
|
||||
ux: IUXStateDriver,
|
||||
) -> bool:
|
||||
"""Handle /todos by displaying the todo list."""
|
||||
if user_input.strip().lower() != "/todos":
|
||||
return False
|
||||
|
||||
if self._todo_provider is None:
|
||||
ux.append_info_line("TodoProvider is not available.")
|
||||
return True
|
||||
|
||||
todos = await self._todo_provider.store.load_items(session, source_id=self._todo_provider.source_id)
|
||||
|
||||
if not todos:
|
||||
ux.append_info_line("No todos yet.")
|
||||
return True
|
||||
|
||||
ux.append_info_line("── Todo List ──")
|
||||
for item in todos:
|
||||
status = "✓" if item.is_complete else "○"
|
||||
color = "dim" if item.is_complete else None
|
||||
description = f" — {item.description}" if item.description else ""
|
||||
ux.append_info_line(
|
||||
f"[{status}] #{item.id} {item.title}{description}",
|
||||
color=color,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""UI components for the harness console.
|
||||
|
||||
This module provides Textual widgets for building the harness console UI,
|
||||
including status displays, input fields, choice selectors, and scrolling panels.
|
||||
"""
|
||||
|
||||
from .agent_status import AgentStatus
|
||||
from .list_selection import HarnessListSelection
|
||||
from .mode_help import AgentModeAndHelp
|
||||
from .prompt_rule import PromptRule
|
||||
from .scroll_panel import HarnessScrollPanel
|
||||
from .text_input import HarnessTextInput
|
||||
|
||||
__all__ = [
|
||||
"AgentStatus",
|
||||
"AgentModeAndHelp",
|
||||
"HarnessListSelection",
|
||||
"PromptRule",
|
||||
"HarnessScrollPanel",
|
||||
"HarnessTextInput",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent status widget with spinner animation and usage statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AgentStatus(Static):
|
||||
"""Agent status bar with animated spinner and token usage display.
|
||||
|
||||
Displays an animated braille pattern spinner when the agent is active,
|
||||
along with token usage statistics. The component automatically updates
|
||||
the spinner animation at ~10fps for smooth visual feedback.
|
||||
|
||||
Attributes:
|
||||
show_spinner: Whether to display the animated spinner.
|
||||
usage_text: Token usage text to display (e.g., "1.2K in / 856 out").
|
||||
"""
|
||||
|
||||
# Braille pattern spinner frames for smooth animation
|
||||
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
show_spinner: reactive[bool] = reactive(False)
|
||||
usage_text: reactive[str] = reactive("")
|
||||
queued_text: reactive[str] = reactive("")
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
"""Initialize the agent status widget."""
|
||||
super().__init__(**kwargs)
|
||||
self._spinner_index = 0
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Start the spinner animation timer when the widget is mounted."""
|
||||
# Update spinner at ~10fps (every 0.1 seconds)
|
||||
self.set_interval(0.1, self._advance_spinner)
|
||||
|
||||
def _advance_spinner(self) -> None:
|
||||
"""Advance the spinner to the next frame."""
|
||||
if self.show_spinner:
|
||||
self._spinner_index = (self._spinner_index + 1) % len(self.SPINNER_FRAMES)
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the status bar with spinner and usage text.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup for spinner and usage display.
|
||||
"""
|
||||
if not self.show_spinner and not self.usage_text and not self.queued_text:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
if self.show_spinner:
|
||||
frame = self.SPINNER_FRAMES[self._spinner_index]
|
||||
parts.append(f"[cyan]{frame}[/cyan]")
|
||||
else:
|
||||
# Keep consistent spacing when spinner is off
|
||||
parts.append(" ")
|
||||
|
||||
if self.usage_text:
|
||||
parts.append(f"[dim]{self.usage_text}[/dim]")
|
||||
|
||||
if self.queued_text:
|
||||
parts.append(f"[dim]{self.queued_text}[/dim]")
|
||||
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,269 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""List selection widget with optional custom text input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container
|
||||
from textual.css.query import NoMatches
|
||||
from textual.events import Key
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label, OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
|
||||
class HarnessListSelection(Widget):
|
||||
"""List selection widget with numbered choices and optional custom text input.
|
||||
|
||||
Displays a title, a list of numbered choices that can be selected via
|
||||
keyboard navigation or number keys (1-9), and an optional custom text
|
||||
input field at the bottom.
|
||||
|
||||
All child nodes (title label, option list, custom input) are always
|
||||
present in the DOM; visibility is toggled via reactive watchers.
|
||||
|
||||
Navigation:
|
||||
- Down arrow on last list item moves focus to the custom text input
|
||||
- Up arrow on the custom text input moves focus back to the option list
|
||||
- When custom input has focus, the option list highlight is cleared
|
||||
|
||||
Attributes:
|
||||
title: The title text displayed above the options.
|
||||
options: List of option strings to display.
|
||||
allow_custom_text: Whether to show a custom text input field.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
HarnessListSelection {
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
}
|
||||
|
||||
HarnessListSelection .list-selection-container {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
HarnessListSelection #selection-title {
|
||||
height: auto;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
|
||||
HarnessListSelection #option-list {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
HarnessListSelection #custom-input {
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
margin-top: 0;
|
||||
border: tall transparent;
|
||||
}
|
||||
|
||||
HarnessListSelection #custom-input:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("1", "select_option(0)", "Select option 1", show=False),
|
||||
Binding("2", "select_option(1)", "Select option 2", show=False),
|
||||
Binding("3", "select_option(2)", "Select option 3", show=False),
|
||||
Binding("4", "select_option(3)", "Select option 4", show=False),
|
||||
Binding("5", "select_option(4)", "Select option 5", show=False),
|
||||
Binding("6", "select_option(5)", "Select option 6", show=False),
|
||||
Binding("7", "select_option(6)", "Select option 7", show=False),
|
||||
Binding("8", "select_option(7)", "Select option 8", show=False),
|
||||
Binding("9", "select_option(8)", "Select option 9", show=False),
|
||||
]
|
||||
|
||||
title: reactive[str] = reactive("")
|
||||
options: reactive[list[str]] = reactive(list, always_update=True)
|
||||
allow_custom_text: reactive[bool] = reactive(False)
|
||||
|
||||
class Selected(Message):
|
||||
"""Message sent when an option is selected.
|
||||
|
||||
Attributes:
|
||||
value: The selected option text or custom text.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Initialize the Selected message.
|
||||
|
||||
Args:
|
||||
value: The selected option text or custom text.
|
||||
"""
|
||||
self.value = value
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the widget — all nodes are always present.
|
||||
|
||||
Yields:
|
||||
Title label (hidden if empty), option list, custom input (hidden by default).
|
||||
"""
|
||||
with Container(classes="list-selection-container"):
|
||||
yield Label("", id="selection-title")
|
||||
yield OptionList(id="option-list")
|
||||
yield Input(
|
||||
placeholder="Or type a custom response...",
|
||||
id="custom-input",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Configure initial visibility after mount."""
|
||||
title_label = self.query_one("#selection-title", Label)
|
||||
title_label.display = bool(self.title)
|
||||
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.display = self.allow_custom_text
|
||||
|
||||
self._update_options()
|
||||
|
||||
def on_key(self, event: Key) -> None:
|
||||
"""Handle key navigation between option list and custom input.
|
||||
|
||||
Args:
|
||||
event: The key event.
|
||||
"""
|
||||
if not self.allow_custom_text:
|
||||
return
|
||||
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
|
||||
# Down arrow on last item → move to custom input
|
||||
if event.key == "down" and option_list.has_focus:
|
||||
last_index = option_list.option_count - 1
|
||||
if last_index >= 0 and option_list.highlighted == last_index:
|
||||
option_list.highlighted = None # type: ignore[assignment]
|
||||
custom_input.focus()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
# Up arrow on custom input → move back to option list (last item)
|
||||
elif event.key == "up" and custom_input.has_focus:
|
||||
last_index = option_list.option_count - 1
|
||||
if last_index >= 0:
|
||||
option_list.highlighted = last_index
|
||||
option_list.focus()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
@on(Input.Changed, "#custom-input")
|
||||
def on_custom_input_focused_or_changed(self, event: Input.Changed) -> None:
|
||||
"""Clear option list highlight when user is typing in custom input.
|
||||
|
||||
Args:
|
||||
event: The input changed event.
|
||||
"""
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.highlighted = None # type: ignore[assignment]
|
||||
|
||||
def watch_title(self, new_title: str) -> None:
|
||||
"""Update the title label when the title changes.
|
||||
|
||||
Args:
|
||||
new_title: The new title text.
|
||||
"""
|
||||
try:
|
||||
label = self.query_one("#selection-title", Label)
|
||||
label.update(new_title)
|
||||
label.display = bool(new_title)
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def watch_options(self, new_options: list[str]) -> None:
|
||||
"""Update the option list when options change.
|
||||
|
||||
Args:
|
||||
new_options: The new list of options.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(NoMatches):
|
||||
self._update_options()
|
||||
|
||||
def watch_allow_custom_text(self, allow: bool) -> None:
|
||||
"""Show/hide the custom input field.
|
||||
|
||||
Args:
|
||||
allow: Whether to show the custom text input.
|
||||
"""
|
||||
try:
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.display = allow
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _update_options(self) -> None:
|
||||
"""Update the OptionList with numbered options."""
|
||||
try:
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
for i, option_text in enumerate(self.options):
|
||||
display_text = f"{i + 1}. {option_text}" if i < 9 else f" {option_text}"
|
||||
option_list.add_option(Option(display_text, id=str(i)))
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
@on(OptionList.OptionSelected)
|
||||
def on_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
"""Handle option selection from the list.
|
||||
|
||||
Args:
|
||||
event: The OptionList.OptionSelected event.
|
||||
"""
|
||||
option_index = int(event.option.id or "0")
|
||||
if 0 <= option_index < len(self.options):
|
||||
selected_value = self.options[option_index]
|
||||
self.post_message(self.Selected(selected_value))
|
||||
|
||||
@on(Input.Submitted)
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle custom text input submission.
|
||||
|
||||
Args:
|
||||
event: The Input.Submitted event.
|
||||
"""
|
||||
if self.allow_custom_text and event.value:
|
||||
self.post_message(self.Selected(event.value))
|
||||
event.input.clear()
|
||||
|
||||
def action_select_option(self, index: int) -> None:
|
||||
"""Select an option by index (0-based).
|
||||
|
||||
Args:
|
||||
index: The option index to select.
|
||||
"""
|
||||
if 0 <= index < len(self.options):
|
||||
selected_value = self.options[index]
|
||||
self.post_message(self.Selected(selected_value))
|
||||
|
||||
def focus_list(self) -> None:
|
||||
"""Focus the option list."""
|
||||
try:
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.focus()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def focus_custom_input(self) -> None:
|
||||
"""Focus the custom text input field."""
|
||||
if self.allow_custom_text:
|
||||
try:
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.focus()
|
||||
except NoMatches:
|
||||
pass
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent mode and help text display widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AgentModeAndHelp(Static):
|
||||
"""Widget displaying the current agent mode and help text.
|
||||
|
||||
Shows the current agent mode (e.g., "plan", "execute") in a colored label,
|
||||
followed by available commands and help text in a dimmed style. Used in
|
||||
the fixed bottom area of the console.
|
||||
|
||||
Attributes:
|
||||
mode: Current mode name (e.g., "plan", "execute"), or None if no mode.
|
||||
mode_color: Rich color string for the mode label (e.g., "yellow", "green").
|
||||
help_text: Help text to display (e.g., "/exit to quit, /mode to switch").
|
||||
"""
|
||||
|
||||
mode: reactive[str | None] = reactive(None)
|
||||
mode_color: reactive[str] = reactive("yellow")
|
||||
help_text: reactive[str] = reactive("")
|
||||
|
||||
def render(self) -> Text:
|
||||
"""Render the mode indicator and help text.
|
||||
|
||||
Returns:
|
||||
Rich Text object with styled mode and help display.
|
||||
"""
|
||||
result = Text()
|
||||
|
||||
if self.mode:
|
||||
result.append(f"[{self.mode}]", style=self.mode_color)
|
||||
|
||||
if self.help_text:
|
||||
if self.mode:
|
||||
result.append(" ")
|
||||
result.append(self.help_text, style="dim")
|
||||
|
||||
if not result.plain:
|
||||
result.append(" ")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mode-colored horizontal rule."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class PromptRule(Static):
|
||||
"""A full-width horizontal rule colored by the current agent mode.
|
||||
|
||||
Renders a line of '─' characters across the terminal width,
|
||||
colored to match the current mode (e.g., cyan for plan, green for execute).
|
||||
|
||||
Attributes:
|
||||
rule_color: Rich color string for the rule (e.g., "cyan", "green").
|
||||
"""
|
||||
|
||||
rule_color: reactive[str] = reactive("cyan")
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the horizontal rule.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup.
|
||||
"""
|
||||
color = self.rule_color
|
||||
width = self.size.width or 80
|
||||
return f"[{color}]{'─' * width}[/{color}]"
|
||||
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Scrolling panel for conversation history display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual.widgets import RichLog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app_state import OutputEntry
|
||||
|
||||
|
||||
class HarnessScrollPanel(RichLog):
|
||||
"""Scrolling panel for displaying conversation history.
|
||||
|
||||
Uses Textual's RichLog widget for efficient append-only rendering with
|
||||
Rich text formatting support. Automatically scrolls to the bottom when
|
||||
new entries are added.
|
||||
|
||||
For streaming text, the panel uses a truncate-and-rewrite strategy: it
|
||||
tracks where streaming began in the RichLog lines list, and on each update
|
||||
truncates back to that point and rewrites the full accumulated text as a
|
||||
single write. This ensures consistent rendering without line-break artifacts
|
||||
between streamed chunks.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
"""Initialize the scroll panel.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments passed to RichLog.
|
||||
"""
|
||||
super().__init__(
|
||||
**kwargs,
|
||||
auto_scroll=True, # Automatically scroll to bottom
|
||||
wrap=True, # Wrap long lines instead of horizontal scroll
|
||||
markup=True, # Enable Rich markup
|
||||
highlight=True, # Enable syntax highlighting
|
||||
)
|
||||
self._entries: list[OutputEntry] = []
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start: int = 0
|
||||
|
||||
def append_entry(self, entry: OutputEntry) -> None:
|
||||
"""Append a new output entry to the conversation history.
|
||||
|
||||
Args:
|
||||
entry: The output entry to append.
|
||||
"""
|
||||
self._entries.append(entry)
|
||||
text = self._format_entry(entry)
|
||||
self.write(text)
|
||||
|
||||
def set_streaming_entry(self, entry: OutputEntry) -> None:
|
||||
"""Set or update the current streaming entry.
|
||||
|
||||
On each update, truncates the RichLog back to where streaming
|
||||
started, then rewrites the full streaming text as a single block.
|
||||
This ensures no spurious line breaks between chunks while avoiding
|
||||
a full rewrite of all entries.
|
||||
|
||||
Args:
|
||||
entry: The streaming entry (will be mutated externally).
|
||||
"""
|
||||
if not self._is_streaming:
|
||||
# First streaming chunk — record where streaming lines begin
|
||||
self._is_streaming = True
|
||||
self._entries.append(entry)
|
||||
self._streaming_line_start = len(self.lines)
|
||||
|
||||
# Truncate lines back to where streaming started
|
||||
if len(self.lines) > self._streaming_line_start:
|
||||
del self.lines[self._streaming_line_start :]
|
||||
from textual.geometry import Size
|
||||
|
||||
self.virtual_size = Size(self._widest_line_width, len(self.lines))
|
||||
|
||||
# Write full streaming text as a single renderable
|
||||
formatted = self._format_text(entry.text, entry.color)
|
||||
self.write(formatted)
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End the current streaming mode."""
|
||||
if self._is_streaming:
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start = 0
|
||||
|
||||
def _rewrite_all(self) -> None:
|
||||
"""Clear and rewrite all entries from scratch."""
|
||||
self.clear()
|
||||
for entry in self._entries:
|
||||
self.write(self._format_entry(entry))
|
||||
|
||||
def _format_entry(self, entry: OutputEntry) -> str:
|
||||
"""Format an output entry with Rich markup.
|
||||
|
||||
Args:
|
||||
entry: The entry to format.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup for color and styling.
|
||||
"""
|
||||
return self._format_text(entry.text, entry.color)
|
||||
|
||||
@staticmethod
|
||||
def _format_text(text: str, color: str | None) -> str:
|
||||
"""Format text with optional Rich color markup.
|
||||
|
||||
Args:
|
||||
text: The text to format.
|
||||
color: Optional Rich color name.
|
||||
|
||||
Returns:
|
||||
Formatted string.
|
||||
"""
|
||||
if color:
|
||||
return f"[{color}]{text}[/{color}]"
|
||||
return text
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear all conversation history from the panel."""
|
||||
self._entries.clear()
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start = 0
|
||||
self.clear()
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Text input widget with inline prompt for the harness console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label
|
||||
|
||||
|
||||
class HarnessTextInput(Widget):
|
||||
"""Text input widget with a prompt label on the left.
|
||||
|
||||
Displays a prompt (e.g., "> ") followed by a borderless input field.
|
||||
Sits between the two mode-colored horizontal rules.
|
||||
|
||||
Attributes:
|
||||
prompt: The prompt text displayed on the left (e.g., "> ").
|
||||
placeholder: Placeholder text shown when the input is empty.
|
||||
"""
|
||||
|
||||
prompt: reactive[str] = reactive("> ")
|
||||
placeholder: reactive[str] = reactive("")
|
||||
|
||||
class Submitted(Message):
|
||||
"""Message sent when the input is submitted.
|
||||
|
||||
Attributes:
|
||||
value: The submitted text value.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Initialize the Submitted message.
|
||||
|
||||
Args:
|
||||
value: The submitted text value.
|
||||
"""
|
||||
self.value = value
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the prompt label and input field.
|
||||
|
||||
Yields:
|
||||
A horizontal container with the prompt and input field.
|
||||
"""
|
||||
with Horizontal(classes="prompt-container"):
|
||||
yield Label(self.prompt, classes="prompt-label", id="prompt-label")
|
||||
yield Input(placeholder=self.placeholder, classes="input-field", id="input-field")
|
||||
|
||||
def watch_prompt(self, new_prompt: str) -> None:
|
||||
"""Update the prompt label when the prompt attribute changes.
|
||||
|
||||
Args:
|
||||
new_prompt: The new prompt text.
|
||||
"""
|
||||
try:
|
||||
label = self.query_one("#prompt-label", Label)
|
||||
label.update(new_prompt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def watch_placeholder(self, new_placeholder: str) -> None:
|
||||
"""Update the input placeholder when the placeholder attribute changes.
|
||||
|
||||
Args:
|
||||
new_placeholder: The new placeholder text.
|
||||
"""
|
||||
try:
|
||||
input_field = self.query_one("#input-field", Input)
|
||||
input_field.placeholder = new_placeholder
|
||||
except Exception:
|
||||
# Input doesn't exist yet (before compose), ignore
|
||||
pass
|
||||
|
||||
@on(Input.Submitted)
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle input submission.
|
||||
|
||||
Clears the input field and posts a Submitted message with the value.
|
||||
|
||||
Args:
|
||||
event: The Input.Submitted event.
|
||||
"""
|
||||
value = event.value
|
||||
event.input.clear()
|
||||
self.post_message(self.Submitted(value))
|
||||
|
||||
def focus_input(self) -> None:
|
||||
"""Focus the input field."""
|
||||
input_field = self.query_one(".input-field", Input)
|
||||
input_field.focus()
|
||||
|
||||
def clear_input(self) -> None:
|
||||
"""Clear the input field."""
|
||||
input_field = self.query_one(".input-field", Input)
|
||||
input_field.clear()
|
||||
@@ -0,0 +1,505 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tool call formatters for displaying function calls in the harness console.
|
||||
|
||||
This module provides formatters that convert raw function call content into
|
||||
human-readable display strings. Each formatter handles specific tool patterns
|
||||
(e.g., web_search, todos_*, etc.) and the FallbackToolFormatter provides
|
||||
generic formatting for any unmatched tools.
|
||||
|
||||
Usage:
|
||||
from harness.console.formatters import build_default_formatters, format_tool_call
|
||||
from agent_framework import Content
|
||||
|
||||
call = Content.from_function_call(
|
||||
call_id="call_1",
|
||||
name="web_search",
|
||||
arguments={"query": "Python async"}
|
||||
)
|
||||
formatters = build_default_formatters()
|
||||
result = format_tool_call(formatters, call) # "web_search (Python async)"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Content
|
||||
|
||||
# region Helper Functions
|
||||
|
||||
|
||||
def get_argument_value(call: Content, param_name: str) -> Any:
|
||||
"""Extract an argument value from a function call.
|
||||
|
||||
Handles both dict and JSON string arguments.
|
||||
|
||||
Args:
|
||||
call: The function call content.
|
||||
param_name: The parameter name to extract.
|
||||
|
||||
Returns:
|
||||
The argument value, or None if not found.
|
||||
"""
|
||||
if call.arguments is None:
|
||||
return None
|
||||
|
||||
if isinstance(call.arguments, str):
|
||||
# arguments is a JSON string, parse it
|
||||
try:
|
||||
args_dict = json.loads(call.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(args_dict, dict):
|
||||
return None
|
||||
elif isinstance(call.arguments, dict):
|
||||
args_dict = call.arguments
|
||||
else:
|
||||
return None
|
||||
|
||||
return args_dict.get(param_name)
|
||||
|
||||
|
||||
def as_int_list(value: Any) -> list[int] | None:
|
||||
"""Convert a value to a list of integers, or None if not possible.
|
||||
|
||||
Args:
|
||||
value: The value to convert (should be a list).
|
||||
|
||||
Returns:
|
||||
A list of integers, or None if conversion fails.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
|
||||
result: list[int] = []
|
||||
for item in value:
|
||||
if isinstance(item, int):
|
||||
result.append(item)
|
||||
else:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
result.append(int(item))
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def as_dict_list(value: Any) -> list[dict[str, Any]] | None:
|
||||
"""Convert a value to a list of dicts, or None if not possible.
|
||||
|
||||
Args:
|
||||
value: The value to convert (should be a list).
|
||||
|
||||
Returns:
|
||||
A list of dicts, or None if value is not a list of dicts.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
result.append(item)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def truncate(text: str, max_length: int) -> str:
|
||||
"""Truncate a string to the specified maximum length, appending an ellipsis if truncated.
|
||||
|
||||
Args:
|
||||
text: The text to truncate.
|
||||
max_length: The maximum length.
|
||||
|
||||
Returns:
|
||||
The truncated string.
|
||||
"""
|
||||
return text if len(text) <= max_length else text[:max_length] + "…"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Base Class
|
||||
|
||||
|
||||
class ToolCallFormatter(ABC):
|
||||
"""Base class for tool call formatters that produce human-readable display strings
|
||||
for function call content items shown in the console.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Return True if this formatter can handle the given function call.
|
||||
|
||||
Args:
|
||||
call: The function call content to check.
|
||||
|
||||
Returns:
|
||||
True if this formatter should be used; otherwise False.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Return the detail portion of the formatted output for the given tool call,
|
||||
or None if only the tool name should be displayed.
|
||||
|
||||
Args:
|
||||
call: The function call content to format.
|
||||
|
||||
Returns:
|
||||
A detail string to append after the tool name, or None.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Concrete Formatters
|
||||
|
||||
|
||||
class FallbackToolFormatter(ToolCallFormatter):
|
||||
"""Catch-all formatter that handles any tool not matched by a more specific formatter.
|
||||
|
||||
Displays a generic summary of the tool's arguments. This formatter should always be
|
||||
placed last in the formatter list.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Always returns True - this formatter matches everything."""
|
||||
return True
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format arguments as generic (key: value, ...) pairs."""
|
||||
if call.arguments is None:
|
||||
return None
|
||||
|
||||
# Parse arguments
|
||||
if isinstance(call.arguments, str):
|
||||
try:
|
||||
args_dict = json.loads(call.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(args_dict, dict):
|
||||
return None
|
||||
elif isinstance(call.arguments, dict):
|
||||
args_dict = call.arguments
|
||||
else:
|
||||
return None
|
||||
|
||||
if not args_dict:
|
||||
return None
|
||||
|
||||
# Build argument list
|
||||
parts: list[str] = []
|
||||
for key, value in args_dict.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
# Convert value to string
|
||||
if isinstance(value, bool):
|
||||
str_value = "true" if value else "false"
|
||||
elif isinstance(value, (int, float)):
|
||||
str_value = str(value)
|
||||
elif isinstance(value, str):
|
||||
str_value = value
|
||||
else:
|
||||
# Complex types - skip for now
|
||||
continue
|
||||
|
||||
parts.append(f"{key}: {truncate(str_value, 40)}")
|
||||
|
||||
return f"({', '.join(parts)})" if parts else None
|
||||
|
||||
|
||||
class WebSearchToolFormatter(ToolCallFormatter):
|
||||
"""Formats web_search tool calls, showing the search query."""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match web_search tool calls."""
|
||||
return call.name == "web_search"
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Extract and format the query parameter."""
|
||||
value = get_argument_value(call, "query")
|
||||
return f"({value})" if value else None
|
||||
|
||||
|
||||
class TodoToolFormatter(ToolCallFormatter):
|
||||
"""Formats todos_* tool calls with tree-view output for added items
|
||||
and structured output for complete/remove operations.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match todos_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("todos_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific todos operation."""
|
||||
if call.name == "todos_add":
|
||||
return self._format_add_todos(call)
|
||||
if call.name == "todos_complete":
|
||||
return self._format_complete_todos(call)
|
||||
if call.name == "todos_remove":
|
||||
return self._format_id_list(call, "ids", "Remove")
|
||||
return None
|
||||
|
||||
def _format_add_todos(self, call: Content) -> str | None:
|
||||
"""Format todos_add with tree view of titles."""
|
||||
todos = as_dict_list(get_argument_value(call, "todos"))
|
||||
if not todos:
|
||||
return None
|
||||
|
||||
titles: list[str] = []
|
||||
for todo in todos:
|
||||
title = todo.get("title")
|
||||
if title and isinstance(title, str):
|
||||
titles.append(title)
|
||||
|
||||
if not titles:
|
||||
return None
|
||||
|
||||
# Build tree view
|
||||
count = len(titles)
|
||||
plural = "s" if count != 1 else ""
|
||||
lines = [f"({count} item{plural})"]
|
||||
for i, title in enumerate(titles):
|
||||
connector = "├─" if i < count - 1 else "└─"
|
||||
lines.append(f"\n {connector} {title}")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_complete_todos(self, call: Content) -> str | None:
|
||||
"""Format todos_complete with tree view of IDs and reasons."""
|
||||
items = as_dict_list(get_argument_value(call, "items"))
|
||||
if not items:
|
||||
return None
|
||||
|
||||
entries: list[tuple[int, str | None]] = []
|
||||
for item in items:
|
||||
todo_id = item.get("id")
|
||||
if not isinstance(todo_id, int):
|
||||
continue
|
||||
|
||||
reason = item.get("reason")
|
||||
reason_str = str(reason) if reason is not None and not isinstance(reason, str) else reason
|
||||
entries.append((todo_id, reason_str))
|
||||
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
# Build tree view
|
||||
lines: list[str] = []
|
||||
for i, (todo_id, reason) in enumerate(entries):
|
||||
connector = "├─" if i < len(entries) - 1 else "└─"
|
||||
line = f"\n {connector} Complete #{todo_id}"
|
||||
if reason:
|
||||
line += f" — {truncate(reason, 80)}"
|
||||
lines.append(line)
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_id_list(self, call: Content, param_name: str, verb: str) -> str | None:
|
||||
"""Format a list of IDs with a verb (e.g., Remove #1, Remove #2)."""
|
||||
ids = as_int_list(get_argument_value(call, param_name))
|
||||
if not ids:
|
||||
return None
|
||||
|
||||
lines: list[str] = []
|
||||
for i, todo_id in enumerate(ids):
|
||||
connector = "├─" if i < len(ids) - 1 else "└─"
|
||||
lines.append(f"\n {connector} {verb} #{todo_id}")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
class ModeToolFormatter(ToolCallFormatter):
|
||||
"""Formats mode_* tool calls, showing the target mode for set operations."""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match mode_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("mode_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific mode operation."""
|
||||
if call.name == "mode_set":
|
||||
value = get_argument_value(call, "mode")
|
||||
return f"({value})" if value else None
|
||||
return None
|
||||
|
||||
|
||||
class BackgroundAgentToolFormatter(ToolCallFormatter):
|
||||
"""Formats background_agents_* tool calls with human-readable details
|
||||
for task start, continue, wait, and result retrieval operations.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match background_agents_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("background_agents_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific background_agents operation."""
|
||||
if call.name == "background_agents_start_task":
|
||||
return self._format_start_background_task(call)
|
||||
if call.name == "background_agents_wait_for_first_completion":
|
||||
return self._format_id_list(call, "task_ids", "Wait for")
|
||||
if call.name == "background_agents_get_task_results":
|
||||
return self._format_single_id(call, "task_id")
|
||||
if call.name == "background_agents_continue_task":
|
||||
return self._format_continue_task(call)
|
||||
if call.name == "background_agents_clear_completed_task":
|
||||
return self._format_single_id(call, "task_id")
|
||||
return None
|
||||
|
||||
def _format_start_background_task(self, call: Content) -> str | None:
|
||||
"""Format start_task with agent name and description."""
|
||||
agent_name = get_argument_value(call, "agent_name")
|
||||
description = get_argument_value(call, "description")
|
||||
|
||||
if agent_name is None and description is None:
|
||||
return None
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
if agent_name is not None and description is not None:
|
||||
lines.append(f"\n ├─ Agent: {agent_name}")
|
||||
lines.append(f'\n └─ "{truncate(description, 80)}"')
|
||||
elif agent_name is not None:
|
||||
lines.append(f"\n └─ Agent: {agent_name}")
|
||||
else:
|
||||
lines.append(f'\n └─ "{truncate(description, 80)}"') # type: ignore[arg-type]
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_id_list(self, call: Content, param_name: str, verb: str) -> str | None:
|
||||
"""Format a list of task IDs with a verb."""
|
||||
ids = as_int_list(get_argument_value(call, param_name))
|
||||
if not ids:
|
||||
return None
|
||||
|
||||
lines: list[str] = []
|
||||
for i, task_id in enumerate(ids):
|
||||
connector = "├─" if i < len(ids) - 1 else "└─"
|
||||
lines.append(f"\n {connector} {verb} #{task_id}")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
def _format_single_id(self, call: Content, param_name: str) -> str | None:
|
||||
"""Format a single task ID in parentheses."""
|
||||
task_id = get_argument_value(call, param_name)
|
||||
if isinstance(task_id, int):
|
||||
return f"(task #{task_id})"
|
||||
return None
|
||||
|
||||
def _format_continue_task(self, call: Content) -> str | None:
|
||||
"""Format continue_task with task ID and optional text."""
|
||||
task_id = get_argument_value(call, "task_id")
|
||||
text = get_argument_value(call, "text")
|
||||
|
||||
if not isinstance(task_id, int):
|
||||
return None
|
||||
|
||||
if text:
|
||||
lines = [
|
||||
f"\n ├─ Task #{task_id}",
|
||||
f'\n └─ "{truncate(text, 80)}"',
|
||||
]
|
||||
return "".join(lines)
|
||||
|
||||
return f"\n └─ Task #{task_id}"
|
||||
|
||||
|
||||
class FileMemoryToolFormatter(ToolCallFormatter):
|
||||
"""Formats file_memory_* tool calls, showing file names and search patterns
|
||||
with tree-view corners for write operations.
|
||||
"""
|
||||
|
||||
def can_format(self, call: Content) -> bool:
|
||||
"""Match file_memory_* tool calls."""
|
||||
return call.name is not None and call.name.startswith("file_memory_")
|
||||
|
||||
def format_detail(self, call: Content) -> str | None:
|
||||
"""Format based on the specific file_memory operation."""
|
||||
if call.name == "file_memory_write":
|
||||
return self._format_write_file(call)
|
||||
if call.name in ("file_memory_read", "file_memory_delete", "file_memory_replace", "file_memory_replace_lines"):
|
||||
value = get_argument_value(call, "file_name")
|
||||
return f"({value})" if value else None
|
||||
if call.name == "file_memory_grep":
|
||||
return self._format_search_files(call)
|
||||
return None
|
||||
|
||||
def _format_write_file(self, call: Content) -> str | None:
|
||||
"""Format write_file with file name and description indicator."""
|
||||
file_name = get_argument_value(call, "file_name")
|
||||
description = get_argument_value(call, "description")
|
||||
|
||||
if not file_name:
|
||||
return None
|
||||
|
||||
if description:
|
||||
return f"\n └─ {file_name} (with description)"
|
||||
return f"\n └─ {file_name}"
|
||||
|
||||
def _format_search_files(self, call: Content) -> str | None:
|
||||
"""Format grep with regex pattern and optional glob pattern."""
|
||||
pattern = get_argument_value(call, "regex_pattern")
|
||||
glob_pattern = get_argument_value(call, "glob_pattern")
|
||||
|
||||
if not pattern:
|
||||
return None
|
||||
|
||||
if glob_pattern:
|
||||
return f"(/{pattern}/ in {glob_pattern})"
|
||||
return f"(/{pattern}/)"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Public API Functions
|
||||
|
||||
|
||||
def format_tool_call(formatters: list[ToolCallFormatter], call: Content) -> str:
|
||||
"""Format a tool call using the first matching formatter from the provided list.
|
||||
|
||||
Returns "{toolName} {detail}" when a formatter produces detail,
|
||||
or just "{toolName}" otherwise.
|
||||
|
||||
Args:
|
||||
formatters: List of formatters to try in order.
|
||||
call: The function call content to format.
|
||||
|
||||
Returns:
|
||||
Formatted string representation of the tool call.
|
||||
"""
|
||||
for formatter in formatters:
|
||||
if formatter.can_format(call):
|
||||
detail = formatter.format_detail(call)
|
||||
tool_name = call.name or "Unknown"
|
||||
return f"{tool_name} {detail}" if detail is not None else tool_name
|
||||
|
||||
return call.name or "Unknown"
|
||||
|
||||
|
||||
def build_default_formatters() -> list[ToolCallFormatter]:
|
||||
"""Create the default list of tool call formatters.
|
||||
|
||||
The FallbackToolFormatter is always last. Users can call this function
|
||||
and combine the result with their own formatters.
|
||||
|
||||
Returns:
|
||||
A list of all built-in tool call formatters.
|
||||
"""
|
||||
return [
|
||||
TodoToolFormatter(),
|
||||
ModeToolFormatter(),
|
||||
BackgroundAgentToolFormatter(),
|
||||
FileMemoryToolFormatter(),
|
||||
WebSearchToolFormatter(),
|
||||
FallbackToolFormatter(),
|
||||
]
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Main entry point for the harness console.
|
||||
|
||||
Provides the top-level run_agent_async() function that creates and runs
|
||||
the Textual-based harness console application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .app import HarnessApp
|
||||
from .observers import build_default_observers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentSession
|
||||
|
||||
from .commands import CommandHandler
|
||||
from .observers.base import ConsoleObserver
|
||||
|
||||
|
||||
async def run_agent_async(
|
||||
agent: Agent,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
observers: list[ConsoleObserver] | None = None,
|
||||
command_handlers: list[CommandHandler] | None = None,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
initial_mode: str | None = None,
|
||||
placeholder: str = "Type a message and press Enter...",
|
||||
title: str = "Harness Console",
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Run the harness console with the given agent.
|
||||
|
||||
This is the main entry point for the harness console. Creates a Textual
|
||||
application with the configured observers and runs it until the user exits.
|
||||
|
||||
Args:
|
||||
agent: The agent to run conversations with.
|
||||
session: Optional agent session for conversation history.
|
||||
observers: List of console observers. If None, uses defaults.
|
||||
command_handlers: List of command handlers. If None, auto-detected from agent.
|
||||
mode_colors: Mapping of mode names to Rich color strings.
|
||||
initial_mode: Initial agent mode text.
|
||||
placeholder: Input placeholder text.
|
||||
title: Application title.
|
||||
max_context_window_tokens: Optional max context window size for usage display.
|
||||
max_output_tokens: Optional max output tokens for usage display.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from console import run_agent_async
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
instructions="You are helpful.",
|
||||
)
|
||||
|
||||
await run_agent_async(agent)
|
||||
"""
|
||||
resolved_observers = observers or build_default_observers()
|
||||
resolved_mode_colors = mode_colors or {
|
||||
"plan": "cyan",
|
||||
"execute": "green",
|
||||
}
|
||||
resolved_session = session or agent.create_session()
|
||||
|
||||
app = HarnessApp(
|
||||
agent=agent,
|
||||
observers=resolved_observers,
|
||||
session=resolved_session,
|
||||
mode_colors=resolved_mode_colors,
|
||||
initial_mode=initial_mode,
|
||||
placeholder=placeholder,
|
||||
title=title,
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
command_handlers=command_handlers,
|
||||
)
|
||||
|
||||
await app.run_async()
|
||||
@@ -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))
|
||||
@@ -0,0 +1,357 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""State driver interface for UI updates.
|
||||
|
||||
This module defines the IUXStateDriver Protocol, which observers use to
|
||||
update the UI during agent streaming. This is an interface-only definition;
|
||||
the concrete implementation will be in a separate module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from .app_state import FollowUpAction
|
||||
|
||||
|
||||
class IUXStateDriver(Protocol):
|
||||
"""Protocol for UI state driver.
|
||||
|
||||
Observers call these methods to update the UI during agent streaming.
|
||||
This is an interface-only definition - concrete implementation comes later.
|
||||
|
||||
The state driver acts as a controller between the agent framework (model)
|
||||
and the Textual UI components (view), coordinating all UI updates.
|
||||
"""
|
||||
|
||||
def append_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Append an informational line to the output.
|
||||
|
||||
Used for displaying tool calls, errors, warnings, and other
|
||||
informational messages that aren't part of the agent's text response.
|
||||
|
||||
Args:
|
||||
text: The text to display.
|
||||
color: Optional Rich color string (e.g., "yellow", "red", "dim").
|
||||
"""
|
||||
...
|
||||
|
||||
def append_stream_footer(self, text: str) -> None:
|
||||
"""Append a footer line after streaming ends.
|
||||
|
||||
Used for displaying final status messages like "(no text response)"
|
||||
or other closing information.
|
||||
|
||||
Args:
|
||||
text: The footer text to display.
|
||||
"""
|
||||
...
|
||||
|
||||
def begin_streaming(self) -> None:
|
||||
"""Begin streaming mode.
|
||||
|
||||
Switches the bottom panel to streaming mode (shows "Streaming..." indicator),
|
||||
starts the spinner animation, and prepares for streaming text updates.
|
||||
"""
|
||||
...
|
||||
|
||||
def update_streaming_text(self, accumulated_text: str) -> None:
|
||||
"""Update the accumulated streaming text.
|
||||
|
||||
Called repeatedly during streaming to update the displayed text as
|
||||
new chunks arrive from the agent. The text should accumulate across
|
||||
multiple calls.
|
||||
|
||||
Args:
|
||||
accumulated_text: The full accumulated text so far.
|
||||
"""
|
||||
...
|
||||
|
||||
def write_text(self, text: str, color: str | None = None) -> None:
|
||||
"""Write a streaming text chunk incrementally.
|
||||
|
||||
Appends the text to the current streaming entry. If the streaming
|
||||
entry is no longer the last output item (e.g., an info_line was
|
||||
inserted), creates a new streaming entry.
|
||||
|
||||
Args:
|
||||
text: The text chunk to append.
|
||||
color: Optional Rich color string.
|
||||
"""
|
||||
...
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End streaming mode.
|
||||
|
||||
Stops the spinner, switches the bottom panel back to text input mode,
|
||||
and finalizes the streaming output.
|
||||
"""
|
||||
...
|
||||
|
||||
def enqueue_follow_up_action(self, action: FollowUpAction) -> None:
|
||||
"""Add a follow-up action to the queue.
|
||||
|
||||
Follow-up actions can be questions to ask the user or messages to
|
||||
inject into the next agent turn. The state driver queues these and
|
||||
processes them after streaming completes.
|
||||
|
||||
Args:
|
||||
action: The follow-up action to queue.
|
||||
"""
|
||||
...
|
||||
|
||||
def has_pending_questions(self) -> bool:
|
||||
"""Check if there are pending follow-up questions awaiting user answers.
|
||||
|
||||
Returns:
|
||||
True if there are unanswered questions in the queue.
|
||||
"""
|
||||
...
|
||||
|
||||
def take_follow_up_responses(self) -> list:
|
||||
"""Take and clear all accumulated follow-up response messages.
|
||||
|
||||
Returns:
|
||||
List of Message objects accumulated from follow-up actions.
|
||||
"""
|
||||
...
|
||||
|
||||
async def write_no_text_warning(self, has_follow_up_actions: bool) -> None:
|
||||
"""Write a warning if the agent produced no text output.
|
||||
|
||||
Called after streaming completes. If no text was received and no
|
||||
follow-up actions exist, writes a "(no text response)" footer.
|
||||
|
||||
Args:
|
||||
has_follow_up_actions: Whether follow-up actions exist.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_mode(self, mode: str | None, mode_color: str | None = None) -> None:
|
||||
"""Set the current agent mode.
|
||||
|
||||
Updates the mode indicator in the UI (e.g., "[plan]", "[execute]")
|
||||
with the specified color.
|
||||
|
||||
Args:
|
||||
mode: The mode name (e.g., "plan", "execute"), or None to hide.
|
||||
mode_color: Optional Rich color string for the mode label.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_show_spinner(self, show: bool) -> None:
|
||||
"""Show or hide the spinner animation.
|
||||
|
||||
The spinner provides visual feedback that the agent is processing.
|
||||
|
||||
Args:
|
||||
show: True to show the spinner, False to hide it.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_usage_text(self, usage_text: str | None) -> None:
|
||||
"""Set the token usage text.
|
||||
|
||||
Displays token usage statistics (e.g., "1.2K in / 856 out") in
|
||||
the status bar.
|
||||
|
||||
Args:
|
||||
usage_text: The formatted usage text, or None to hide.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def current_mode(self) -> str | None:
|
||||
"""Get the current agent mode.
|
||||
|
||||
Returns:
|
||||
The current mode name, or None if no mode is set.
|
||||
"""
|
||||
...
|
||||
|
||||
def begin_streaming_output(self) -> None:
|
||||
"""Reset per-turn streaming bookkeeping.
|
||||
|
||||
Called at the start of each agent turn to reset streaming state
|
||||
(e.g., clear accumulated text, reset flags).
|
||||
"""
|
||||
...
|
||||
|
||||
def write_user_input_echo(self, text: str) -> None:
|
||||
"""Echo user input to the output area.
|
||||
|
||||
Displays the user's submitted input in the conversation history,
|
||||
typically with a "You: " prefix.
|
||||
|
||||
Args:
|
||||
text: The user's input text.
|
||||
"""
|
||||
...
|
||||
|
||||
def set_queued_messages(self, pending: list[str]) -> None:
|
||||
"""Set the queued (pending injected) message display.
|
||||
|
||||
Called while an agent turn is streaming to reflect messages the user
|
||||
has queued for injection into the ongoing run. Consumed messages are
|
||||
echoed separately via write_user_input_echo.
|
||||
|
||||
Args:
|
||||
pending: List of pending message texts.
|
||||
"""
|
||||
...
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
"""Request the application to shut down.
|
||||
|
||||
Called by the /exit command handler to signal that the user
|
||||
wants to quit the console.
|
||||
"""
|
||||
...
|
||||
|
||||
def replace_session(self, session: AgentSession) -> None:
|
||||
"""Replace the current agent session.
|
||||
|
||||
Called by the /session-import command handler to swap the
|
||||
active session with one loaded from a file.
|
||||
|
||||
Args:
|
||||
session: The new session to use.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class SimpleConsoleStateDriver:
|
||||
"""Simple console-based state driver for testing.
|
||||
|
||||
This is a minimal implementation that logs all operations to the console.
|
||||
Useful for testing the agent runner without a full UI.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the simple state driver."""
|
||||
self._streaming = False
|
||||
self._spinner_visible = False
|
||||
self._current_mode: str | None = None
|
||||
print("[SimpleConsoleStateDriver initialized]")
|
||||
|
||||
def append_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Append an informational line to the output."""
|
||||
color_prefix = f"[{color}]" if color else ""
|
||||
print(f"{color_prefix} {text}")
|
||||
|
||||
def append_stream_footer(self, text: str) -> None:
|
||||
"""Append a footer line after streaming ends."""
|
||||
print(f"[Footer] {text}")
|
||||
|
||||
async def write_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Async version of append_info_line."""
|
||||
self.append_info_line(text, color)
|
||||
|
||||
def write_user_input_echo(self, text: str) -> None:
|
||||
"""Echo user input to the output."""
|
||||
print(f"\n[User] {text}\n")
|
||||
|
||||
def begin_streaming(self) -> None:
|
||||
"""Begin streaming mode."""
|
||||
self._streaming = True
|
||||
print("[▶ Streaming started]")
|
||||
|
||||
def begin_streaming_output(self) -> None:
|
||||
"""Begin streaming output to the scroll panel."""
|
||||
print("[▶ Streaming output started]")
|
||||
|
||||
def update_streaming_text(self, text: str) -> None:
|
||||
"""Update the currently streaming text."""
|
||||
# Truncate for readability
|
||||
display_text = text[:80] + "..." if len(text) > 80 else text
|
||||
print(f"[Assistant] {display_text}", end="", flush=True)
|
||||
|
||||
def write_text(self, text: str, color: str | None = None) -> None:
|
||||
"""Write a streaming text chunk."""
|
||||
print(text, end="", flush=True)
|
||||
|
||||
async def end_streaming_output(self) -> None:
|
||||
"""End streaming output."""
|
||||
print("\n[▪ Streaming output ended]")
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End streaming mode."""
|
||||
self._streaming = False
|
||||
print("[▪ Streaming ended]")
|
||||
|
||||
def set_show_spinner(self, show: bool) -> None:
|
||||
"""Show or hide the spinner."""
|
||||
self._spinner_visible = show
|
||||
status = "visible" if show else "hidden"
|
||||
print(f"[Spinner: {status}]")
|
||||
|
||||
def set_mode(self, mode: str | None, mode_color: str | None = None) -> None:
|
||||
"""Set the current mode text."""
|
||||
self._current_mode = mode
|
||||
color_str = f" ({mode_color})" if mode_color else ""
|
||||
print(f"[Mode: {mode or 'default'}{color_str}]")
|
||||
|
||||
@property
|
||||
def current_mode(self) -> str | None:
|
||||
"""Get the current agent mode."""
|
||||
return self._current_mode
|
||||
|
||||
def set_usage_text(self, usage_text: str | None) -> None:
|
||||
"""Set the usage display text."""
|
||||
if usage_text:
|
||||
print(f"[Usage: {usage_text}]")
|
||||
|
||||
def enqueue_follow_up_action(self, action) -> None:
|
||||
"""Enqueue a follow-up action.
|
||||
|
||||
Args:
|
||||
action: The follow-up action to enqueue.
|
||||
"""
|
||||
action_type = type(action).__name__
|
||||
print(f"[Follow-up queued: {action_type}]")
|
||||
|
||||
def has_pending_questions(self) -> bool:
|
||||
"""Check if there are pending follow-up questions."""
|
||||
return False
|
||||
|
||||
def take_follow_up_responses(self) -> list:
|
||||
"""Take and clear all accumulated follow-up responses."""
|
||||
return []
|
||||
|
||||
async def write_no_text_warning(self, has_follow_up_actions: bool) -> None:
|
||||
"""Write a warning if no text was produced."""
|
||||
if not has_follow_up_actions:
|
||||
print("[▪ (no text response from agent)]")
|
||||
|
||||
def update_last_entry(self, entry_type, new_text: str) -> None:
|
||||
"""Update the last output entry (placeholder for now).
|
||||
|
||||
Args:
|
||||
entry_type: The type of entry to update.
|
||||
new_text: The new text content.
|
||||
"""
|
||||
# Simplified: just print the update
|
||||
display_text = new_text[:80] + "..." if len(new_text) > 80 else new_text
|
||||
print(f"[Update last entry: {display_text}]", flush=True)
|
||||
|
||||
def set_queued_messages(self, pending: list[str]) -> None:
|
||||
"""Set the queued (pending injected) message display."""
|
||||
if pending:
|
||||
print(f"[Queued: {', '.join(pending)}]")
|
||||
else:
|
||||
print("[Queued: (none)]")
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
"""Request application shutdown."""
|
||||
print("[Shutdown requested]")
|
||||
|
||||
def replace_session(self, session) -> None:
|
||||
"""Replace the active session.
|
||||
|
||||
Args:
|
||||
session: The new session to use.
|
||||
"""
|
||||
print(f"[Session replaced: {getattr(session, 'id', 'unknown')}]")
|
||||
@@ -0,0 +1,400 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Textual-based UX state driver implementation.
|
||||
|
||||
This module provides the full HarnessConsoleUXStateDriver that connects
|
||||
the agent runner and observers to the Textual UI components. It mutates
|
||||
the application state and triggers UI updates through the Textual app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .app_state import (
|
||||
BottomPanelMode,
|
||||
ChoiceFollowUpQuestion,
|
||||
FollowUpAction,
|
||||
FollowUpMessage,
|
||||
FollowUpQuestion,
|
||||
HarnessAppState,
|
||||
OutputEntry,
|
||||
OutputEntryType,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Message
|
||||
|
||||
|
||||
# Default mode colors (mode name -> Rich color string)
|
||||
DEFAULT_MODE_COLORS: dict[str, str] = {
|
||||
"plan": "cyan",
|
||||
"execute": "green",
|
||||
"review": "yellow",
|
||||
"default": "blue",
|
||||
}
|
||||
|
||||
|
||||
def get_mode_color(mode: str | None, mode_colors: dict[str, str] | None = None) -> str:
|
||||
"""Get the color for a mode name.
|
||||
|
||||
Args:
|
||||
mode: The mode name.
|
||||
mode_colors: Optional custom mode color mapping.
|
||||
|
||||
Returns:
|
||||
A Rich color string for the mode.
|
||||
"""
|
||||
colors = mode_colors or DEFAULT_MODE_COLORS
|
||||
if mode is None:
|
||||
return colors.get("default", "blue")
|
||||
return colors.get(mode, colors.get("default", "blue"))
|
||||
|
||||
|
||||
class HarnessConsoleUXStateDriver:
|
||||
"""Full Textual-based UX state driver.
|
||||
|
||||
Implements the IUXStateDriver protocol by mutating application state
|
||||
and calling back into the Textual app to trigger UI updates.
|
||||
|
||||
The driver owns the output entry list and streaming state, and produces
|
||||
state snapshots that the app uses to render the UI.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_state: HarnessAppState,
|
||||
on_state_changed: Callable[[], None],
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the state driver.
|
||||
|
||||
Args:
|
||||
app_state: The application state object to mutate.
|
||||
on_state_changed: Callback invoked after state changes to trigger UI refresh.
|
||||
mode_colors: Optional mapping of mode names to Rich color strings.
|
||||
"""
|
||||
self._state = app_state
|
||||
self._on_state_changed = on_state_changed
|
||||
self._mode_colors = mode_colors
|
||||
|
||||
# Streaming bookkeeping
|
||||
self._has_received_any_text = False
|
||||
self._current_streaming_entry: OutputEntry | None = None
|
||||
self._current_streaming_entry_index: int = -1
|
||||
self._last_entry_type: OutputEntryType | None = None
|
||||
|
||||
@property
|
||||
def state(self) -> HarnessAppState:
|
||||
"""Get the current application state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def current_mode(self) -> str | None:
|
||||
"""Get the current agent mode."""
|
||||
return self._state.mode_text
|
||||
|
||||
@current_mode.setter
|
||||
def current_mode(self, value: str | None) -> None:
|
||||
"""Set the current agent mode."""
|
||||
self._state.mode_text = value
|
||||
self._state.mode_color = get_mode_color(value, self._mode_colors)
|
||||
self._notify()
|
||||
|
||||
# --- Streaming lifecycle ---
|
||||
|
||||
def begin_streaming(self) -> None:
|
||||
"""Begin streaming mode - switch bottom panel and show spinner."""
|
||||
self._state.mode = BottomPanelMode.STREAMING
|
||||
self._state.show_spinner = True
|
||||
self._state.input_enabled = False
|
||||
self._notify()
|
||||
|
||||
def begin_streaming_output(self) -> None:
|
||||
"""Reset per-turn streaming bookkeeping."""
|
||||
self._has_received_any_text = False
|
||||
self._current_streaming_entry = None
|
||||
self._current_streaming_entry_index = -1
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End streaming mode - return to text input."""
|
||||
self._state.mode = BottomPanelMode.TEXT_INPUT
|
||||
self._state.show_spinner = False
|
||||
self._state.input_enabled = True
|
||||
self._notify()
|
||||
|
||||
async def end_streaming_output(self) -> None:
|
||||
"""Finalize streaming output - add trailing newline if text was received."""
|
||||
if self._has_received_any_text:
|
||||
self._current_streaming_entry = None
|
||||
self._last_entry_type = OutputEntryType.STREAM_FOOTER
|
||||
self._notify()
|
||||
|
||||
def set_show_spinner(self, show: bool) -> None:
|
||||
"""Show or hide the spinner."""
|
||||
self._state.show_spinner = show
|
||||
self._notify()
|
||||
|
||||
# --- Text output ---
|
||||
|
||||
def write_user_input_echo(self, text: str) -> None:
|
||||
"""Echo user input to the output area."""
|
||||
entry = OutputEntry(
|
||||
type=OutputEntryType.USER_INPUT,
|
||||
text=f"You: {text}",
|
||||
color="green",
|
||||
)
|
||||
self._append_entry(entry)
|
||||
self._last_entry_type = OutputEntryType.USER_INPUT
|
||||
self._notify()
|
||||
|
||||
def append_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Append an informational line to the output."""
|
||||
effective_color = color or get_mode_color(self._state.mode_text, self._mode_colors)
|
||||
|
||||
# Add separator when transitioning from streaming text
|
||||
prefix = ""
|
||||
if self._last_entry_type in (OutputEntryType.STREAMING_TEXT, OutputEntryType.STREAM_FOOTER):
|
||||
prefix = "" # Textual handles spacing via widget layout
|
||||
|
||||
entry = OutputEntry(
|
||||
type=OutputEntryType.INFO_LINE,
|
||||
text=prefix + text,
|
||||
color=effective_color,
|
||||
)
|
||||
self._append_entry(entry)
|
||||
self._last_entry_type = OutputEntryType.INFO_LINE
|
||||
self._notify()
|
||||
|
||||
def append_stream_footer(self, text: str) -> None:
|
||||
"""Append a footer line after streaming ends."""
|
||||
entry = OutputEntry(
|
||||
type=OutputEntryType.STREAM_FOOTER,
|
||||
text=text,
|
||||
color="dim",
|
||||
)
|
||||
self._append_entry(entry)
|
||||
self._last_entry_type = OutputEntryType.STREAM_FOOTER
|
||||
self._notify()
|
||||
|
||||
async def write_info_line(self, text: str, color: str | None = None) -> None:
|
||||
"""Async version of append_info_line."""
|
||||
self.append_info_line(text, color)
|
||||
|
||||
def write_text(self, text: str, color: str | None = None) -> None:
|
||||
"""Write streaming text from the agent.
|
||||
|
||||
Accumulates text into the current streaming entry. If the streaming
|
||||
entry is still the last output item, appends to it in place. Otherwise
|
||||
starts a new streaming entry.
|
||||
|
||||
Args:
|
||||
text: The text chunk to append.
|
||||
color: Optional Rich color.
|
||||
"""
|
||||
self._last_entry_type = OutputEntryType.STREAMING_TEXT
|
||||
self._has_received_any_text = True
|
||||
|
||||
effective_color = color or get_mode_color(self._state.mode_text, self._mode_colors)
|
||||
|
||||
if (
|
||||
self._current_streaming_entry is not None
|
||||
and self._current_streaming_entry_index == len(self._state.output_entries) - 1
|
||||
):
|
||||
# Append to existing streaming entry in place
|
||||
self._current_streaming_entry.text += text
|
||||
# Update the entry in the list (same object, but trigger notify)
|
||||
else:
|
||||
# Start a fresh streaming entry
|
||||
self._current_streaming_entry = OutputEntry(
|
||||
type=OutputEntryType.STREAMING_TEXT,
|
||||
text=text,
|
||||
color=effective_color,
|
||||
)
|
||||
self._state.output_entries.append(self._current_streaming_entry)
|
||||
self._current_streaming_entry_index = len(self._state.output_entries) - 1
|
||||
|
||||
self._notify()
|
||||
|
||||
def update_streaming_text(self, accumulated_text: str) -> None:
|
||||
"""Update the accumulated streaming text (full replacement).
|
||||
|
||||
Alternative to write_text() - replaces the entire streaming entry text.
|
||||
If an info_line was appended after the streaming entry (e.g., a tool
|
||||
call), creates a new streaming entry at the end of the list so the
|
||||
UI can render it.
|
||||
|
||||
Args:
|
||||
accumulated_text: The full accumulated text so far.
|
||||
"""
|
||||
effective_color = get_mode_color(self._state.mode_text, self._mode_colors)
|
||||
|
||||
if (
|
||||
self._current_streaming_entry is not None
|
||||
and self._current_streaming_entry_index == len(self._state.output_entries) - 1
|
||||
):
|
||||
# Streaming entry is still the last entry — update in place
|
||||
self._current_streaming_entry.text = accumulated_text
|
||||
else:
|
||||
# Either no current entry, or it's no longer at the end (an
|
||||
# info_line was appended after it). Create a new streaming entry
|
||||
# so the panel can render the continued text.
|
||||
self._current_streaming_entry = OutputEntry(
|
||||
type=OutputEntryType.STREAMING_TEXT,
|
||||
text=accumulated_text,
|
||||
color=effective_color,
|
||||
)
|
||||
self._state.output_entries.append(self._current_streaming_entry)
|
||||
self._current_streaming_entry_index = len(self._state.output_entries) - 1
|
||||
|
||||
self._last_entry_type = OutputEntryType.STREAMING_TEXT
|
||||
self._has_received_any_text = True
|
||||
self._notify()
|
||||
|
||||
async def write_no_text_warning(self, has_follow_up_actions: bool) -> None:
|
||||
"""Write '(no text response)' warning if no text was received."""
|
||||
if not self._has_received_any_text and not has_follow_up_actions:
|
||||
self.append_stream_footer("(no text response from agent)")
|
||||
|
||||
# --- Usage and mode ---
|
||||
|
||||
def set_usage_text(self, usage_text: str | None) -> None:
|
||||
"""Set the token usage text."""
|
||||
self._state.usage_text = usage_text
|
||||
self._notify()
|
||||
|
||||
def set_mode(self, mode: str | None, mode_color: str | None = None) -> None:
|
||||
"""Set the current mode."""
|
||||
self._state.mode_text = mode
|
||||
self._state.mode_color = mode_color or get_mode_color(mode, self._mode_colors)
|
||||
self._notify()
|
||||
|
||||
# --- Follow-up actions ---
|
||||
|
||||
def enqueue_follow_up_action(self, action: FollowUpAction) -> None:
|
||||
"""Enqueue a follow-up action."""
|
||||
if isinstance(action, FollowUpMessage):
|
||||
self._state.accumulated_follow_up_responses.append(action.message)
|
||||
elif isinstance(action, FollowUpQuestion):
|
||||
self.queue_follow_up_questions([action])
|
||||
|
||||
def queue_follow_up_questions(self, questions: list[FollowUpQuestion]) -> None:
|
||||
"""Queue follow-up questions for user interaction.
|
||||
|
||||
Args:
|
||||
questions: List of questions to queue.
|
||||
"""
|
||||
if not questions:
|
||||
return
|
||||
|
||||
was_empty = len(self._state.pending_questions) == 0
|
||||
self._state.pending_questions.extend(questions)
|
||||
|
||||
if was_empty:
|
||||
self._configure_for_head_question(self._state.pending_questions[0])
|
||||
|
||||
self._notify()
|
||||
|
||||
def add_follow_up_response(self, response: Message) -> None:
|
||||
"""Add a follow-up response message."""
|
||||
self._state.accumulated_follow_up_responses.append(response)
|
||||
|
||||
def advance_follow_up_question(self) -> None:
|
||||
"""Advance to the next follow-up question.
|
||||
|
||||
Removes the head question from the queue. If more questions remain,
|
||||
configures the UI for the next one. Otherwise returns to text input.
|
||||
"""
|
||||
if not self._state.pending_questions:
|
||||
return
|
||||
|
||||
self._state.pending_questions.pop(0)
|
||||
|
||||
if self._state.pending_questions:
|
||||
self._configure_for_head_question(self._state.pending_questions[0])
|
||||
else:
|
||||
# No more questions - return to text input
|
||||
self._state.mode = BottomPanelMode.TEXT_INPUT
|
||||
self._state.list_selection_options = []
|
||||
self._state.list_selection_title = None
|
||||
self._state.list_selection_custom_text_placeholder = None
|
||||
self._state.list_selection_index = 0
|
||||
self._state.list_selection_custom_input_text = ""
|
||||
|
||||
self._notify()
|
||||
|
||||
def take_follow_up_responses(self) -> list[Message]:
|
||||
"""Take and clear all accumulated follow-up responses.
|
||||
|
||||
Returns:
|
||||
List of accumulated response messages.
|
||||
"""
|
||||
responses = list(self._state.accumulated_follow_up_responses)
|
||||
self._state.accumulated_follow_up_responses.clear()
|
||||
return responses
|
||||
|
||||
def has_pending_questions(self) -> bool:
|
||||
"""Check if there are pending follow-up questions.
|
||||
|
||||
Returns:
|
||||
True if unanswered questions exist in the queue.
|
||||
"""
|
||||
return len(self._state.pending_questions) > 0
|
||||
|
||||
# --- Queued messages (message injection) ---
|
||||
|
||||
def set_queued_messages(self, pending: list[str]) -> None:
|
||||
"""Set the queued message display.
|
||||
|
||||
Args:
|
||||
pending: List of pending message texts.
|
||||
"""
|
||||
self._state.queued_items = [f"💬 {text}" for text in pending]
|
||||
self._notify()
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
def _append_entry(self, entry: OutputEntry) -> None:
|
||||
"""Append an output entry to the state."""
|
||||
self._state.output_entries.append(entry)
|
||||
|
||||
def _configure_for_head_question(self, question: FollowUpQuestion) -> None:
|
||||
"""Configure the UI for the current head question.
|
||||
|
||||
Args:
|
||||
question: The question to display.
|
||||
"""
|
||||
if isinstance(question, ChoiceFollowUpQuestion):
|
||||
self._state.mode = BottomPanelMode.LIST_SELECTION
|
||||
self._state.list_selection_options = list(question.choices)
|
||||
self._state.list_selection_title = question.prompt
|
||||
self._state.list_selection_custom_text_placeholder = (
|
||||
"✏️ Type a custom response..." if question.allow_custom_text else None
|
||||
)
|
||||
self._state.list_selection_index = 0
|
||||
self._state.list_selection_custom_input_text = ""
|
||||
else:
|
||||
# Text question - show as info line and switch to text input
|
||||
self.append_info_line(question.prompt)
|
||||
self._state.mode = BottomPanelMode.TEXT_INPUT
|
||||
self._state.list_selection_options = []
|
||||
self._state.list_selection_title = None
|
||||
|
||||
def _notify(self) -> None:
|
||||
"""Notify the app that state has changed."""
|
||||
self._on_state_changed()
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
"""Request the application to shut down."""
|
||||
self._state.shutdown_requested = True
|
||||
self._notify()
|
||||
|
||||
def replace_session(self, session) -> None:
|
||||
"""Replace the current agent session.
|
||||
|
||||
Args:
|
||||
session: The new AgentSession to use.
|
||||
"""
|
||||
self._state.replaced_session = session
|
||||
self._notify()
|
||||
@@ -0,0 +1,140 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-foundry",
|
||||
# "textual>=6.2.1",
|
||||
# "rich>=13.7.1",
|
||||
# "azure-identity",
|
||||
# "python-dotenv",
|
||||
# ]
|
||||
# ///
|
||||
# Run with any PEP 723 compatible runner, e.g.:
|
||||
# uv run samples/02-agents/harness/harness_data_processing.py
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness Data Processing Assistant with Console UI and tool approvals.
|
||||
|
||||
Demonstrates ``create_harness_agent`` configured with a ``FileAccessProvider``
|
||||
to give an agent access to a folder of CSV data files. The agent can read,
|
||||
analyze, and extract information from the data, then write results back as new
|
||||
files via the ``file_access_*`` tools.
|
||||
|
||||
This sample also demonstrates **tool approval**. The ``FileAccessProvider``
|
||||
registers all of its tools with ``approval_mode="always_require"``, so every
|
||||
file operation would normally prompt the host for approval. To keep read-only
|
||||
exploration frictionless while still guarding mutations, the agent is given the
|
||||
:meth:`FileAccessProvider.read_only_tools_auto_approval_rule` auto-approval
|
||||
rule. With this rule:
|
||||
|
||||
- Read-only tools (read, list files, list subdirectories, search) are
|
||||
auto-approved and run without prompting.
|
||||
- Write tools (save and delete) still require explicit approval, so you are
|
||||
asked before the agent modifies the file store.
|
||||
|
||||
The sample includes a pre-populated ``working/`` folder with sales transaction
|
||||
data. The ``FileAccessProvider`` is pointed at that folder (resolved relative to
|
||||
this script) so it works regardless of the current working directory. Ask the
|
||||
agent to analyze the data, produce summaries, or create new output files. For
|
||||
example::
|
||||
|
||||
Please process the sales.csv file by first filtering it to only North region
|
||||
sales, and then calculating the sum of sales by person. I'd like to write the
|
||||
results of the processing to north_region_totals.csv
|
||||
|
||||
When the agent reads ``sales.csv`` it proceeds automatically, but when it tries
|
||||
to save ``north_region_totals.csv`` you are prompted to approve the write.
|
||||
|
||||
Unused harness features (todos, plan/execute mode, web search) are disabled to
|
||||
keep this a simple, conversational data-interaction sample.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import FileAccessProvider, FileSystemAgentFileStore, create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from console import build_default_observers, run_agent_async
|
||||
from dotenv import load_dotenv
|
||||
|
||||
DATA_ANALYST_INSTRUCTIONS = """\
|
||||
You are a data analyst assistant. You have access to a folder of data files via the file_access_* tools.
|
||||
|
||||
## Getting started
|
||||
- Start by listing available files with file_access_ls to see what data is available.
|
||||
- Read the files to understand their structure and contents.
|
||||
|
||||
## Working with data
|
||||
- When asked to analyze data, read the relevant files first, then perform the analysis.
|
||||
- Show your analysis clearly with tables, summaries, and key insights.
|
||||
- When calculations are needed, work through them step by step and show your reasoning.
|
||||
|
||||
## Writing output
|
||||
- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_write to write them.
|
||||
- Use appropriate file formats: CSV for tabular data, Markdown for reports.
|
||||
- Confirm what you wrote and where.
|
||||
|
||||
## Important
|
||||
- Never modify or delete the original input data files unless explicitly asked to do so.
|
||||
- If asked about data you haven't read yet, read it first before answering.
|
||||
- Always explain your reasoning and thought process as you work through tasks.
|
||||
- Always explain what you learned and what you are going to do next between tool calls, so the user can
|
||||
follow along with your thought process.
|
||||
"""
|
||||
|
||||
MAX_CONTEXT_WINDOW_TOKENS = 1_050_000
|
||||
MAX_OUTPUT_TOKENS = 128_000
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_dotenv()
|
||||
|
||||
# Resolve the working/ folder bundled alongside this script. The agent reads
|
||||
# the seed data from here and writes any output files back into it.
|
||||
working_dir = Path(__file__).parent / "working"
|
||||
|
||||
# Create the chat client.
|
||||
# For authentication, run `az login` in terminal or replace AzureCliCredential
|
||||
# with your preferred authentication option.
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Create a harness agent with data-analyst instructions. Unused features are
|
||||
# disabled. The read_only_tools_auto_approval_rule auto-approves the
|
||||
# FileAccessProvider's read-only tools, so only write operations prompt.
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS,
|
||||
max_output_tokens=MAX_OUTPUT_TOKENS,
|
||||
name="DataAnalyst",
|
||||
description="A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
agent_instructions=DATA_ANALYST_INSTRUCTIONS,
|
||||
file_access_store=FileSystemAgentFileStore(working_dir),
|
||||
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
|
||||
disable_todo=True,
|
||||
disable_mode=True,
|
||||
disable_web_search=True,
|
||||
)
|
||||
|
||||
# Run the harness console. This sample has no plan/execute mode, so it uses
|
||||
# the default observers (no planning observer) and no initial mode.
|
||||
await run_agent_async(
|
||||
agent,
|
||||
session=agent.create_session(),
|
||||
observers=build_default_observers(),
|
||||
title="📊 Data Analyst",
|
||||
placeholder="Ask me to analyze the data files, produce summaries, or create output files...",
|
||||
max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS,
|
||||
max_output_tokens=MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,135 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework-foundry",
|
||||
# "textual>=6.2.1",
|
||||
# "rich>=13.7.1",
|
||||
# "azure-identity",
|
||||
# "python-dotenv",
|
||||
# ]
|
||||
# ///
|
||||
# Run with any PEP 723 compatible runner, e.g.:
|
||||
# uv run samples/02-agents/harness/harness_research.py
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness Research Assistant with Console UI.
|
||||
|
||||
Demonstrates ``create_harness_agent`` — a factory function that builds a
|
||||
pre-configured agent with batteries included, automatically wiring up function
|
||||
invocation, per-service-call history persistence, compaction, and a rich set of
|
||||
context providers:
|
||||
|
||||
- **TodoProvider** — the agent can create, track, and complete work items
|
||||
- **AgentModeProvider** — plan/execute mode tracking (interactive vs. autonomous)
|
||||
- **SkillsProvider** — file-based skill discovery and progressive loading
|
||||
- **CompactionProvider** — automatic context-window management
|
||||
- **InMemoryHistoryProvider** — session history with per-service-call persistence
|
||||
- **OpenTelemetry** — built-in observability via AgentTelemetryLayer
|
||||
- **Web Search** — real-time web search via ``get_web_search_tool()``
|
||||
|
||||
The sample creates a research-focused agent with web search capability and runs
|
||||
it inside the Textual-based harness console. The agent will plan research tasks
|
||||
using todos, switch between plan and execute modes, search the web for current
|
||||
information, and track its progress.
|
||||
|
||||
It also demonstrates harness **looping**: a ``loop_should_continue`` predicate
|
||||
keeps re-invoking the agent automatically while it is in ``"execute"`` mode and
|
||||
the ``TodoProvider`` still has open items, so the agent works through the whole
|
||||
plan autonomously once execution begins. A ``loop_next_message`` callable injects
|
||||
a reminder between iterations listing the todos that are still open, and the loop
|
||||
is scoped to ``"execute"`` mode so ``"plan"`` mode stays interactive.
|
||||
``loop_max_iterations`` caps the number of autonomous passes per turn as a safety
|
||||
net.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
create_harness_agent,
|
||||
todos_remaining,
|
||||
todos_remaining_message,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from console import build_observers_with_planning, run_agent_async
|
||||
from dotenv import load_dotenv
|
||||
|
||||
RESEARCH_INSTRUCTIONS = """\
|
||||
## Research Assistant Instructions
|
||||
|
||||
You are a research assistant. When given a research topic, research it
|
||||
thoroughly using web search and web browsing. Use your knowledge to form good
|
||||
search queries and hypotheses, but always verify claims with the tools
|
||||
available to you rather than relying on memory alone.
|
||||
|
||||
### Research quality
|
||||
|
||||
Consult multiple sources when possible and cross-reference key claims.
|
||||
When sources disagree, note the discrepancy and explain which source you
|
||||
consider more reliable and why.
|
||||
If a web page fails to load or a search returns irrelevant results, try
|
||||
alternative search queries or sources before moving on.
|
||||
Track your sources — you will need them when presenting results.
|
||||
|
||||
### Presenting results
|
||||
|
||||
When presenting your final findings:
|
||||
- Use Markdown formatting for clarity.
|
||||
- Use clear sections with headings for each major topic or sub-question.
|
||||
- Cite your sources inline (e.g., "According to [source name](URL), ...").
|
||||
- End with a brief summary of key takeaways.
|
||||
- In addition to returning the results to the user, save the final research
|
||||
report to file memory so it survives compaction and can be referenced later.
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_dotenv()
|
||||
|
||||
# Create the chat client.
|
||||
# For authentication, run `az login` in terminal or replace AzureCliCredential
|
||||
# with your preferred authentication option.
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Create a harness agent with research-specific instructions.
|
||||
# All other features (todo, mode, compaction, skills, telemetry, web search) are
|
||||
# automatically configured with sensible defaults.
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
name="ResearchAgent",
|
||||
description="A research assistant that plans and executes research tasks.",
|
||||
agent_instructions=RESEARCH_INSTRUCTIONS,
|
||||
# Enable harness looping: while the agent is in "execute" mode and still has open todos,
|
||||
# keep re-invoking it automatically so it works through the whole plan without manual
|
||||
# prompting. loop_next_message reminds the agent which todos are still open each pass, and
|
||||
# loop_max_iterations caps the autonomous passes per turn as a safety net.
|
||||
loop_should_continue=todos_remaining(looping_modes=["execute"]),
|
||||
loop_next_message=todos_remaining_message,
|
||||
loop_max_iterations=10,
|
||||
)
|
||||
|
||||
# Run the harness console with the research agent.
|
||||
await run_agent_async(
|
||||
agent,
|
||||
session=agent.create_session(),
|
||||
observers=build_observers_with_planning(agent),
|
||||
initial_mode="plan",
|
||||
title="🔬 Research Assistant",
|
||||
placeholder="Enter a research topic...",
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
date,product,category,quantity,unit_price,region,salesperson
|
||||
2025-01-03,Laptop Pro 15,Electronics,2,1299.99,North,Alice
|
||||
2025-01-05,Ergonomic Chair,Furniture,5,349.50,South,Bob
|
||||
2025-01-07,Wireless Mouse,Electronics,12,24.99,North,Alice
|
||||
2025-01-08,Standing Desk,Furniture,1,599.00,West,Carol
|
||||
2025-01-10,USB-C Hub,Electronics,8,45.99,North,David
|
||||
2025-01-12,Monitor 27in,Electronics,3,429.00,South,Bob
|
||||
2025-01-14,Desk Lamp,Furniture,6,79.95,West,Carol
|
||||
2025-01-15,Keyboard Mech,Electronics,4,149.99,North,Alice
|
||||
2025-01-17,Filing Cabinet,Furniture,2,189.00,South,David
|
||||
2025-01-20,Webcam HD,Electronics,10,89.99,West,Bob
|
||||
2025-01-22,Laptop Pro 15,Electronics,1,1299.99,South,Carol
|
||||
2025-01-24,Ergonomic Chair,Furniture,3,349.50,North,Alice
|
||||
2025-01-25,Notebook Pack,Stationery,20,12.99,South,David
|
||||
2025-01-27,Wireless Mouse,Electronics,15,24.99,West,Carol
|
||||
2025-01-28,Whiteboard,Stationery,4,129.00,North,Bob
|
||||
2025-01-30,Standing Desk,Furniture,2,599.00,South,Alice
|
||||
2025-02-02,USB-C Hub,Electronics,6,45.99,West,David
|
||||
2025-02-04,Monitor 27in,Electronics,2,429.00,North,Carol
|
||||
2025-02-05,Desk Lamp,Furniture,8,79.95,South,Bob
|
||||
2025-02-07,Keyboard Mech,Electronics,5,149.99,West,Alice
|
||||
2025-02-09,Filing Cabinet,Furniture,1,189.00,North,David
|
||||
2025-02-11,Webcam HD,Electronics,7,89.99,South,Carol
|
||||
2025-02-13,Laptop Pro 15,Electronics,3,1299.99,West,Bob
|
||||
2025-02-15,Notebook Pack,Stationery,30,12.99,North,Alice
|
||||
2025-02-17,Ergonomic Chair,Furniture,4,349.50,South,David
|
||||
2025-02-19,Wireless Mouse,Electronics,20,24.99,North,Carol
|
||||
2025-02-20,Whiteboard,Stationery,2,129.00,West,Bob
|
||||
2025-02-22,Standing Desk,Furniture,1,599.00,North,Alice
|
||||
2025-02-24,USB-C Hub,Electronics,10,45.99,South,David
|
||||
2025-02-26,Monitor 27in,Electronics,4,429.00,West,Carol
|
||||
2025-02-28,Desk Lamp,Furniture,3,79.95,North,Bob
|
||||
2025-03-02,Keyboard Mech,Electronics,6,149.99,South,Alice
|
||||
2025-03-04,Filing Cabinet,Furniture,3,189.00,West,David
|
||||
2025-03-06,Webcam HD,Electronics,9,89.99,North,Carol
|
||||
2025-03-08,Laptop Pro 15,Electronics,2,1299.99,South,Bob
|
||||
2025-03-10,Notebook Pack,Stationery,25,12.99,West,Alice
|
||||
2025-03-12,Ergonomic Chair,Furniture,6,349.50,North,David
|
||||
2025-03-14,Wireless Mouse,Electronics,18,24.99,South,Carol
|
||||
2025-03-15,Whiteboard,Stationery,5,129.00,North,Bob
|
||||
2025-03-17,Standing Desk,Furniture,3,599.00,West,Alice
|
||||
2025-03-19,USB-C Hub,Electronics,7,45.99,North,David
|
||||
2025-03-21,Monitor 27in,Electronics,5,429.00,South,Carol
|
||||
2025-03-23,Desk Lamp,Furniture,4,79.95,West,Bob
|
||||
2025-03-25,Keyboard Mech,Electronics,3,149.99,North,Alice
|
||||
2025-03-27,Filing Cabinet,Furniture,2,189.00,South,David
|
||||
2025-03-28,Webcam HD,Electronics,11,89.99,West,Carol
|
||||
2025-03-29,Laptop Pro 15,Electronics,1,1299.99,North,Bob
|
||||
2025-03-30,Notebook Pack,Stationery,15,12.99,South,Alice
|
||||
2025-03-31,Ergonomic Chair,Furniture,2,349.50,West,David
|
||||
|
Reference in New Issue
Block a user