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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,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