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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+164
View File
@@ -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())
@@ -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())
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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}"
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-55AA44BB
Date: 2025-06-21
Symbol: NVDA
Action: SELL
Quantity: 20
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-77CC88DD
Date: 2024-05-08
Symbol: SPY
Action: SELL
Quantity: 15
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-9F8E7D6C
Date: 2024-11-03
Symbol: AAPL
Action: BUY
Quantity: 75
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-1234ABCD
Date: 2025-09-12
Symbol: AMZN
Action: BUY
Quantity: 30
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-EE11FF22
Date: 2025-01-30
Symbol: GOOGL
Action: BUY
Quantity: 25
@@ -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
1 symbol shares cost_basis purchase_date
2 MSFT 40 312.50 2023-02-14
3 AAPL 75 168.20 2022-11-03
4 NVDA 120 42.80 2021-06-21
5 AMZN 30 142.10 2023-09-12
6 GOOGL 25 128.45 2024-01-30
7 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
1 date product category quantity unit_price region salesperson
2 2025-01-03 Laptop Pro 15 Electronics 2 1299.99 North Alice
3 2025-01-05 Ergonomic Chair Furniture 5 349.50 South Bob
4 2025-01-07 Wireless Mouse Electronics 12 24.99 North Alice
5 2025-01-08 Standing Desk Furniture 1 599.00 West Carol
6 2025-01-10 USB-C Hub Electronics 8 45.99 North David
7 2025-01-12 Monitor 27in Electronics 3 429.00 South Bob
8 2025-01-14 Desk Lamp Furniture 6 79.95 West Carol
9 2025-01-15 Keyboard Mech Electronics 4 149.99 North Alice
10 2025-01-17 Filing Cabinet Furniture 2 189.00 South David
11 2025-01-20 Webcam HD Electronics 10 89.99 West Bob
12 2025-01-22 Laptop Pro 15 Electronics 1 1299.99 South Carol
13 2025-01-24 Ergonomic Chair Furniture 3 349.50 North Alice
14 2025-01-25 Notebook Pack Stationery 20 12.99 South David
15 2025-01-27 Wireless Mouse Electronics 15 24.99 West Carol
16 2025-01-28 Whiteboard Stationery 4 129.00 North Bob
17 2025-01-30 Standing Desk Furniture 2 599.00 South Alice
18 2025-02-02 USB-C Hub Electronics 6 45.99 West David
19 2025-02-04 Monitor 27in Electronics 2 429.00 North Carol
20 2025-02-05 Desk Lamp Furniture 8 79.95 South Bob
21 2025-02-07 Keyboard Mech Electronics 5 149.99 West Alice
22 2025-02-09 Filing Cabinet Furniture 1 189.00 North David
23 2025-02-11 Webcam HD Electronics 7 89.99 South Carol
24 2025-02-13 Laptop Pro 15 Electronics 3 1299.99 West Bob
25 2025-02-15 Notebook Pack Stationery 30 12.99 North Alice
26 2025-02-17 Ergonomic Chair Furniture 4 349.50 South David
27 2025-02-19 Wireless Mouse Electronics 20 24.99 North Carol
28 2025-02-20 Whiteboard Stationery 2 129.00 West Bob
29 2025-02-22 Standing Desk Furniture 1 599.00 North Alice
30 2025-02-24 USB-C Hub Electronics 10 45.99 South David
31 2025-02-26 Monitor 27in Electronics 4 429.00 West Carol
32 2025-02-28 Desk Lamp Furniture 3 79.95 North Bob
33 2025-03-02 Keyboard Mech Electronics 6 149.99 South Alice
34 2025-03-04 Filing Cabinet Furniture 3 189.00 West David
35 2025-03-06 Webcam HD Electronics 9 89.99 North Carol
36 2025-03-08 Laptop Pro 15 Electronics 2 1299.99 South Bob
37 2025-03-10 Notebook Pack Stationery 25 12.99 West Alice
38 2025-03-12 Ergonomic Chair Furniture 6 349.50 North David
39 2025-03-14 Wireless Mouse Electronics 18 24.99 South Carol
40 2025-03-15 Whiteboard Stationery 5 129.00 North Bob
41 2025-03-17 Standing Desk Furniture 3 599.00 West Alice
42 2025-03-19 USB-C Hub Electronics 7 45.99 North David
43 2025-03-21 Monitor 27in Electronics 5 429.00 South Carol
44 2025-03-23 Desk Lamp Furniture 4 79.95 West Bob
45 2025-03-25 Keyboard Mech Electronics 3 149.99 North Alice
46 2025-03-27 Filing Cabinet Furniture 2 189.00 South David
47 2025-03-28 Webcam HD Electronics 11 89.99 West Carol
48 2025-03-29 Laptop Pro 15 Electronics 1 1299.99 North Bob
49 2025-03-30 Notebook Pack Stationery 15 12.99 South Alice
50 2025-03-31 Ergonomic Chair Furniture 2 349.50 West David