diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 40c791159ba..92ee3e8521b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -109,6 +109,10 @@ + + + + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/ClawAgent.Console.csproj b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/ClawAgent.Console.csproj new file mode 100644 index 00000000000..4174b7b3d6f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/ClawAgent.Console.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + enable + $(NoWarn);OPENAI001;MAAI001 + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/Program.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/Program.cs new file mode 100644 index 00000000000..4c0d4e346b9 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/Program.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.Metrics; +using ClawAgent; +using Harness.Shared.Console; +using Harness.Shared.Console.OpenAI; +using Harness.Shared.Console.ToolFormatters; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +const string ServiceName = "ClawAgent.Console"; +var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT"); +var telemetryEnabled = !string.IsNullOrWhiteSpace(otlpEndpoint); + +// Export telemetry only when an OTLP endpoint is configured. We deliberately avoid the +// console exporter: this is an interactive app whose UI is rendered by +// HarnessConsole.RunAgentAsync, and streaming spans/metrics to stdout corrupts that UI. +var resourceBuilder = ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"); +using var tracerProvider = telemetryEnabled + ? Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(resourceBuilder) + .AddSource(ClawAgentFactory.OpenTelemetrySourceName) + .AddHttpClientInstrumentation() + .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!)) + .Build() + : null; + +using var meterProvider = telemetryEnabled + ? Sdk.CreateMeterProviderBuilder() + .SetResourceBuilder(resourceBuilder) + .AddMeter(ClawAgentFactory.OpenTelemetrySourceName) + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!)) + .Build() + : null; + +if (!telemetryEnabled) +{ + Console.WriteLine("Telemetry export is off. Set OTEL_EXPORTER_OTLP_ENDPOINT to send traces/metrics to an OTLP collector."); +} + +using var meter = new Meter(ClawAgentFactory.OpenTelemetrySourceName); +var sessionCounter = meter.CreateCounter("claw_console_sessions_total", description: "Interactive claw console sessions started."); +sessionCounter.Add(1); + +await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions +{ + Log = Console.WriteLine, +}); + +await HarnessConsole.RunAgentAsync( + build.Agent, + userPrompt: "Ask me to value a stock, score your portfolio risk, research some tickers, or tidy your trade confirmations.", + new HarnessConsoleOptions + { + Observers = + [ + new OpenAIResponsesWebSearchDisplayObserver(), + new OpenAIResponsesErrorObserver(), + .. HarnessConsoleOptions.BuildObserversWithPlanning( + build.Agent, + planModeName: "plan", + executionModeName: "execute", + toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters()), + ], + CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(build.Agent), + }); diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/README.md new file mode 100644 index 00000000000..6029ba48dd5 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console/README.md @@ -0,0 +1,12 @@ +# ClawAgent.Console + +Interactive local host for the production-ready claw. It uses the shared `ClawAgentFactory` and the Step 03 console experience (`HarnessConsole.RunAgentAsync`) with planning observers and OpenAI Responses display helpers. + +## Run + +```bash +cd dotnet +dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Console +``` + +Set `OTEL_EXPORTER_OTLP_ENDPOINT` to send traces/metrics to an OTLP collector (for example a local Aspire dashboard). When it is not set, telemetry is not exported — there is no console exporter, because streaming spans and metrics to stdout would corrupt the interactive UI rendered by `HarnessConsole.RunAgentAsync`. diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/ClawAgent.Evals.csproj b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/ClawAgent.Evals.csproj new file mode 100644 index 00000000000..98062801e81 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/ClawAgent.Evals.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + $(NoWarn);OPENAI001;MAAI001 + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/Program.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/Program.cs new file mode 100644 index 00000000000..956486d2031 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/Program.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.RegularExpressions; +using Azure.AI.Projects; +using Azure.Identity; +using ClawAgent; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI.Evaluation; +using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals; + +string[] queries = +[ + "What's the capital of France?", + "Value MSFT for me.", + "How risky is my portfolio?", +]; + +await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions +{ + // Evals run only the trusted skill scripts bundled with this sample. Auto-approve those scripts + // so evaluation receives the completed answer instead of an approval request. + AdditionalToolAutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule], + Log = Console.WriteLine, +}); + +Regex digitRegex = new(@"\d"); +LocalEvaluator localEvaluator = new( + FunctionEvaluator.Create("off_topic_refusal_or_finance_steer", item => + { + if (!item.Query.Contains("capital of France", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return item.Response.Contains("finance", StringComparison.OrdinalIgnoreCase) + || item.Response.Contains("invest", StringComparison.OrdinalIgnoreCase) + || item.Response.Contains("portfolio", StringComparison.OrdinalIgnoreCase) + || item.Response.Contains("outside", StringComparison.OrdinalIgnoreCase) + || item.Response.Contains("can't", StringComparison.OrdinalIgnoreCase) + || item.Response.Contains("cannot", StringComparison.OrdinalIgnoreCase); + }), + FunctionEvaluator.Create("numeric_valuation", item => + !item.Query.Contains("Value MSFT", StringComparison.OrdinalIgnoreCase) + || digitRegex.IsMatch(item.Response)), + FunctionEvaluator.Create("portfolio_risk_runs", item => + !item.Query.Contains("portfolio", StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrWhiteSpace(item.Response))); + +AgentEvaluationResults localResults = await build.Agent.EvaluateAsync(queries, localEvaluator, evalName: "ClawLocalFinanceEvals"); +PrintResults("Local finance evals", localResults, queries); + +string? endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT"); +if (!string.IsNullOrWhiteSpace(endpoint)) +{ + string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; + AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + FoundryEvals foundryEvals = new(projectClient, deploymentName, FoundryEvals.Relevance, FoundryEvals.Coherence); + AgentEvaluationResults foundryResults = await build.Agent.EvaluateAsync(queries, foundryEvals, evalName: "ClawFoundryQualityEvals"); + PrintResults("Foundry quality evals", foundryResults, queries); +} +else +{ + Console.WriteLine("Skipping Foundry quality evals. Set FOUNDRY_PROJECT_ENDPOINT to enable them."); +} + +static void PrintResults(string title, AgentEvaluationResults results, string[] queries) +{ + Console.WriteLine($"=== {title} ==="); + Console.WriteLine($"Provider: {results.ProviderName}"); + Console.WriteLine($"Passed: {results.Passed}/{results.Total}"); + if (results.ReportUrl is not null) + { + Console.WriteLine($"Report: {results.ReportUrl}"); + } + + Console.WriteLine(); + + for (int i = 0; i < results.Items.Count; i++) + { + Console.WriteLine($"Query: {(i < queries.Length ? queries[i] : "N/A")}"); + Console.WriteLine($"Response: {(results.InputItems?[i].Response is { } response ? response[..Math.Min(80, response.Length)] : "N/A")}..."); + foreach (var metric in results.Items[i].Metrics) + { + string value = metric.Value is NumericMetric numericMetric && numericMetric.Value.HasValue + ? numericMetric.Value.Value.ToString("F1") + : metric.Value.Interpretation?.Failed == true ? "FAIL" : "PASS"; + Console.WriteLine($" {metric.Key}: {value}"); + } + + Console.WriteLine(); + } +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/README.md new file mode 100644 index 00000000000..848d26c6070 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals/README.md @@ -0,0 +1,16 @@ +# ClawAgent.Evals + +Evaluation host for the production-ready claw. + +It builds the shared agent with `ClawAgentFactory`, runs local finance checks with `LocalEvaluator` and `FunctionEvaluator.Create(...)`, and prints `Passed`/`Total`. When `FOUNDRY_PROJECT_ENDPOINT` is available, it also runs Foundry quality evals (`FoundryEvals.Relevance` and `FoundryEvals.Coherence`). + +The eval host auto-approves only Agent Skills tools so the trusted scripts bundled with this sample +can produce complete answers. Trades, shell commands, file writes, and unrelated tools remain subject +to their normal approval behavior. + +## Run + +```bash +cd dotnet +dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Evals +``` diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.agentignore b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.agentignore new file mode 100644 index 00000000000..4e8de03ee83 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.agentignore @@ -0,0 +1,40 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.dockerignore b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.dockerignore new file mode 100644 index 00000000000..7c571658e8a --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.dockerignore @@ -0,0 +1,10 @@ +.env +bin/ +obj/ +.vs/ +.vscode/ +*.user +.azure/ +.checkpoints/ +agent-file-memory/ +*.log diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.gitignore b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.gitignore new file mode 100644 index 00000000000..5fe72b02045 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.gitignore @@ -0,0 +1,2 @@ +.azure +azure.yaml \ No newline at end of file diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/ClawAgent.Hosted.csproj b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/ClawAgent.Hosted.csproj new file mode 100644 index 00000000000..3786e46bbb8 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/ClawAgent.Hosted.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + $(NoWarn);OPENAI001;MAAI001 + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/Dockerfile b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/Dockerfile new file mode 100644 index 00000000000..4f9da5f96a0 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/Dockerfile @@ -0,0 +1,34 @@ +# Dockerfile for the ClawAgent.Hosted Foundry Hosted Agent, built from the agent-framework repo source. +# +# This project uses ProjectReference to local repo sources (ClawAgent, Microsoft.Agents.AI.Foundry, +# Microsoft.Agents.AI.Foundry.Hosting, Microsoft.Agents.AI.LocalCodeAct) and to the repo's Central +# Package Management, so a standard in-container `dotnet restore`/`publish` cannot resolve everything +# from this folder alone. Instead, PRE-PUBLISH the app on your machine (inside the full repo, where the +# references and package versions resolve) and COPY the output into the image: +# +# # 1. Build and publish separately, targeting the container runtime (glibc x64): +# dotnet publish -c Release -f net10.0 -r linux-x64 --self-contained false -o out +# +# # 2. Then build the image from the pre-published output: +# docker build -t personal-finance-claw . +# +# # 3. (Optional) run it locally: +# docker run --rm -p 8088:8088 --env-file .env personal-finance-claw +# +# `azd deploy` performs step 2 for you (building remotely in Azure Container Registry) — but you must +# still run step 1 (publish to ./out) first. See README.md. +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# LocalCodeAct spawns Python to run and validate model-generated code, so the image needs python3. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENV LOCAL_CODEACT_PYTHON=python3 +# The container is non-interactive: never block on a console prompt for a missing setting. +ENV AF_DEMO_NONINTERACTIVE=1 +ENTRYPOINT ["dotnet", "ClawAgent.Hosted.dll"] diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/Program.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/Program.cs new file mode 100644 index 00000000000..c4266c9d714 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Hosts the claw as a Foundry Hosted Agent (Responses API). +// +// Observability requires no extra wiring here: AddFoundryResponses automatically wraps the agent +// with OpenTelemetryAgent, and the Foundry hosting runtime (Azure.AI.AgentServer.Core's +// AddAgentHostTelemetry) registers the OTLP exporter pipeline. In the hosted environment Foundry +// injects APPLICATIONINSIGHTS_CONNECTION_STRING automatically, so traces, metrics and logs flow to +// Application Insights with no exporter configuration. To capture prompt/response content in traces, +// set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true (off by default). +// +// File access and shell are DISABLED on the hosted agent. Granting the model arbitrary read/write +// access to the container filesystem, or letting it run shell commands, is a serious security risk in +// a shared hosted environment (data exfiltration, tampering, persistence) — and the local +// confirmations vault the shell operates on does not exist here. If you genuinely need file access +// when hosted, supply an external AgentFileStore (for example, one backed by Azure Blob Storage) via +// ClawAgentFactoryOptions.FileStore instead of using the container disk. +// +// CodeAct uses LocalCodeAct here, NOT the Hyperlight provider the local hosts use. Hyperlight runs +// guest code in a VM-isolated micro-sandbox that needs a hypervisor (KVM) and FUSE — neither of which +// an unprivileged Foundry hosted container exposes (attempting it fails at startup while configuring +// `fuse`, so the app never reports ready). LocalCodeAct instead runs the generated Python in a child +// process and relies on the hosted container itself as the isolation boundary, which is exactly the +// pattern the canonical Hosted-LocalCodeAct sample uses. SECURITY: LocalCodeAct is not itself a +// sandbox — only deploy it to an externally sandboxed environment such as a Foundry hosted-agent +// container. + +using Azure.Core; +using Azure.Identity; +using ClawAgent; +using DotNetEnv; +using Hosted_Shared_Contributor_Setup; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Agents.AI.LocalCodeAct; + +Env.TraversePath().Load(); + +var builder = WebApplication.CreateBuilder(args); +var httpContextAccessor = new HttpContextAccessor(); +builder.Services.AddSingleton(httpContextAccessor); + +var projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; +var pythonExecutable = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON") ?? "python3"; +var purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID"); + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in +// production. Prefer a specific credential (e.g. ManagedIdentityCredential) when hosted. Here we chain +// a temporary dev token (for local Docker debugging) ahead of DefaultAzureCredential (for local +// dotnet run / managed identity when hosted). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions +{ + ProjectEndpoint = projectEndpoint, + DeploymentName = deploymentName, + Credential = credential, + AgentDescription = "A production-ready personal finance claw with skills, CodeAct, background agents, telemetry, and optional Purview governance.", + PurviewCredential = string.IsNullOrWhiteSpace(purviewClientAppId) ? null : credential, + FoundryCallIdProvider = () => + { + HttpContext? context = httpContextAccessor.HttpContext; + return context is null ? null : context.Request.Headers["x-agent-foundry-call-id"].ToString(); + }, + + // Disable filesystem and shell access on the hosted container (see risk note above). + EnableFileAccess = false, + EnableShell = false, + + // Use LocalCodeAct instead of the default Hyperlight provider: the hosted container has no + // hypervisor/FUSE for Hyperlight, and acts as the sandbox for the child Python process itself. + CodeActProvider = new LocalCodeActProvider(pythonExecutable), + + Log = Console.WriteLine, +}); + +// AddFoundryResponses wires up the Responses API host for the agent and auto-applies OpenTelemetry. +builder.Services.AddFoundryResponses(build.Agent); + +var app = builder.Build(); + +// Map the hosted-agent endpoint that live Foundry calls. +app.MapFoundryResponses(); + +// Contributor-only: map the per-agent OpenAI route shape for local debugging. Not used in production. +app.MapDevTemporaryLocalAgentEndpoint(); + +app.Run(); diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/README.md new file mode 100644 index 00000000000..a1ba6809da7 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/README.md @@ -0,0 +1,186 @@ +# ClawAgent.Hosted + +ASP.NET host that serves the shared claw through the Foundry Responses hosting APIs. + +The host is deliberately thin: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Wires up the Responses API host for the agent and auto-applies OpenTelemetry. +builder.Services.AddFoundryResponses(build.Agent); + +var app = builder.Build(); + +// The endpoint that live Foundry calls. +app.MapFoundryResponses(); + +// Contributor-only: local REPL route shape. Not used in production. +app.MapDevTemporaryLocalAgentEndpoint(); + +app.Run(); +``` + +## Observability comes for free + +No exporter wiring is required. `AddFoundryResponses` automatically wraps the agent with +`OpenTelemetryAgent`, and the Foundry hosting runtime (`Azure.AI.AgentServer.Core`'s +`AddAgentHostTelemetry`) registers the OTLP exporter pipeline. When hosted, Foundry injects +`APPLICATIONINSIGHTS_CONNECTION_STRING` automatically, so traces, metrics, and logs flow to +Application Insights with no configuration. + +To capture prompt and response content in traces (off by default), set: + +```bash +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +``` + +## File and shell access are disabled here + +The hosted build turns **file access and shell off**: + +```csharp +await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions +{ + // ... + EnableFileAccess = false, + EnableShell = false, +}); +``` + +Why: in a shared, hosted container, giving the model arbitrary read/write access to the filesystem, or +letting it run shell commands, is a serious security risk — data exfiltration, tampering, and +persistence — even behind a deny-list. The local confirmations vault the shell operates on doesn't +exist in the hosted environment anyway. If you enable either capability on a hosted container, treat it +as a production security decision and scope it tightly. + +If you genuinely need file access when hosted, prefer supplying an **external `AgentFileStore`** (for +example, one backed by Azure Blob Storage) rather than the container disk: + +```csharp +await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions +{ + // ... + EnableFileAccess = true, + FileStore = new MyBlobAgentFileStore(blobContainerClient), +}); +``` + +## CodeAct runs on LocalCodeAct here, not Hyperlight + +The local hosts give the model a **Hyperlight**-backed CodeAct sandbox, which runs guest code in a +VM-isolated micro-sandbox. That needs a hypervisor (KVM) and FUSE — neither of which an unprivileged +Foundry hosted container exposes — so the Hyperlight provider can't initialize its sandbox when +hosted, and the agent never becomes ready. + +The hosted build instead supplies a **`LocalCodeActProvider`**, which runs the generated Python in a +child process and relies on the hosted container itself as the isolation boundary: + +```csharp +await using ClawAgentBuild build = await ClawAgentFactory.CreateAsync(new ClawAgentFactoryOptions +{ + // ... + EnableFileAccess = false, + EnableShell = false, + + // Hyperlight needs a hypervisor + FUSE the hosted container lacks; LocalCodeAct relies on the + // container as the sandbox. Override the interpreter with LOCAL_CODEACT_PYTHON if needed. + CodeActProvider = new LocalCodeActProvider( + Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON") ?? "python3"), +}); +``` + +> **Security:** `LocalCodeAct` is not itself a sandbox — it executes model-generated Python in a child +> process. Only deploy it to an externally sandboxed environment such as a Foundry hosted-agent +> container. To turn CodeAct off entirely instead, set `EnableCodeAct = false`. + +## Run locally + +```bash +cd dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted +dotnet run +``` + +## Deploy to Foundry (container path) + +This project deploys as a **container image** (not Foundry's source-code/zip path). + +The project uses `ProjectReference` to sibling and +framework sources (`ClawAgent`, `Microsoft.Agents.AI.Foundry`, `.Foundry.Hosting`, +`.LocalCodeAct`) and the repo's Central Package Management (`dotnet/Directory.Packages.props`). + +Because the ProjectReferences point outside this folder, a standard in-container `dotnet publish` +can't resolve them. So the flow is **two explicit steps**: publish locally first, then build/deploy +the image (this is what [`Dockerfile`](./Dockerfile) expects — it just +`COPY`s the pre-published `out/`). + +**1. (First time only) initialize azd in container mode** (writes a `docker`-based `azure.yaml`): + +```bash +azd ai agent init -m agent.manifest.yaml --deploy-mode container +``` + +`azd init` provisions (or reuses) a **container registry** and records it in the +`AZURE_CONTAINER_REGISTRY_ENDPOINT` environment variable, so you don't need to configure a registry +manually — azd pushes the built image there using this project's [`Dockerfile`](./Dockerfile) +automatically. + +By default azd builds the image **remotely in Azure Container Registry**, so you don't need local +Docker. Set `remoteBuild: false` under the `docker:` options in `azure.yaml` to build locally +(requires Docker Desktop). + +**2. Build and publish the app separately** (on your machine, inside the full repo, so the +ProjectReferences and package versions resolve). Target the container runtime (glibc x64): + +```bash +cd dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted +dotnet publish -c Release -f net10.0 -r linux-x64 --self-contained false -o out +``` + +This produces `out/ClawAgent.Hosted.dll` and its dependencies. `out/` is what +`Dockerfile` copies — you must run this step **before** every image build/deploy. + +**3. Grant the Foundry workspace identity `AcrPull` on the registry.** azd pushes the image, but the +hosted agent runtime pulls it using the Foundry **project's** system-assigned managed identity. That +identity needs `AcrPull` on your registry, or the deploy fails with *"Container registry +authentication failed … verify the workspace managed identity has AcrPull permissions"*: + +```bash +# Get the project's system-assigned managed identity principal id: +PRINCIPAL_ID=$(az resource show \ + --ids "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/" \ + --query identity.principalId -o tsv) + +# Grant AcrPull on the registry: +az role assignment create \ + --role AcrPull \ + --assignee-principal-type ServicePrincipal \ + --assignee-object-id "$PRINCIPAL_ID" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.ContainerRegistry/registries/ +``` + +> RBAC changes can take a minute or two to propagate before the deploy can pull the image. + +**4. Deploy:** + +```bash +azd up # first deploy: provisions resources, builds the image, creates the agent version +# or, once provisioned (remember to re-run step 2 first so out/ is fresh): +azd deploy +``` + +**Test the image locally first (optional but recommended):** + +```bash +# after step 2: +docker build -t personal-finance-claw . +docker run --rm -p 8088:8088 --env-file .env personal-finance-claw +# in another shell — should return HTTP 200: +curl -i http://localhost:8088/readiness +``` + +> **Non-interactive note:** the sample helpers prompt on the console for missing settings, which would +> block a non-interactive container. The image sets `AF_DEMO_NONINTERACTIVE=1` (and `az`-style hosts +> have redirected stdin) so startup never blocks. Provide real values via the `env:` map in +> `azure.yaml` or the container's environment. See the +> [container deployment guide](https://learn.microsoft.com/azure/foundry/agents/how-to/deploy-hosted-agent). diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/agent.manifest.yaml b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/agent.manifest.yaml new file mode 100644 index 00000000000..7a29f536f9e --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/agent.manifest.yaml @@ -0,0 +1,38 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: personal-finance-claw +displayName: "Personal Finance Claw" + +description: > + A production-ready personal finance claw hosted as a Foundry Hosted Agent with + Agent Framework harness capabilities, observability, optional Purview governance, + local finance skills, CodeAct, and background agents. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - Observability + - Purview + - Evaluation + +template: + name: personal-finance-claw + kind: hosted + protocols: + - protocol: responses + version: 2.0.0 + resources: + cpu: "1.0" + memory: 2Gi + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: TOOLBOX_MCP_SERVER_URL + value: "{{TOOLBOX_MCP_SERVER_URL}}" + - name: PURVIEW_CLIENT_APP_ID + value: "{{PURVIEW_CLIENT_APP_ID}}" +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/agent.yaml b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/agent.yaml new file mode 100644 index 00000000000..bf2171dd1ad --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/agent.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: personal-finance-claw +protocols: + - protocol: responses + version: 2.0.0 +resources: + cpu: "1.0" + memory: 2Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: TOOLBOX_MCP_SERVER_URL + value: ${TOOLBOX_MCP_SERVER_URL} + - name: PURVIEW_CLIENT_APP_ID + value: ${PURVIEW_CLIENT_APP_ID} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgent.csproj b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgent.csproj new file mode 100644 index 00000000000..caeca12129f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgent.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + $(NoWarn);OPENAI001;MAAI001 + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentBuild.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentBuild.cs new file mode 100644 index 00000000000..87a0b6beff0 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentBuild.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; + +namespace ClawAgent; + +/// +/// Contains the built production-ready claw agent and resources that must live as long as the agent. +/// +public sealed class ClawAgentBuild : IAsyncDisposable +{ + private readonly List _disposables; + private readonly List _asyncDisposables; + private bool _disposed; + + internal ClawAgentBuild( + AIAgent agent, + bool foundrySkillsEnabled, + bool purviewEnabled, + IEnumerable disposables, + IEnumerable asyncDisposables) + { + this.Agent = agent; + this.FoundrySkillsEnabled = foundrySkillsEnabled; + this.PurviewEnabled = purviewEnabled; + this._disposables = [.. disposables]; + this._asyncDisposables = [.. asyncDisposables]; + } + + /// + /// Gets the fully configured claw agent. + /// + public AIAgent Agent { get; } + + /// + /// Gets a value indicating whether Foundry Toolbox MCP skills were enabled. + /// + public bool FoundrySkillsEnabled { get; } + + /// + /// Gets a value indicating whether Purview governance was enabled. + /// + public bool PurviewEnabled { get; } + + /// + public async ValueTask DisposeAsync() + { + if (this._disposed) + { + return; + } + + this._disposed = true; + foreach (IAsyncDisposable disposable in this._asyncDisposables) + { + await disposable.DisposeAsync().ConfigureAwait(false); + } + + foreach (IDisposable disposable in this._disposables) + { + disposable.Dispose(); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentFactory.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentFactory.cs new file mode 100644 index 00000000000..69e766e14a8 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentFactory.cs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using HyperlightSandbox.Guest.Python; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; +using Microsoft.Agents.AI.Purview; +using Microsoft.Agents.AI.Tools.Shell; +using Microsoft.Extensions.AI; + +namespace ClawAgent; + +/// +/// Builds the shared production-ready claw agent used by all hosts. +/// +public static class ClawAgentFactory +{ + /// + /// The OpenTelemetry source and meter name used by the claw harness agent. + /// + public const string OpenTelemetrySourceName = "BuildYourOwnClaw.ProductionReady.Claw"; + + private const string DefaultDeploymentName = "gpt-5.4"; + + private const string 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. + """; + + /// + /// Creates the full claw agent and returns it with the resources that must be disposed by the host. + /// + /// Optional host-specific build settings. + /// Cancellation token. + /// The built agent and disposable resources. + public static async Task CreateAsync(ClawAgentFactoryOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= new ClawAgentFactoryOptions(); + Action log = options.Log ?? Console.WriteLine; + + string endpoint = options.ProjectEndpoint + ?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); + string deploymentName = options.DeploymentName + ?? Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") + ?? DefaultDeploymentName; + + string workingDir = options.WorkingDirectory ?? Path.Combine(AppContext.BaseDirectory, "working"); + string vaultDir = Path.Combine(workingDir, "confirmations"); + string skillsDir = options.SkillsDirectory ?? Path.Combine(AppContext.BaseDirectory, "skills"); + + TokenCredential credential = options.Credential ?? new DefaultAzureCredential(); + AIProjectClient projectClient = new( + new Uri(endpoint), + credential, + new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }); + + IChatClient chatClient = projectClient + .GetProjectOpenAIClient() + .GetResponsesClient() + .AsIChatClient(deploymentName); + + bool purviewEnabled = false; + string? purviewClientAppId = Environment.GetEnvironmentVariable("PURVIEW_CLIENT_APP_ID"); + TokenCredential? purviewCredential = options.PurviewCredential; + if (purviewCredential is null && !string.IsNullOrWhiteSpace(purviewClientAppId)) + { + purviewCredential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialOptions { ClientId = purviewClientAppId }); + } + + if (purviewCredential is not null) + { + chatClient = chatClient + .AsBuilder() + .WithPurview(purviewCredential, new PurviewSettings("Claw")) + .Build(); + purviewEnabled = true; + log(options.PurviewCredential is not null + ? "Purview enabled (host-provided credential). " + : "Purview enabled (interactive browser credential). "); + } + else + { + log("Purview disabled. Set PURVIEW_CLIENT_APP_ID to enable governance checks."); + } + + var skillsBuilder = new AgentSkillsProviderBuilder() + .UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync); + + HttpClient? toolboxHttpClient = null; + ModelContextProtocol.Client.McpClient? toolboxMcpClient = null; + string? toolboxUrl = Environment.GetEnvironmentVariable("TOOLBOX_MCP_SERVER_URL"); + bool foundrySkillsEnabled = false; + if (!string.IsNullOrWhiteSpace(toolboxUrl)) + { + (toolboxMcpClient, toolboxHttpClient) = await FoundrySkills.ConnectAsync( + toolboxUrl, + credential, + options.FoundryCallIdProvider, + cancellationToken).ConfigureAwait(false); + skillsBuilder.UseMcpSkills(toolboxMcpClient); + foundrySkillsEnabled = true; + log("Foundry skills enabled (Toolbox MCP). "); + } + else + { + log("Foundry skills disabled. Set TOOLBOX_MCP_SERVER_URL to enable them."); + } + + skillsBuilder.UseOptions((options) => + { + options.DisableLoadSkillApproval = true; + options.DisableReadSkillResourceApproval = true; + }); + + AgentSkillsProvider skillsProvider = skillsBuilder.Build(); + AIAgent researchAgent = ResearchAgent.Create(chatClient); + + // Shell access is a powerful capability. It is confined to the local vault directory with a + // deny-list here, but on shared/hosted deployments it is disabled entirely (see hosted host). + LocalShellExecutor? shell = null; + if (options.EnableShell) + { + shell = new LocalShellExecutor(new LocalShellExecutorOptions + { + WorkingDirectory = vaultDir, + ConfineWorkingDirectory = true, + Policy = new ShellPolicy(denyList: + [ + @"\brm\s+-rf\b", + @"\bsudo\b", + @":\(\)\s*\{", + @"\bmkfs\b", + @">\s*/dev/sd", + ]), + Timeout = TimeSpan.FromSeconds(15), + }); + log("Shell enabled (confined to the confirmations vault). "); + } + else + { + log("Shell disabled. "); + } + + // File access is enabled by default via a filesystem-backed store. Hosts may disable it or + // supply an external store (for example, backed by blob storage) instead of the container disk. + AgentFileStore? fileStore = null; + if (options.EnableFileAccess) + { + fileStore = options.FileStore ?? new FileSystemAgentFileStore(workingDir); + log(options.FileStore is not null + ? "File access enabled (custom AgentFileStore). " + : "File access enabled (local filesystem). "); + } + else + { + log("File access disabled. "); + } + + // CodeAct gives the model a sandboxed code interpreter. By default we use the Hyperlight + // provider, which runs guest code in a VM-isolated micro-sandbox — great for local hosts, but + // it needs a hypervisor (KVM) and FUSE, which an unprivileged Foundry hosted container does not + // expose. Hosts running in such an environment supply their own provider via + // options.CodeActProvider (for example a LocalCodeActProvider that relies on the container + // itself as the isolation boundary) or disable CodeAct entirely with EnableCodeAct = false. + AIContextProvider? codeAct = null; + if (options.EnableCodeAct) + { + codeAct = options.CodeActProvider + ?? new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath())); + log(options.CodeActProvider is not null + ? "CodeAct enabled (custom provider). " + : "CodeAct enabled (Hyperlight VM-isolated sandbox). "); + } + else + { + log("CodeAct disabled. "); + } + + List contextProviders = [skillsProvider]; + if (codeAct is not null) + { + contextProviders.Add(codeAct); + } + + List tools = + [ + StockTools.CreateGetStockPriceTool(), + TradingTools.CreatePlaceTradeTool(), + ]; + + // Shell support is composed explicitly: the context provider tells the model about the + // environment, while the approval-gated function exposes command execution. + if (shell is not null) + { + contextProviders.Add(new ShellEnvironmentProvider(shell)); + tools.Add(shell.AsAIFunction(requireApproval: true)); + } + + List>> autoApprovalRules = + [ + FileAccessProvider.ReadOnlyToolsAutoApprovalRule, + ]; + if (options.AdditionalToolAutoApprovalRules is not null) + { + autoApprovalRules.AddRange(options.AdditionalToolAutoApprovalRules); + } + + AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions + { + Name = options.AgentName, + Description = options.AgentDescription, + FileAccessStore = fileStore, + DisableAgentSkillsProvider = true, + BackgroundAgents = [researchAgent], + OpenTelemetrySourceName = OpenTelemetrySourceName, + ToolApprovalAgentOptions = new ToolApprovalAgentOptions + { + AutoApprovalRules = autoApprovalRules, + }, + AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" }, + AIContextProviders = contextProviders, + ChatOptions = new ChatOptions + { + Instructions = Instructions, + Tools = tools, + Reasoning = new() { Effort = ReasoningEffort.Medium }, + }, + }); + + List disposables = []; + if (codeAct is IDisposable disposableCodeAct) + { + disposables.Add(disposableCodeAct); + } + + if (toolboxHttpClient is not null) + { + disposables.Add(toolboxHttpClient); + } + + if (chatClient is IDisposable disposableChatClient) + { + disposables.Add(disposableChatClient); + } + + List asyncDisposables = []; + if (shell is not null) + { + asyncDisposables.Add(shell); + } + + if (toolboxMcpClient is not null) + { + asyncDisposables.Add(toolboxMcpClient); + } + + return new ClawAgentBuild(agent, foundrySkillsEnabled, purviewEnabled, disposables, asyncDisposables); + } +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentFactoryOptions.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentFactoryOptions.cs new file mode 100644 index 00000000000..39a6eebb7c4 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ClawAgentFactoryOptions.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Core; +using Microsoft.Agents.AI; + +namespace ClawAgent; + +/// +/// Options for building the production-ready claw agent. +/// +public sealed class ClawAgentFactoryOptions +{ + /// + /// Gets or sets the Foundry project endpoint. Defaults to FOUNDRY_PROJECT_ENDPOINT. + /// + public string? ProjectEndpoint { get; set; } + + /// + /// Gets or sets the Foundry model deployment name. Defaults to AZURE_AI_MODEL_DEPLOYMENT_NAME or gpt-5.4. + /// + public string? DeploymentName { get; set; } + + /// + /// Gets or sets the token credential used for Foundry. Defaults to . + /// + public TokenCredential? Credential { get; set; } + + /// + /// Gets or sets the token credential used for Purview. When provided, Purview is enabled with + /// this credential. Otherwise, PURVIEW_CLIENT_APP_ID enables local browser authentication. + /// + public TokenCredential? PurviewCredential { get; set; } + + /// + /// Gets or sets an optional provider for the current Foundry hosted call ID. When supplied, the + /// call ID is forwarded to the Toolbox MCP endpoint as x-agent-foundry-call-id. + /// + public Func? FoundryCallIdProvider { get; set; } + + /// + /// Gets or sets the agent name exposed to hosting and telemetry. + /// + public string AgentName { get; set; } = "personal-finance-claw"; + + /// + /// Gets or sets the agent description exposed to hosting and telemetry. + /// + public string AgentDescription { get; set; } = "A production-ready personal finance claw with skills, shell, CodeAct, background agents, telemetry, and optional Purview governance."; + + /// + /// Gets or sets the working directory containing portfolio data and trade confirmations. + /// + public string? WorkingDirectory { get; set; } + + /// + /// Gets or sets the directory containing file-based skills. + /// + public string? SkillsDirectory { get; set; } + + /// + /// Gets or sets a value indicating whether the agent can read and write files on the host. + /// + /// + /// Enabled by default for local hosts. Disable it on shared/hosted deployments where giving the + /// model arbitrary read/write access to the container filesystem is a data-exfiltration and + /// tampering risk. When you still need file access in a hosted environment, prefer supplying an + /// external (for example, a blob-storage-backed store) rather than the + /// container disk. + /// + public bool EnableFileAccess { get; set; } = true; + + /// + /// Gets or sets an optional custom used for file access. + /// + /// + /// When (and is ), a + /// rooted at is used. Supply + /// your own store to keep files off the local disk — for example, a store backed by Azure Blob + /// Storage — which is the recommended approach for hosted deployments. Ignored when + /// is . + /// + public AgentFileStore? FileStore { get; set; } + + /// + /// Gets or sets a value indicating whether the agent can run shell commands on the host. + /// + /// + /// Enabled by default for local hosts. Disable it on shared/hosted deployments: arbitrary command + /// execution inside the hosted container is a serious security risk (data exfiltration, persistence, + /// tampering) even with a deny-list, and the local vault it operates on does not exist in the + /// hosted environment. + /// + public bool EnableShell { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the agent exposes a CodeAct code interpreter. + /// + /// + /// Enabled by default. When and is + /// , a Hyperlight-backed, VM-isolated provider is used — suitable for local + /// hosts with a hypervisor (KVM) and FUSE. Foundry hosted containers do not expose those, so a + /// hosted host should either supply a that relies on the container as + /// the sandbox (for example a LocalCodeActProvider) or set this to . + /// + public bool EnableCodeAct { get; set; } = true; + + /// + /// Gets or sets an optional CodeAct context provider used when is + /// . + /// + /// + /// When the factory creates a Hyperlight-backed provider. Supply your own + /// (for example a LocalCodeActProvider) to run in an environment without a hypervisor, such + /// as a Foundry hosted container. Ignored when is + /// . If the provider implements it is + /// disposed by the returned . + /// + public AIContextProvider? CodeActProvider { get; set; } + + /// + /// Gets or sets additional tool auto-approval rules for this host. + /// + /// + /// The factory always includes the read-only file-access rule. Use additional rules only in + /// trusted hosts, such as an evaluation runner that executes bundled skill scripts. + /// + public IEnumerable>>? AdditionalToolAutoApprovalRules { get; set; } + + /// + /// Gets or sets the optional log callback used for setup notes. + /// + public Action? Log { get; set; } +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/FoundrySkills.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/FoundrySkills.cs new file mode 100644 index 00000000000..b1748c10a39 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/FoundrySkills.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Headers; +using Azure.Core; +using ModelContextProtocol.Client; + +namespace ClawAgent; + +/// +/// Helpers for wiring centrally-managed Foundry skills into the claw via a Foundry Toolbox MCP endpoint. +/// +internal static class FoundrySkills +{ + /// + /// Connects to a Foundry Toolbox MCP endpoint and returns a connected MCP client. + /// + public static async Task<(McpClient McpClient, HttpClient HttpClient)> ConnectAsync( + string toolboxMcpServerUrl, + TokenCredential credential, + Func? foundryCallIdProvider = null, + CancellationToken cancellationToken = default) + { + var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default", foundryCallIdProvider) + { + InnerHandler = new HttpClientHandler(), + }); + + try + { + McpClient mcpClient = await McpClient.CreateAsync( + new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri(toolboxMcpServerUrl), + Name = "foundry_toolbox", + TransportMode = HttpTransportMode.StreamableHttp, + AdditionalHeaders = new Dictionary + { + ["Foundry-Features"] = "Toolboxes=V1Preview", + }, + }, + httpClient), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return (mcpClient, httpClient); + } + catch + { + httpClient.Dispose(); + throw; + } + } + + private sealed class BearerTokenHandler( + TokenCredential credential, + string scope, + Func? foundryCallIdProvider) : DelegatingHandler + { + private readonly TokenRequestContext _tokenContext = new([scope]); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + string? callId = foundryCallIdProvider?.Invoke(); + if (!string.IsNullOrWhiteSpace(callId) && !request.Headers.Contains("x-agent-foundry-call-id")) + { + request.Headers.TryAddWithoutValidation("x-agent-foundry-call-id", callId); + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/README.md new file mode 100644 index 00000000000..3a04dec0291 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/README.md @@ -0,0 +1,7 @@ +# ClawAgent + +Shared class library for the production-ready personal finance claw. + +`ClawAgentFactory.CreateAsync(...)` returns a `ClawAgentBuild` containing the fully configured `AIAgent` plus disposable resources. It preserves the Step 03 capabilities: Foundry Responses `IChatClient`, local file skills, optional Foundry Toolbox MCP skills, background research agent, confined `LocalShellExecutor`, Hyperlight CodeAct, file access, approvals, agent modes, stock tools, and trading tools. + +Purview is opt-in via `PURVIEW_CLIENT_APP_ID`; when unset, the chat client is not wrapped. Telemetry is always enabled through `HarnessAgentOptions.OpenTelemetrySourceName`. diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ResearchAgent.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ResearchAgent.cs new file mode 100644 index 00000000000..d76d98b39fb --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/ResearchAgent.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace ClawAgent; + +/// +/// Builds the background research agent that the main claw fans work out to. +/// +internal static class ResearchAgent +{ + /// + /// Creates a web-search-only background agent for delegated ticker research. + /// + public static AIAgent Create(IChatClient chatClient) => + chatClient.AsAIAgent( + 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.", + name: "TickerResearchAgent", + description: "Searches the web for recent news and commentary about a single stock ticker.", + tools: [new HostedWebSearchTool()]); +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/StockTools.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/StockTools.cs new file mode 100644 index 00000000000..1404e4ef267 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/StockTools.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace ClawAgent; + +/// +/// A custom function tool that gives the claw access to illustrative stock prices. +/// +internal static class StockTools +{ + /// + /// A delayed, illustrative stock quote, including trailing earnings-per-share. + /// + public sealed record StockQuote(string Symbol, decimal Price, decimal TrailingEps, string Currency, DateTimeOffset AsOf); + + private static readonly Dictionary s_priceBook = new(StringComparer.OrdinalIgnoreCase) + { + ["MSFT"] = (462.97m, 11.80m), + ["AAPL"] = (229.35m, 6.13m), + ["GOOGL"] = (178.12m, 7.54m), + ["AMZN"] = (201.45m, 4.18m), + ["NVDA"] = (134.81m, 2.95m), + ["SPY"] = (612.40m, 23.10m), + }; + + /// + /// Gets the latest delayed, illustrative stock price and trailing EPS for a ticker symbol. + /// + [Description("Gets the latest (delayed, illustrative) stock price and trailing earnings per share for a ticker symbol.")] + public static StockQuote GetStockPrice( + [Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol) + { + if (!s_priceBook.TryGetValue(symbol, out var data)) + { + var seed = 0; + foreach (var ch in symbol.ToUpperInvariant()) + { + seed = (seed * 31 + ch) % 1_000_000; + } + + var price = 50m + seed % 45000 / 100m; + data = (price, Math.Round(price / 20m, 2)); + } + + return new StockQuote(symbol.ToUpperInvariant(), data.Price, data.Eps, "USD", DateTimeOffset.UtcNow); + } + + /// + /// Creates the AI function wrapper used to expose the stock price tool to the agent. + /// + public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price"); +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/SubprocessScriptRunner.cs new file mode 100644 index 00000000000..b892b634b6a --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/SubprocessScriptRunner.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClawAgent; + +/// +/// Executes file-based skill scripts as local subprocesses. +/// +internal sealed class SubprocessScriptRunner +{ + private static readonly TimeSpan s_scriptTimeout = TimeSpan.FromSeconds(30); + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public SubprocessScriptRunner(ILoggerFactory? loggerFactory = null) + { + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + /// Runs a skill script as a local subprocess. + /// + public async Task RunAsync( + AgentFileSkill skill, + AgentFileSkillScript script, + JsonElement? arguments, + IServiceProvider? serviceProvider, + CancellationToken cancellationToken) + { + this._logger.LogDebug("Running script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name); + + if (!File.Exists(script.FullPath)) + { + this._logger.LogError("Script file not found for skill '{SkillName}': {ScriptPath}", skill.Frontmatter.Name, script.FullPath); + return $"Error: Script file not found: {script.FullPath}"; + } + + string extension = Path.GetExtension(script.FullPath); + string? interpreter = extension switch + { + ".py" => OperatingSystem.IsWindows() ? "python" : "python3", + ".js" => "node", + ".sh" => "bash", + ".ps1" => "pwsh", + _ => null, + }; + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".", + }; + + if (interpreter is not null) + { + startInfo.FileName = interpreter; + startInfo.ArgumentList.Add(script.FullPath); + } + else + { + startInfo.FileName = script.FullPath; + } + + if (arguments is { ValueKind: JsonValueKind.Array } json) + { + foreach (var element in json.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " + + "All array elements must be JSON strings."); + } + + startInfo.ArgumentList.Add(element.GetString()!); + } + } + else if (arguments?.ValueKind is not null and not JsonValueKind.Null and not JsonValueKind.Undefined) + { + throw new InvalidOperationException( + $"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " + + "File-based skill scripts expect positional arguments as a JSON array of strings."); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_scriptTimeout); + CancellationToken runToken = timeoutCts.Token; + + Process? process = null; + try + { + process = Process.Start(startInfo); + if (process is null) + { + this._logger.LogError("Failed to start process for script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name); + return $"Error: Failed to start process for script '{script.Name}'."; + } + + Task outputTask = process.StandardOutput.ReadToEndAsync(runToken); + Task errorTask = process.StandardError.ReadToEndAsync(runToken); + + await process.WaitForExitAsync(runToken).ConfigureAwait(false); + + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + + if (!string.IsNullOrEmpty(error)) + { + if (process.ExitCode == 0) + { + this._logger.LogWarning( + "Script '{ScriptName}' from skill '{SkillName}' succeeded but wrote to stderr:\n{Stderr}", + script.Name, skill.Frontmatter.Name, error.Trim()); + } + + output += $"\nStderr:\n{error}"; + } + + if (process.ExitCode != 0) + { + this._logger.LogError( + "Script '{ScriptName}' from skill '{SkillName}' exited with code {ExitCode}.{Stderr}", + script.Name, + skill.Frontmatter.Name, + process.ExitCode, + string.IsNullOrEmpty(error) ? string.Empty : $"\nStderr:\n{error.Trim()}"); + + output += $"\nScript exited with code {process.ExitCode}"; + } + + string result = string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + + if (process.ExitCode == 0) + { + this._logger.LogInformation( + "Script '{ScriptName}' from skill '{SkillName}' completed successfully. Output:\n{Output}", + script.Name, + skill.Frontmatter.Name, + result); + } + + return result; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + process?.Kill(entireProcessTree: true); + this._logger.LogError( + "Script '{ScriptName}' from skill '{SkillName}' timed out after {Timeout} seconds.", + script.Name, + skill.Frontmatter.Name, + s_scriptTimeout.TotalSeconds); + return $"Error: Script '{script.Name}' timed out after {s_scriptTimeout.TotalSeconds:0} seconds."; + } + catch (OperationCanceledException) + { + process?.Kill(entireProcessTree: true); + throw; + } + catch (Exception ex) + { + this._logger.LogError(ex, "Failed to execute script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name); + return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; + } + finally + { + process?.Dispose(); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/TradingTools.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/TradingTools.cs new file mode 100644 index 00000000000..057f1fd13a9 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/TradingTools.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace ClawAgent; + +/// +/// Sensitive claw tools that take real-world actions and therefore require human approval. +/// +internal static class TradingTools +{ + /// + /// Places a simulated buy or sell order for a given symbol and quantity. + /// + [Description("Places a buy or sell order for a given symbol and quantity.")] + public static string PlaceTrade( + [Description("The stock ticker symbol to trade, e.g. MSFT.")] string symbol, + [Description("Either 'buy' or 'sell'.")] string action, + [Description("The number of shares to trade.")] int quantity) + { + var isBuy = action.Equals("buy", StringComparison.OrdinalIgnoreCase); + var isSell = action.Equals("sell", StringComparison.OrdinalIgnoreCase); + if (!isBuy && !isSell) + { + return $"Invalid action '{action}'. Use 'buy' or 'sell'."; + } + + if (quantity <= 0) + { + return $"Invalid quantity '{quantity}'. Quantity must be a positive whole number of shares."; + } + + var verb = isSell ? "Sold" : "Bought"; + var confirmation = $"TRADE-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + return $"{verb} {quantity} share(s) of {symbol.ToUpperInvariant()}. Confirmation: {confirmation}."; + } + + /// + /// Creates an approval-required AI function for placing trades. + /// + public static AIFunction CreatePlaceTradeTool() => + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(PlaceTrade, "place_trade")); +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/SKILL.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/SKILL.md new file mode 100644 index 00000000000..b83e785a692 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/SKILL.md @@ -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. diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/references/risk-bands.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/references/risk-bands.md new file mode 100644 index 00000000000..2d55b607068 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/references/risk-bands.md @@ -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. diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/scripts/risk_score.py b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/scripts/risk_score.py new file mode 100644 index 00000000000..b1186c6ed46 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/risk-scoring/scripts/risk_score.py @@ -0,0 +1,58 @@ +# 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() diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/SKILL.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/SKILL.md new file mode 100644 index 00000000000..e00bc51c20b --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/SKILL.md @@ -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. diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/references/valuation-guide.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/references/valuation-guide.md new file mode 100644 index 00000000000..3021bc47334 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/references/valuation-guide.md @@ -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. diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/scripts/valuation_metrics.py b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/scripts/valuation_metrics.py new file mode 100644 index 00000000000..c4528c98277 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/skills/valuation/scripts/valuation_metrics.py @@ -0,0 +1,57 @@ +# 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() diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/2025-06-21_nvda.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/2025-06-21_nvda.txt new file mode 100644 index 00000000000..dd748103c24 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/2025-06-21_nvda.txt @@ -0,0 +1,6 @@ +TRADE CONFIRMATION +Confirmation: TRADE-55AA44BB +Date: 2025-06-21 +Symbol: NVDA +Action: SELL +Quantity: 20 diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/SPY sell.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/SPY sell.txt new file mode 100644 index 00000000000..264f6be1653 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/SPY sell.txt @@ -0,0 +1,6 @@ +TRADE CONFIRMATION +Confirmation: TRADE-77CC88DD +Date: 2024-05-08 +Symbol: SPY +Action: SELL +Quantity: 15 diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/conf_AAPL.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/conf_AAPL.txt new file mode 100644 index 00000000000..b922fd9c52c --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/conf_AAPL.txt @@ -0,0 +1,6 @@ +TRADE CONFIRMATION +Confirmation: TRADE-9F8E7D6C +Date: 2024-11-03 +Symbol: AAPL +Action: BUY +Quantity: 75 diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/copy of trade 3.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/copy of trade 3.txt new file mode 100644 index 00000000000..f100704e6d3 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/copy of trade 3.txt @@ -0,0 +1,6 @@ +TRADE CONFIRMATION +Confirmation: TRADE-1234ABCD +Date: 2025-09-12 +Symbol: AMZN +Action: BUY +Quantity: 30 diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/googl-jan.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/googl-jan.txt new file mode 100644 index 00000000000..5671661ec85 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/googl-jan.txt @@ -0,0 +1,6 @@ +TRADE CONFIRMATION +Confirmation: TRADE-EE11FF22 +Date: 2025-01-30 +Symbol: GOOGL +Action: BUY +Quantity: 25 diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/trade confirmation 1.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/trade confirmation 1.txt new file mode 100644 index 00000000000..dbab2841072 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/confirmations/trade confirmation 1.txt @@ -0,0 +1,6 @@ +TRADE CONFIRMATION +Confirmation: TRADE-A1B2C3D4 +Date: 2024-02-14 +Symbol: MSFT +Action: BUY +Quantity: 40 diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/portfolio.csv b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/portfolio.csv new file mode 100644 index 00000000000..0ed4304caa7 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent/working/portfolio.csv @@ -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 diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/README.md new file mode 100644 index 00000000000..ef3d45bf712 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/README.md @@ -0,0 +1,21 @@ +# Production-ready claw (Post 4) — .NET + +Post 4 restructures the Step 03 claw into a shared agent library plus three thin hosts. + +## Projects + +- [`ClawAgent`](./ClawAgent/README.md) — shared factory that builds the full Step 03 HarnessAgent, adds a stable OpenTelemetry source name, and enables Purview only when `PURVIEW_CLIENT_APP_ID` is set. +- [`ClawAgent.Console`](./ClawAgent.Console/README.md) — local interactive console host with console and OTLP OpenTelemetry exporters. +- [`ClawAgent.Hosted`](./ClawAgent.Hosted/README.md) — ASP.NET Responses host for Foundry Hosted Agent deployment. Observability is wired automatically by the hosting runtime; file access and shell are disabled on the container. +- [`ClawAgent.Evals`](./ClawAgent.Evals/README.md) — local finance checks and optional Foundry quality evals. + +## Common configuration + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" # optional +export TOOLBOX_MCP_SERVER_URL="https://.../mcp?api-version=v1" # optional Foundry skills +export PURVIEW_CLIENT_APP_ID="" # optional Purview governance +``` + +OpenTelemetry uses source and meter name `BuildYourOwnClaw.ProductionReady.Claw`. Hosts choose exporters. diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md index f1a96ed7d52..9417ba42952 100644 --- a/dotnet/samples/02-agents/Harness/README.md +++ b/dotnet/samples/02-agents/Harness/README.md @@ -20,6 +20,7 @@ Samples accompanying the [*Build your own agent harness or claw with Microsoft A | [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning | | [Claw_Step02_WorkingWithData](./BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md) | Post 2 — file access, approvals, and durable memory (file memory plus optional Foundry memory) | | [Claw_Step03_ScalingCapabilities](./BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md) | Post 3 — scaling the claw with skills (plus optional Foundry skills), a confined shell, CodeAct, and background agents | +| [Claw_Step04_ProductionReady](./BuildYourOwnClaw/Claw_Step04_ProductionReady/README.md) | Post 4 — production-ready shared claw library with observability, opt-in Purview governance, Foundry hosted deployment, and evals | ## Security Considerations diff --git a/dotnet/src/Shared/Demos/SampleEnvironment.cs b/dotnet/src/Shared/Demos/SampleEnvironment.cs index 850ab2da624..3a141eec8e9 100644 --- a/dotnet/src/Shared/Demos/SampleEnvironment.cs +++ b/dotnet/src/Shared/Demos/SampleEnvironment.cs @@ -4,6 +4,7 @@ using System; using System.Collections; +using System.IO; using SystemEnvironment = System.Environment; namespace SampleHelpers; @@ -13,6 +14,32 @@ internal static class SampleEnvironment public static string? GetEnvironmentVariable(string key) => GetEnvironmentVariable(key, EnvironmentVariableTarget.Process); + // Returns true when the process cannot safely prompt for console input. This is the case for + // hosted-agent containers, CI runs, and any invocation with redirected/piped stdin. Callers must + // never block on Console.ReadLine in these environments: a hosted agent that blocks on startup + // never serves its /readiness endpoint and is reported as never becoming ready. Deployments can + // also force this behavior explicitly by setting AF_DEMO_NONINTERACTIVE (useful when a host + // allocates a pseudo-terminal so stdin is not detected as redirected). + private static bool IsNonInteractive(EnvironmentVariableTarget target) + { + var forced = SystemEnvironment.GetEnvironmentVariable("AF_DEMO_NONINTERACTIVE", target); + if (!string.IsNullOrEmpty(forced) && + forced?.ToUpperInvariant() is "1" or "Y" or "YES" or "TRUE") + { + return true; + } + + try + { + return Console.IsInputRedirected; + } + catch (IOException) + { + // No console is attached at all (for example, some container hosts): treat as non-interactive. + return true; + } + } + public static string? GetEnvironmentVariable(string key, EnvironmentVariableTarget target) { // Allows for opting into showing all setting values in the console output, so that it is easy to troubleshoot sample setup issues. @@ -22,6 +49,16 @@ internal static class SampleEnvironment var value = SystemEnvironment.GetEnvironmentVariable(key, target); if (string.IsNullOrWhiteSpace(value)) { + // In non-interactive environments (a hosted-agent container, CI, or piped/redirected + // stdin) there is no console to prompt at, and Console.ReadLine can block indefinitely + // waiting for input that never arrives. For a hosted agent that means the app never + // finishes starting and its /readiness endpoint never returns 200. Skip the interactive + // prompt in that case and fall back to the default (null) instead of blocking. + if (IsNonInteractive(target)) + { + return value; + } + var color = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Green; Console.Write("Setting '"); diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/.agentignore b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/.agentignore new file mode 100644 index 00000000000..4e8de03ee83 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/.agentignore @@ -0,0 +1,40 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/.gitignore b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/.gitignore new file mode 100644 index 00000000000..5fe72b02045 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/.gitignore @@ -0,0 +1,2 @@ +.azure +azure.yaml \ No newline at end of file diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md new file mode 100644 index 00000000000..48f3081b520 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md @@ -0,0 +1,144 @@ +# Claw Step 04 — Production-ready + +This folder restructures the Step 03 claw into a shared agent module plus thin hosts. It is also a +self-contained Foundry deployment package: Foundry uses code (ZIP) deployment for Python hosted +agents and uploads this folder only. + +- `agent.py` — `build_claw_agent(...)` builds the full Step 03 claw and adds opt-in Purview chat middleware. +- `console_app.py` — local interactive Textual console with OpenTelemetry provider setup. +- `hosted.py` — Foundry Hosted Agent entry point using `ResponsesHostServer`. +- `evals.py` — local finance checks plus optional Foundry evaluators. +- `requirements.txt` — packages installed into the hosted deployment. +- `.agentignore` — files excluded from the uploaded package (caches, `.env`, azd tooling files). +- `skills/` and `subprocess_script_runner.py` — local copies so the folder is a self-contained + package (the parent sample folder is outside the upload and cannot be reached from the container). + +## Environment + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" +export FOUNDRY_MODEL="your-local-model-deployment" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="your-hosted-model-deployment" +``` + +Optional: + +```bash +export TOOLBOX_MCP_SERVER_URL="https://.../mcp?api-version=v1" +export PURVIEW_CLIENT_APP_ID="your-purview-app-client-id" +export ENABLE_CONSOLE_EXPORTERS="true" +export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" +``` + +> **Why `TOOLBOX_MCP_SERVER_URL` and not `FOUNDRY_TOOLBOX_MCP_SERVER_URL`?** Foundry hosted agents +> reserve the `FOUNDRY_*` (and `AGENT_*`) prefix for platform-injected variables such as +> `FOUNDRY_PROJECT_ENDPOINT`. A custom variable using that prefix does not reach the container, so +> the toolbox skills would silently fail to load when deployed. Keep this one unprefixed. + +> **How the toolbox is wired.** `agent.py` uses `FoundryToolbox` (from `agent_framework.foundry`) +> with `load_tools=False`, so only the toolbox's Agent Skills are surfaced, and passes it to the +> agent via `tools=` — that is what connects its MCP session. `MCPSkillsSource` then reads skills +> from `toolbox.session`, aggregated with the local file skills. `FoundryToolbox` authenticates each +> request and forwards the platform's per-request `x-agent-foundry-call-id`. See +> [`04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills`](../../../../04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills) +> for the minimal version of this pattern. Hosted runs connect the toolbox because +> `ResponsesHostServer` enters the agent; `console_app.py` and `evals.py` do it explicitly with +> `async with agent:`. + +> **The hosted agent's managed identity needs the `Foundry User` role.** This is the single most +> likely reason a Toolbox skill fails to load, and the failure actively misleads you: connecting to +> the toolbox and **discovering** skills both succeed (`skill://index.json` is toolbox metadata, which +> needs no role), so the skill is advertised to the model exactly as expected. Only the first +> `load_skill` fails — with `McpError('Failed to read resource.')` — because reading a skill's *body* +> dereferences the project-level skill resource, which does require the role. The toolbox answers +> with a bare JSON-RPC `-32603` and no `data`, so nothing in the error names the cause. +> +> The container logs the identity to grant it to at startup: +> +> ``` +> Agent managed identity (grant it the Foundry User role): +> ``` +> +> ```bash +> az role assignment create --assignee-object-id \ +> --assignee-principal-type ServicePrincipal --role "Foundry User" \ +> --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ +> ``` +> +> Resolve the object id with `az ad sp list --filter "startswith(displayName,'')" -o table` +> (the agent's `…-AgentIdentity` entry), and verify with +> `az role assignment list --assignee --all`. +> +> **Allow for RBAC propagation.** A grant can take several minutes to take effect. Retesting +> immediately can still fail with the identical error, which makes it easy to wrongly conclude the +> role was not the problem. If the first retest fails, wait and try again before looking elsewhere. + +## Run locally + +```bash +uv run --prerelease=allow python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/console_app.py +``` + +> **Why `--prerelease=allow`?** These entry points depend on `agent-framework-foundry-hosting` +> (it provides `FoundryToolbox`), which is currently a prerelease. Without the flag uv refuses to +> resolve the PEP 723 dependency block and the run fails before it starts. + +## Host with Foundry + +```bash +uv run --prerelease=allow python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py +``` + +The hosted version **disables file access and shell** on the container. In a shared, hosted environment, giving the model arbitrary read/write access to the container filesystem or letting it run shell commands is a serious security risk (data exfiltration, tampering, persistence), and the local confirmations vault the shell operates on doesn't exist there. Background agents and Monty CodeAct (alpha) remain enabled. If you need file access when hosted, pass an external `file_access_store` (for example, one backed by Azure Blob Storage) instead of the container disk. + +File memory **stays enabled** when hosted, but its store has to move. The harness writes file memory +to `{cwd}/agent-file-memory` by default, and the deployed code directory (`/app`) is mounted +**read-only** on Foundry hosted agents, so the default directory fails. `hosted.py` therefore passes a +`FileSystemAgentFileStore` rooted at `~/.claw/agent-file-memory`, which is writable. + +### Deploy to Foundry + +```bash +cd python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready +azd ai agent init -m agent.manifest.yaml --entry-point hosted.py +azd deploy +``` + +Foundry deploys this agent with **code (ZIP) deployment** — the default for Python hosted agents. +It uploads this folder, installs `requirements.txt`, and runs a Python entry point. Which file runs +is set by `codeConfiguration.entryPoint` in the generated `azure.yaml` and defaults to `main.py`, so +you must point it at `hosted.py`. We therefore pass `--entry-point hosted.py` to +`azd ai agent init`. The deploy packages this folder only, so `skills/` and +`subprocess_script_runner.py` are copied in here, making the folder a self-contained package. + +## Run evals + +```bash +uv run --prerelease=allow python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/evals.py +``` + +Local evals use `LocalEvaluator` custom checks. When `FOUNDRY_PROJECT_ENDPOINT` is set, the sample also runs `FoundryEvals` with relevance and coherence. + +> **Why the evals auto-approve skill scripts.** `evals.py` passes `auto_approve_skill_scripts=True` +> to `build_claw_agent`. The valuation skill's instructions tell the agent to run +> `scripts/valuation_metrics.py`, and `run_skill_script` requires approval by default — so an +> unattended run would stop at an approval request and the valuation check would score that instead +> of a real answer. The flag is eval-only and scoped to the skill tools: `place_trade`, the shell, +> and file writes keep their normal approval behavior. + +> **Foundry evals permissions.** The `FoundryEvals` step uploads the eval items as a temporary +> dataset to the storage account backing your Foundry project. The identity running the evals — your +> `az login` user for local runs, or the project's managed identity when it reaches storage via +> Entra ID — needs the **Storage Blob Data Contributor** role on that storage account, plus an +> appropriate project role (for example **Azure AI User**). Without the blob role the run fails at +> the dataset upload with `UnauthorizedUserAction` (`POST .../assetstore/v1.0/temporaryDataReference`) +> even though the local evals pass. See +> [Troubleshoot evaluation and observability issues](https://learn.microsoft.com/azure/foundry/observability/how-to/troubleshooting). + +## Observability and Purview + +The **local** hosts (`console_app.py`, `evals.py`) call `configure_otel_providers()` from `agent_framework.observability`, which honors `ENABLE_INSTRUMENTATION`, `ENABLE_SENSITIVE_DATA`, `ENABLE_CONSOLE_EXPORTERS`, and OTLP endpoint environment variables. + +The **hosted** host (`hosted.py`) wires no exporters: Agent Framework instrumentation is on by default and the Foundry hosting runtime collects and exports telemetry. Foundry injects `APPLICATIONINSIGHTS_CONNECTION_STRING` when deployed; set `ENABLE_SENSITIVE_DATA=true` to include prompt/response content. Because the exporters are Foundry-managed, run the hosted host with `azd ai agent run` to see telemetry. + +Purview is opt-in. When `PURVIEW_CLIENT_APP_ID` is set, `agent.py` creates `InteractiveBrowserCredential(client_id=...)` and attaches `PurviewChatPolicyMiddleware(..., PurviewSettings(app_name="Claw"))` to `FoundryChatClient`. Otherwise it prints a note and runs without policy middleware. diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.manifest.yaml b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.manifest.yaml new file mode 100644 index 00000000000..bb9302624b2 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.manifest.yaml @@ -0,0 +1,30 @@ +name: personal-finance-claw +description: > + A production-ready Agent Framework claw harness for personal finance, hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Harness + - CodeAct + - Monty + - Observability + - Purview +template: + name: personal-finance-claw + kind: hosted + protocols: + - protocol: responses + version: 2.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: ENABLE_SENSITIVE_DATA + value: "false" + - name: TOOLBOX_MCP_SERVER_URL + value: "{{TOOLBOX_MCP_SERVER_URL}}" + - name: PURVIEW_CLIENT_APP_ID + value: "{{PURVIEW_CLIENT_APP_ID}}" +resources: [] diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.py new file mode 100644 index 00000000000..754f771814a --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.py @@ -0,0 +1,409 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "agent-framework-purview", +# "agent-framework-tools", +# "agent-framework-monty", +# "mcp", +# "azure-identity", +# "python-dotenv", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +"""Shared production-ready claw agent factory for Post 4. + +Builds the same personal finance claw as Step 03 and adds production wiring for +observability-aware hosts plus opt-in Microsoft Purview chat policy middleware. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL + FOUNDRY_MODEL — Model deployment name for local hosts (defaults to gpt-5.4) + TOOLBOX_MCP_SERVER_URL — Optional Foundry Toolbox MCP endpoint URL for managed skills + PURVIEW_CLIENT_APP_ID — Optional app/client ID; enables Purview chat policy middleware + +Run indirectly through a host, for example: + uv run --prerelease=allow \ + python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/console_app.py +""" + +from __future__ import annotations + +import logging +import os +import uuid +from collections.abc import Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Any, Literal +from urllib.parse import urlsplit + +from agent_framework import ( + Agent, + AgentModeProvider, + AggregatingSkillsSource, + DeduplicatingSkillsSource, + FileAccessProvider, + FileSkillsSource, + FileSystemAgentFileStore, + HistoryProvider, + InMemoryHistoryProvider, + MCPSkillsSource, + SkillsProvider, + SkillsSource, + create_harness_agent, + tool, +) +from agent_framework.foundry import FoundryChatClient, FoundryToolbox +from agent_framework.microsoft import PurviewChatPolicyMiddleware, PurviewSettings +from agent_framework_monty import MontyCodeActProvider +from agent_framework_tools.shell import LocalShellTool, ShellPolicy +from azure.core.credentials import TokenCredential +from azure.identity import AzureCliCredential, InteractiveBrowserCredential +from dotenv import load_dotenv +from mcp.client.session import ClientSession +from pydantic import Field + +# Resolve everything the hosted container needs from this folder. Foundry uses code (ZIP) +# deployment for Python hosted agents and packages *this folder only*, so ``subprocess_script_runner.py`` +# and ``skills/`` live beside this file rather than being shared with the parent sample folder. +# ``working/`` (used only by the local file-access and shell hosts) stays in the parent folder: it is +# outside the deployment package and unused on the hosted container, where file access and shell are off. +_SELF_DIR = Path(__file__).resolve().parent +from subprocess_script_runner import subprocess_script_runner # noqa: E402 + +_WORKING_DIR = _SELF_DIR.parent / "working" +_VAULT_DIR = _WORKING_DIR / "confirmations" +_SKILLS_DIR = _SELF_DIR / "skills" + +# Startup diagnostics go through ``logging``, not ``print``. Foundry hosted agents surface only the +# container's stderr stream, and the agentserver SDK attaches a stderr handler to the root logger — +# so ``print`` (stdout) output is invisible when hosted. See ``hosted.py`` for the matching +# ``logging.basicConfig`` call that has to run before this module builds anything. +logger = logging.getLogger(__name__) + +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. +""" + +_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), +} + + +# +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: + 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(), + } + + +# + + +# +@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; no real order is placed.""" + 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}." + + +# + + +# +def _require_toolbox_session(toolbox: FoundryToolbox) -> ClientSession: + """Return the toolbox's live MCP session, or explain why it is not there yet.""" + session = toolbox.session + if session is None: + raise RuntimeError( + "The Foundry Toolbox is not connected, so its skills cannot be read. It is connected by " + "the agent because it is passed via ``tools=``; make sure that wiring is still in place." + ) + return session + + +def _build_skills_provider(credential: TokenCredential) -> tuple[SkillsProvider, list[Any]]: + """Build local file-based skills plus optional Foundry Toolbox MCP skills. + + Returns the provider and any tools it depends on. ``FoundryToolbox`` is returned as a tool so the + agent owns its connection lifecycle; it is what authenticates the toolbox and forwards the + platform's per-request ``x-agent-foundry-call-id``. + + Note that reading a skill's body requires the caller identity to hold the ``Foundry User`` role + on the Foundry account. Discovery does not, so a missing grant shows up as skills that load and + advertise fine but fail on first use — see the README. + """ + sources: list[SkillsSource] = [FileSkillsSource(str(_SKILLS_DIR), script_runner=subprocess_script_runner)] + tools: list[Any] = [] + logger.info("Local file skills enabled (from %s).", _SKILLS_DIR) + + toolbox_url = os.environ.get("TOOLBOX_MCP_SERVER_URL", "").strip() + if toolbox_url.startswith(("http://", "https://")): + # Only the netloc is logged: the full URL carries query parameters. + toolbox_host = urlsplit(toolbox_url).netloc or "unknown host" + # ``load_tools=False`` surfaces the toolbox's skills without its tools. The toolbox is still + # passed to the agent via ``tools=`` because that is what connects the MCP session. + toolbox = FoundryToolbox(credential, url=toolbox_url, load_tools=False) + tools.append(toolbox) + # ``session_provider`` (rather than ``client``) lets the source resolve the session on every + # discovery and every on-demand fetch, so it survives a toolbox reconnect. + sources.append(MCPSkillsSource(session_provider=lambda: _require_toolbox_session(toolbox))) + logger.info("Foundry skills enabled (Toolbox MCP at %s).", toolbox_host) + elif toolbox_url: + # A set-but-unusable value is almost always an unsubstituted deployment template placeholder + # (for example a literal ``{{TOOLBOX_MCP_SERVER_URL}}``). Silently skipping it makes a + # deployment error look identical to a deliberate opt-out, so warn instead. + logger.warning( + "Foundry skills disabled: TOOLBOX_MCP_SERVER_URL is set but is not an http(s) URL (got %r). " + "If this looks like an unsubstituted placeholder, check the environment variable wiring " + "in azure.yaml / agent.manifest.yaml.", + toolbox_url, + ) + else: + logger.info("Foundry skills disabled. Set TOOLBOX_MCP_SERVER_URL to enable them.") + + source: SkillsSource = sources[0] if len(sources) == 1 else AggregatingSkillsSource(sources) + # ``load_skill`` only reads a skill's own body, so gating it behind an approval prompt costs a + # full round-trip without protecting anything. Approval is kept where it earns its keep: on + # ``place_trade`` (see its ``approval_mode``) and on ``run_skill_script``, which executes code. + return SkillsProvider(DeduplicatingSkillsSource(source), disable_load_skill_approval=True), tools + + +# + + +# +def _build_research_agent(client: FoundryChatClient) -> Any: + """Build the lean web-search-only chat agent used for per-ticker research.""" + 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." + ), + ) + + +# + + +# +def _build_shell() -> LocalShellTool: + """Build a sandboxed shell confined to the trade-confirmation vault.""" + return LocalShellTool( + mode="persistent", + workdir=str(_VAULT_DIR), + confine_workdir=True, + policy=ShellPolicy( + denylist=[ + r"\brm\s+-rf\b", + r"\bsudo\b", + r":\(\)\s*\{", + r"\bmkfs\b", + r">\s*/dev/sd", + ], + ), + timeout=15, + ) + + +# + + +def _build_purview_middleware(credential: TokenCredential | None = None) -> list[PurviewChatPolicyMiddleware]: + """Build opt-in Purview chat middleware from environment variables. + + When ``credential`` is provided (for example the container's managed identity on hosted + deployments), it is used to authenticate against Purview. Otherwise an + ``InteractiveBrowserCredential`` is used, which suits local interactive runs. + """ + client_app_id = os.environ.get("PURVIEW_CLIENT_APP_ID", "").strip() + if not client_app_id or client_app_id.startswith("{{"): + logger.info("Purview disabled. Set PURVIEW_CLIENT_APP_ID to enable chat policy enforcement.") + return [] + + purview_credential = credential or InteractiveBrowserCredential(client_id=client_app_id) + settings = PurviewSettings(app_name="Claw") + logger.info("Purview enabled (chat policy middleware).") + return [PurviewChatPolicyMiddleware(purview_credential, settings)] + + +# +async def build_claw_agent( + *, + credential: TokenCredential | None = None, + project_endpoint: str | None = None, + model: str | None = None, + default_options: Mapping[str, Any] | None = None, + history_provider: HistoryProvider | None = None, + enable_file_access: bool = True, + file_access_store: Any = None, + file_memory_store: Any = None, + enable_shell: bool = True, + purview_credential: TokenCredential | None = None, + auto_approve_skill_scripts: bool = False, +) -> Agent[Any]: + """Build the production-ready claw harness agent. + + Args: + credential: Azure credential for the Foundry chat client. Defaults to AzureCliCredential. + project_endpoint: Optional Foundry project endpoint override. + model: Optional model deployment override. + default_options: Optional per-agent default chat options, such as ``{"store": False}`` for hosting. + history_provider: Optional history provider override. Hosted agents should pass + ``InMemoryHistoryProvider(load_messages=False)`` because Responses hosting owns history. + enable_file_access: When True (default), the agent can read and write files. Disable it on + shared/hosted deployments where arbitrary read/write access to the container filesystem is a + data-exfiltration and tampering risk; prefer an external ``file_access_store`` instead. + file_access_store: Optional custom ``AgentFileStore``. When None (and ``enable_file_access`` is + True), a ``FileSystemAgentFileStore`` rooted at the working dir is used. Supply your own — for + example, one backed by Azure Blob Storage — to keep files off the container disk when hosted. + file_memory_store: Optional custom ``AgentFileStore`` backing the harness file memory. When + None, the harness default is used, which writes to ``{cwd}/agent-file-memory``. Hosted + deployments must supply a store rooted at a writable path, because the deployed code + directory is mounted read-only. + enable_shell: When True (default), the agent can run shell commands. Disable it on + shared/hosted deployments: arbitrary command execution inside the container is a serious + security risk (data exfiltration, persistence, tampering) even behind a deny-list. + purview_credential: Optional credential for Purview chat policy enforcement. Pass the + container's managed identity (``DefaultAzureCredential``) on hosted deployments; when None, + an ``InteractiveBrowserCredential`` is used for local interactive runs. + auto_approve_skill_scripts: When True, ``run_skill_script`` is auto-approved so skills that run + a bundled script complete without a human in the loop. Intended for unattended runs such as + ``evals.py``; leave it False for interactive and hosted use. It only covers the skill tools — + ``place_trade``, the shell, and file writes keep their normal approval behavior. + + Returns: + A fully configured harness agent with Step 03 capabilities plus opt-in Purview middleware. + """ + load_dotenv() + + # + resolved_credential = credential or AzureCliCredential() + client = FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=resolved_credential, + middleware=_build_purview_middleware(purview_credential), + ) + # + + skills_provider, skills_tools = _build_skills_provider(resolved_credential) + research_agent = _build_research_agent(client) + + if enable_shell: + # The vault only exists for the local shell host; creating it unconditionally would write + # outside the deployed package on a hosted container, where shell is off. + _VAULT_DIR.mkdir(parents=True, exist_ok=True) + shell = _build_shell() + logger.info("Shell enabled (confined to the confirmations vault).") + else: + shell = None + logger.info("Shell disabled.") + + if enable_file_access: + if file_access_store is None: + # Same reasoning as the vault: only the default local store needs this directory. + _WORKING_DIR.mkdir(exist_ok=True) + access_store = file_access_store or FileSystemAgentFileStore(str(_WORKING_DIR)) + logger.info( + "File access enabled (custom AgentFileStore)." + if file_access_store is not None + else "File access enabled (local filesystem)." + ) + else: + access_store = None + logger.info("File access disabled.") + + # + context_providers: list[Any] = [MontyCodeActProvider(approval_mode="never_require")] + logger.info("CodeAct enabled (Monty).") + # + + auto_approval_rules: list[Any] = [FileAccessProvider.read_only_tools_auto_approval_rule] + if auto_approve_skill_scripts: + # Adds ``run_skill_script`` to the auto-approved set. The rule is scoped to the skills + # provider's own local tools, so it cannot approve anything else. + auto_approval_rules.append(SkillsProvider.all_tools_auto_approval_rule) + logger.info("Skill script approval disabled (unattended run).") + + # + return create_harness_agent( + client=client, + name="ClawFinanceAssistant", + description="Production-ready personal finance claw harness agent.", + agent_instructions=FINANCE_INSTRUCTIONS, + tools=[get_stock_price, place_trade, *skills_tools], + history_provider=history_provider or InMemoryHistoryProvider(), + file_access_store=access_store, + file_memory_store=file_memory_store, + skills_provider=skills_provider, + background_agents=[research_agent], + shell_executor=shell, + auto_approval_rules=auto_approval_rules, + context_providers=context_providers, + mode_provider=AgentModeProvider(default_mode="execute"), + default_options=default_options, + ) + # diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.yaml b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.yaml new file mode 100644 index 00000000000..1cd5f43ac26 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-claw-production-ready-responses +protocols: + - protocol: responses + version: 2.0.0 +resources: + cpu: "0.5" + memory: 1Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: ENABLE_SENSITIVE_DATA + value: "false" + - name: TOOLBOX_MCP_SERVER_URL + value: ${TOOLBOX_MCP_SERVER_URL} + - name: PURVIEW_CLIENT_APP_ID + value: ${PURVIEW_CLIENT_APP_ID} diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/console_app.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/console_app.py new file mode 100644 index 00000000000..b5a602ee4ab --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/console_app.py @@ -0,0 +1,84 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "agent-framework-purview", +# "agent-framework-tools", +# "agent-framework-monty", +# "mcp", +# "httpx", +# "textual>=6.2.1", +# "rich>=13.7.1", +# "azure-identity", +# "python-dotenv", +# "opentelemetry-api", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +"""Interactive local host for the production-ready claw. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL + FOUNDRY_MODEL — Model deployment name (defaults to gpt-5.4) + TOOLBOX_MCP_SERVER_URL — Optional Foundry Toolbox MCP endpoint URL + PURVIEW_CLIENT_APP_ID — Optional app/client ID; enables Purview + ENABLE_INSTRUMENTATION — Controls Agent Framework instrumentation + ENABLE_SENSITIVE_DATA — Enables sensitive telemetry capture when true + ENABLE_CONSOLE_EXPORTERS — Enables console OpenTelemetry exporters when true + OTEL_EXPORTER_OTLP_ENDPOINT — Optional OTLP collector endpoint + +Run: + uv run --prerelease=allow \ + python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/console_app.py +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +from pathlib import Path + +from agent_framework.observability import configure_otel_providers, get_tracer +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from opentelemetry import trace +from opentelemetry.trace.span import format_trace_id + +_HARNESS_DIR = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(_HARNESS_DIR)) +from agent import build_claw_agent # noqa: E402 +from console import build_observers_with_planning, run_agent_async # noqa: E402 + + +async def main() -> None: + """Run the production-ready claw in the local interactive console.""" + load_dotenv() + # The agent factory reports its startup wiring (skills, Purview, shell, file access) through + # ``logging``, so that it is visible on Foundry hosted agents, which surface only stderr. + # Configure a handler here so those diagnostics also show up in the local console. + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + configure_otel_providers() + + with get_tracer().start_as_current_span("Claw Console Session", kind=trace.SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + agent = await build_claw_agent(credential=AzureCliCredential()) + async with agent: + session = agent.create_session() + + await run_agent_async( + agent, + session=session, + observers=build_observers_with_planning(agent), + initial_mode="execute", + title="💹 Finance Assistant", + placeholder="Value a stock, score risk, research tickers, or tidy confirmations...", + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/evals.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/evals.py new file mode 100644 index 00000000000..3d013c092d5 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/evals.py @@ -0,0 +1,164 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "agent-framework-purview", +# "agent-framework-tools", +# "agent-framework-monty", +# "mcp", +# "httpx", +# "azure-identity", +# "python-dotenv", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +"""Local and optional hosted Foundry evals for the production-ready claw. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL; also gates Foundry evals + FOUNDRY_MODEL — Model deployment name for local eval runs (defaults to gpt-5.4) + TOOLBOX_MCP_SERVER_URL — Optional Foundry Toolbox MCP endpoint URL + PURVIEW_CLIENT_APP_ID — Optional app/client ID; enables Purview + ENABLE_INSTRUMENTATION — Controls Agent Framework instrumentation + ENABLE_SENSITIVE_DATA — Enables sensitive telemetry capture when true + ENABLE_CONSOLE_EXPORTERS — Enables console OpenTelemetry exporters when true + OTEL_EXPORTER_OTLP_ENDPOINT — Optional OTLP collector endpoint + +Run: + uv run --prerelease=allow python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/evals.py +""" + +from __future__ import annotations + +import asyncio +import os +import re +from typing import Any + +from agent import build_claw_agent +from agent_framework import Agent, AgentResponse, LocalEvaluator, evaluate_agent, evaluator +from agent_framework.foundry import FoundryChatClient, FoundryEvals +from agent_framework.observability import configure_otel_providers +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +FINANCE_EVAL_QUERIES = [ + "What's the capital of France? If this is off-topic, briefly redirect me to finance topics.", + "Use the stock price tool to value MSFT numerically. Include price, trailing EPS, and P/E.", + "Read portfolio.csv and summarize the portfolio tickers and approximate total position value.", +] + + +@evaluator(name="off_topic_refusal_lenient") +def off_topic_refusal_lenient(query: str, response: str) -> dict[str, object]: + """Leniently check that off-topic questions are redirected toward finance.""" + if "capital of france" not in query.lower(): + return {"passed": True, "reason": "Not the off-topic eval item."} + + text = response.lower() + markers = ["finance", "invest", "portfolio", "stock", "off-topic", "can't help", "not able"] + passed = any(marker in text for marker in markers) + return {"passed": passed, "reason": "Off-topic response redirects to finance." if passed else response[:160]} + + +@evaluator(name="numeric_valuation_answer") +def numeric_valuation_answer(query: str, response: str) -> dict[str, object]: + """Check that valuation answers contain numeric content.""" + if "msft" not in query.lower(): + return {"passed": True, "reason": "Not the valuation eval item."} + + numbers = re.findall(r"\d+(?:\.\d+)?", response) + passed = len(numbers) >= 2 and any(term in response.lower() for term in ("p/e", "pe", "price", "eps")) + return {"passed": passed, "reason": f"Found {len(numbers)} numeric tokens."} + + +@evaluator(name="portfolio_grounding_runs_cleanly") +def portfolio_grounding_runs_cleanly(query: str, response: str) -> dict[str, object]: + """Check that portfolio answers are grounded in the sample portfolio file.""" + if "portfolio.csv" not in query.lower(): + return {"passed": True, "reason": "Not the portfolio eval item."} + + text = response.lower() + tickers = ["msft", "aapl", "nvda", "spy"] + found = [ticker for ticker in tickers if ticker in text] + passed = len(found) >= 2 and "error" not in text and "traceback" not in text + return {"passed": passed, "reason": f"Found portfolio tickers: {found}."} + + +async def _run_queries(agent: Agent[Any], queries: list[str]) -> list[AgentResponse[Any]]: + """Run each query on its own fresh session and collect the responses. + + The claw harness agent includes ``ToolApprovalMiddleware``, which requires an + ``AgentSession``. ``evaluate_agent`` does not create one when it runs queries itself, so we run + the agent here (one session per query) and hand the responses to ``evaluate_agent`` via + ``responses=``. Skill scripts are auto-approved for this run (see ``main``), so a skill that + runs a script completes instead of pausing on an approval request. + """ + responses: list[AgentResponse[Any]] = [] + for query in queries: + session = agent.create_session() + responses.append(await agent.run(query, session=session)) + return responses + + +async def main() -> None: + """Run local claw evals, then Foundry-hosted evals when configured.""" + load_dotenv() + configure_otel_providers() + + credential = AzureCliCredential() + # store=False keeps chat history client-side (managed by the harness InMemoryHistoryProvider) + # instead of server-side on the Foundry service. + agent = await build_claw_agent( + credential=credential, + default_options={"store": False}, + # The valuation skill's instructions tell the agent to run ``scripts/valuation_metrics.py``, + # and ``run_skill_script`` requires approval by default — an unattended eval run would stop + # at an approval request and score that instead of a real answer. This is eval-only: + # ``place_trade``, the shell, and file writes keep their normal approval behavior. + auto_approve_skill_scripts=True, + ) + async with agent: + local = LocalEvaluator( + off_topic_refusal_lenient, + numeric_valuation_answer, + portfolio_grounding_runs_cleanly, + ) + + # Run the agent once (one session per query) and reuse the responses for every evaluator. + responses = await _run_queries(agent, FINANCE_EVAL_QUERIES) + + results = await evaluate_agent(agent=agent, queries=FINANCE_EVAL_QUERIES, responses=responses, evaluators=local) + print(f"Local evals: {results[0].passed}/{results[0].total} passed") + for item in results[0].items: + print(f" [{item.status}] {(item.input_text or '')[:70]}") + + if not os.environ.get("FOUNDRY_PROJECT_ENDPOINT"): + print("Foundry evals skipped. Set FOUNDRY_PROJECT_ENDPOINT to enable them.") + return + + foundry = FoundryEvals( + # Supply a credentialed client; otherwise FoundryEvals builds a FoundryChatClient with + # no credential and fails. Endpoint and model resolve from FOUNDRY_PROJECT_ENDPOINT / + # FOUNDRY_MODEL, matching the agent's own client. + client=FoundryChatClient(credential=credential), + evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE], + ) + hosted_results = await evaluate_agent( + agent=agent, + queries=FINANCE_EVAL_QUERIES, + responses=responses, + evaluators=foundry, + eval_name="claw-step04-production-ready", + ) + print(f"Foundry evals: {hosted_results[0].passed}/{hosted_results[0].total} passed") + if hosted_results[0].report_url: + print(f"Foundry report: {hosted_results[0].report_url}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py new file mode 100644 index 00000000000..452cf54b836 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py @@ -0,0 +1,130 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-purview", +# "agent-framework-tools", +# "agent-framework-monty", +# "agent-framework-foundry-hosting", +# "mcp", +# "httpx", +# "azure-identity", +# "python-dotenv", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry Hosted Agent host for the production-ready claw. + +Observability requires no exporter setup here. Agent Framework is natively instrumented (on by +default), and the Foundry hosting runtime collects and exports the traces, metrics, and logs — so +there is no ``configure_otel_providers()`` call. When deployed, Foundry injects +``APPLICATIONINSIGHTS_CONNECTION_STRING`` automatically. To capture prompt/response content, set +``ENABLE_SENSITIVE_DATA=true`` (see ``agent.yaml``). Because the exporters are Foundry-managed, run +this host with ``azd ai agent run`` to see telemetry; running it directly won't export anything. + +File access and shell are disabled on the hosted container (see ``enable_file_access`` / +``enable_shell`` below). File memory stays enabled, but its store is pointed at a writable directory +under the home directory. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL + AZURE_AI_MODEL_DEPLOYMENT_NAME — Model deployment name for the hosted agent + TOOLBOX_MCP_SERVER_URL — Optional Foundry Toolbox MCP endpoint URL + PURVIEW_CLIENT_APP_ID — Optional app/client ID; enables Purview + ENABLE_SENSITIVE_DATA — Enables sensitive telemetry capture (prompts/responses) when true + +Run locally: + uv run --prerelease=allow \ + python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from pathlib import Path + +from agent import build_claw_agent +from agent_framework import FileSystemAgentFileStore, InMemoryHistoryProvider +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +# File memory writes to disk, and the deployed code directory is read-only on Foundry hosted agents, +# so the harness default of ``{cwd}/agent-file-memory`` cannot be created. The home directory is +# writable, so root the store there. +_FILE_MEMORY_DIR = Path.home() / ".claw" / "agent-file-memory" + +logger = logging.getLogger(__name__) + + +def _configure_logging() -> None: + """Route startup diagnostics to stderr so they survive on a Foundry hosted agent. + + Two constraints make this necessary: + + * Foundry surfaces the container's **stderr** stream, so ``print`` (stdout) output never + appears in the hosted logs. + * The agentserver SDK attaches its own stderr handler to the root logger, but only once the + host starts — which is *after* the agent is built below. Without this call, the diagnostics + emitted while building the agent would reach a handler-less root logger and be dropped by + ``logging.lastResort`` (level ``WARNING``). + + The SDK skips adding its handler when one is already present, so this does not double-log. + """ + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + +def _log_environment() -> None: + """Log which platform variables are present, to make misconfiguration self-evident. + + Only variable *names* are logged for the platform-injected ``FOUNDRY_*`` set: their values can + carry session and project details. ``FOUNDRY_AGENT_INSTANCE_CLIENT_ID`` is the exception. It is + the client id of the managed identity the container authenticates as, and that identity needs + the ``Foundry User`` role to read Toolbox skill content — so when a skill fails to load, this + line names the exact principal to grant the role to (see the README). + """ + foundry_vars = sorted(name for name in os.environ if name.startswith("FOUNDRY_")) + logger.info("Platform-injected FOUNDRY_* variables present: %s", ", ".join(foundry_vars) or "(none)") + logger.info( + "Agent managed identity (grant it the Foundry User role): %s", + os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID") or "(not set)", + ) + + +async def main() -> None: + """Build the claw and expose it with the Foundry Responses host server.""" + _configure_logging() + load_dotenv() + _log_environment() + + credential = DefaultAzureCredential() + logger.info("File memory enabled (local filesystem at %s).", _FILE_MEMORY_DIR) + agent = await build_claw_agent( + credential=credential, + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + default_options={"store": False}, + history_provider=InMemoryHistoryProvider(load_messages=False), + # Disable filesystem and shell access on the hosted container. Arbitrary read/write or + # command execution in a shared hosted environment is a serious security risk, and the + # local confirmations vault does not exist here. To keep file access when hosted, pass an + # external file_access_store (e.g. one backed by Azure Blob Storage) instead of the disk. + enable_file_access=False, + enable_shell=False, + # File memory is on by default; keep it, but on a writable path (see _FILE_MEMORY_DIR). + file_memory_store=FileSystemAgentFileStore(_FILE_MEMORY_DIR), + # Purview authenticates via the container's managed identity; InteractiveBrowserCredential + # cannot run on a headless hosted container. + purview_credential=credential, + ) + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/requirements.txt b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/requirements.txt new file mode 100644 index 00000000000..2f607e5666c --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/requirements.txt @@ -0,0 +1,10 @@ +agent-framework-core +agent-framework-foundry +agent-framework-foundry-hosting>=1.0.0a260630 +agent-framework-tools +agent-framework-monty +agent-framework-purview +mcp +httpx +azure-identity +python-dotenv diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/SKILL.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/SKILL.md new file mode 100644 index 00000000000..3ced6ba1c5e --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/SKILL.md @@ -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. diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/references/risk-bands.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/references/risk-bands.md new file mode 100644 index 00000000000..a2beec75ba1 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/references/risk-bands.md @@ -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. diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/scripts/risk_score.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/scripts/risk_score.py new file mode 100644 index 00000000000..22119c8e079 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/risk-scoring/scripts/risk_score.py @@ -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() diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/SKILL.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/SKILL.md new file mode 100644 index 00000000000..d84b57ebb14 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/SKILL.md @@ -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. diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/references/valuation-guide.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/references/valuation-guide.md new file mode 100644 index 00000000000..f6858ac0fc4 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/references/valuation-guide.md @@ -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. diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/scripts/valuation_metrics.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/scripts/valuation_metrics.py new file mode 100644 index 00000000000..9f13df98d06 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/skills/valuation/scripts/valuation_metrics.py @@ -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() diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/subprocess_script_runner.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/subprocess_script_runner.py new file mode 100644 index 00000000000..1f8764a72d4 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/subprocess_script_runner.py @@ -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}"