diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c2d4b957c0f..4356d8e9242 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -122,6 +122,7 @@ + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Claw_Step03_ScalingCapabilities.csproj b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Claw_Step03_ScalingCapabilities.csproj new file mode 100644 index 00000000000..3b5f9735d9d --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Claw_Step03_ScalingCapabilities.csproj @@ -0,0 +1,31 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/FoundrySkills.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/FoundrySkills.cs new file mode 100644 index 00000000000..aea7da4088f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/FoundrySkills.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Headers; +using Azure.Core; +using ModelContextProtocol.Client; + +namespace ClawSample; + +/// +/// Helpers for wiring centrally-managed Foundry skills into the claw via a Foundry Toolbox +/// MCP endpoint. These are opt-in: skills published to the toolbox are discovered at runtime, so +/// they can be managed and updated without changing or redeploying the agent. +/// +internal static class FoundrySkills +{ + /// + /// Connects to a Foundry Toolbox MCP endpoint and returns a connected . + /// The caller owns the returned client and its HTTP client. + /// + /// The Foundry Toolbox MCP server URL. + /// Credential used to obtain a bearer token for the toolbox. + /// The connected MCP client and the underlying HTTP client; both must be disposed by the caller. + public static async Task<(McpClient McpClient, HttpClient HttpClient)> ConnectAsync( + string toolboxMcpServerUrl, + TokenCredential credential) + { + var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default") + { + 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)); + + return (mcpClient, httpClient); + } + catch + { + // The MCP client never took ownership of the HTTP client, so dispose it here. + httpClient.Dispose(); + throw; + } + } + + private sealed class BearerTokenHandler(TokenCredential credential, string scope) : 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); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Program.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Program.cs new file mode 100644 index 00000000000..5b0d760984f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Program.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft. All rights reserved. + +// "Scaling its capabilities" — Post 3 of the "Build your own claw and agent harness with Microsoft +// Agent Framework" series. +// See: https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-its-capabilities. +// +// This sample builds on Post 2's personal finance assistant and makes it *more capable* in four ways: +// 1. Skills — package finance know-how (valuation, risk-scoring) as discoverable SKILL.md +// files the agent loads on demand. Optionally fold in centrally-managed Foundry +// skills from a Foundry Toolbox MCP endpoint (opt-in via FOUNDRY_TOOLBOX_MCP_SERVER_URL). +// 2. Shell — a sandboxed shell, confined to the trade-confirmation vault, that the agent +// uses to reorganize the accumulated confirmation files (year/month, rename, +// archive). Guarded by a deny-list policy and a confined working directory. +// 3. CodeAct — the agent writes and runs Python to crunch portfolio numbers, in a sandboxed +// Hyperlight micro-VM (needs hardware virtualization). +// 4. Background agents — fan out a per-ticker research sub-agent so several tickers are researched +// concurrently, then aggregated. +// +// Special commands (handled by the shared HarnessConsole): +// /todos — Display the current todo list without invoking the agent. +// /mode — Get or set the current agent mode. +// /exit — End the session. + +#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. +#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. + +using System.ClientModel.Primitives; +using Azure.AI.Projects; +using Azure.Identity; +using ClawSample; +using Harness.Shared.Console; +using Harness.Shared.Console.OpenAI; +using Harness.Shared.Console.ToolFormatters; +using HyperlightSandbox.Guest.Python; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; +using Microsoft.Agents.AI.Tools.Shell; +using Microsoft.Extensions.AI; + +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4"; + +// The two folders the claw works in: the working folder (portfolio.csv, reports) and the +// trade-confirmation "vault" inside it that the shell will reorganize. +var workingDir = Path.Combine(AppContext.BaseDirectory, "working"); +var vaultDir = Path.Combine(workingDir, "confirmations"); +var skillsDir = Path.Combine(AppContext.BaseDirectory, "skills"); + +// +var 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. + """; +// + +// +// Construct an IChatClient backed by a Microsoft Foundry project (see Post 1 for details). +var credential = new DefaultAzureCredential(); +var projectClient = new AIProjectClient( + new Uri(endpoint), + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + credential, + new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }); + +IChatClient chatClient = projectClient + .GetProjectOpenAIClient() + .GetResponsesClient() + .AsIChatClient(deploymentName); +// + +// +// The harness turns a skills provider on by default (it discovers SKILL.md files from the working +// directory). Here we build our own so we can point it at this sample's skills/ folder and, when +// configured, fold in centrally-managed Foundry skills — all behind one provider. +var skillsBuilder = new AgentSkillsProviderBuilder() + // File-based skills: valuation and risk-scoring. SubprocessScriptRunner runs their Python scripts. + .UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync); + +// Foundry skills (opt-in): discovered live from a Foundry Toolbox MCP endpoint, so they can be +// managed and updated centrally without changing or redeploying this agent. +HttpClient? toolboxHttpClient = null; +ModelContextProtocol.Client.McpClient? toolboxMcpClient = null; +var toolboxUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL"); +if (!string.IsNullOrWhiteSpace(toolboxUrl)) +{ + (toolboxMcpClient, toolboxHttpClient) = await FoundrySkills.ConnectAsync(toolboxUrl, credential); + skillsBuilder.UseMcpSkills(toolboxMcpClient); + Console.WriteLine("Foundry skills enabled (Toolbox MCP)."); +} +else +{ + Console.WriteLine("Foundry skills disabled. Set FOUNDRY_TOOLBOX_MCP_SERVER_URL to enable them."); +} + +AgentSkillsProvider skillsProvider = skillsBuilder.Build(); +// + +// +// Background agents: a lean, web-search-only research sub-agent. Passing it to the harness exposes +// the background_agents_* tools so the claw can start several research tasks concurrently and +// collect the results. +AIAgent researchAgent = ResearchAgent.Create(chatClient); +// + +// +// A sandboxed shell, confined to the trade-confirmation vault. ConfineWorkingDirectory re-anchors +// every command to the vault, and the deny-list policy pre-filters obviously destructive commands. +// (Patterns are a UX guardrail, not a security boundary — for hard isolation use DockerShellExecutor.) +await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions +{ + WorkingDirectory = vaultDir, + ConfineWorkingDirectory = true, + Policy = new ShellPolicy(denyList: + [ + @"\brm\s+-rf\b", + @"\bsudo\b", + @":\(\)\s*\{", // fork-bomb shape + @"\bmkfs\b", + @">\s*/dev/sd", + ]), + Timeout = TimeSpan.FromSeconds(15), +}); +// + +// +// CodeAct: a sandboxed Python interpreter the model can write and run code in to crunch numbers. +// It runs on Hyperlight (a micro-VM, so it needs hardware virtualization). The guest module path is +// resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package. +using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath())); +// + +// +// Turn the chat client into a HarnessAgent. On top of Post 2's file access and approvals we add the +// four "scaling" capabilities: skills (our own provider), background agents, a confined shell, and +// CodeAct. +List contextProviders = [skillsProvider, codeAct]; + +AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions +{ + // File access: portfolio.csv, reports, and the confirmations vault all live under working/. + FileAccessStore = new FileSystemAgentFileStore(workingDir), + // We supply our own skills provider (file + optional Foundry), so turn off the default one. + DisableAgentSkillsProvider = true, + // Fan-out research is delegated to this background agent. + BackgroundAgents = [researchAgent], + // The confined shell, exposed as the approval-gated run_shell tool. + ShellExecutor = shell, + // Keep reading the portfolio frictionless while writes, trades, and shell commands still prompt. + ToolApprovalAgentOptions = new ToolApprovalAgentOptions + { + AutoApprovalRules = [FileAccessProvider.ReadOnlyToolsAutoApprovalRule], + }, + // Start in "execute" mode for quick lookups and actions; switch any time with /mode plan. + AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" }, + // Our skills provider plus CodeAct. + AIContextProviders = contextProviders, + ChatOptions = new ChatOptions + { + Instructions = instructions, + Tools = + [ + StockTools.CreateGetStockPriceTool(), + TradingTools.CreatePlaceTradeTool(), + ], + Reasoning = new() { Effort = ReasoningEffort.Medium }, + }, +}); +// + +try +{ + // + // Run the interactive console session. The default planning observers already include a tool + // approval observer, so the place_trade and run_shell approval prompts are surfaced automatically. + await HarnessConsole.RunAgentAsync( + 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( + agent, + planModeName: "plan", + executionModeName: "execute", + toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())], + CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent), + }); + // +} +finally +{ + codeAct?.Dispose(); + if (toolboxMcpClient is not null) + { + await toolboxMcpClient.DisposeAsync().ConfigureAwait(false); + } + + toolboxHttpClient?.Dispose(); +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md new file mode 100644 index 00000000000..8435415dffb --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md @@ -0,0 +1,80 @@ +# Scaling its capabilities (Post 3) — .NET + +The third runnable sample from the [**"Build your own claw and agent harness with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework) +series ([Part 3 — Scaling its capabilities](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-its-capabilities)). +It builds on Post 2's personal finance assistant and makes it *more capable* along four axes. + +## What this sample demonstrates + +- **Skills** — finance know-how (`valuation`, `risk-scoring`) is packaged as discoverable `SKILL.md` + files under `skills/`, which the agent loads on demand. The sample builds its own provider with + `AgentSkillsProviderBuilder.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync)` + so the skills' Python scripts can run, and sets `DisableAgentSkillsProvider = true` to replace the + harness default. Optionally folds in centrally-managed **Foundry skills** discovered live from a + Foundry **Toolbox MCP** endpoint via `FoundrySkills.ConnectAsync(...)` + `UseMcpSkills(...)` + (opt-in; see below). +- **Shell** — a `LocalShellExecutor` confined to the trade-confirmation vault + (`working/confirmations/`) lets the agent tidy the accumulated confirmation files (reorganize into + `year/month`, rename to `YYYY-MM-DD_TICKER_BUY|SELL.txt`). `ConfineWorkingDirectory` re-anchors + every command to the vault and a `ShellPolicy` deny-list pre-filters obviously destructive + commands. Exposed as the `run_shell` tool, which prompts for approval before each command runs. + (The deny-list is a UX guardrail, not a security boundary — for hard isolation use a + `DockerShellExecutor`.) +- **CodeAct** — a `HyperlightCodeActProvider` gives the agent a sandboxed Python interpreter to + crunch portfolio numbers by writing and running code. It runs on Hyperlight (a micro-VM), so it + requires hardware virtualization. The guest module path is resolved automatically from the + `Hyperlight.HyperlightSandbox.Guest.Python` NuGet package via `PythonGuestModule.GetModulePath()`. +- **Background agents** — a lean, web-search-only `ResearchAgent` is registered via + `HarnessAgentOptions.BackgroundAgents`, exposing the `background_agents_*` tools so the main agent + can fan out per-ticker research concurrently and aggregate the findings. + +## Prerequisites + +1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`). +2. Azure CLI installed and authenticated (`az login`). +3. *(For CodeAct)* a host with hardware virtualization enabled (Hyperlight runs the Python + interpreter in a micro-VM). + +## Environment variables + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" +# Optional (defaults to gpt-5.4) +export FOUNDRY_MODEL="gpt-5.4" + +# Optional — enable centrally-managed Foundry skills (Foundry Toolbox MCP endpoint URL): +export FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-project.services.ai.azure.com/.../toolboxes/your-toolbox/mcp?api-version=v1" +``` + +When `FOUNDRY_TOOLBOX_MCP_SERVER_URL` is not set, the sample runs with the local file skills only and +prints a note. + +## Running + +```bash +cd dotnet +dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities +``` + +## What to expect + +The sample starts an interactive loop in **execute** mode (quick lookups don't need a plan). Try +these in order: + +1. `Value MSFT for me.` — the agent loads the `valuation` skill and follows its instructions + (reading references and running its script). +2. `Score the risk of my portfolio.` — the agent reads `portfolio.csv` and loads the `risk-scoring` + skill. +3. `/mode plan`, then `Tidy up my trade confirmations.` — switching to plan mode first makes the + agent inspect `working/confirmations/` and propose a reorganization plan before touching anything; + once you approve it switches to execute and uses the shell to reorganize and rename the files, + **prompting you to approve** each command. +4. `Work out the total value of my portfolio.` — the agent writes and runs Python via CodeAct. +5. `Research MSFT, NVDA and SPY and summarize the latest news.` — the agent fans the tickers out to + the background research agent and aggregates the results. +6. `What's the capital of France?` — with a `financial-agent-rules` skill published to your Foundry + toolbox and Foundry skills enabled (`FOUNDRY_TOOLBOX_MCP_SERVER_URL`), the agent loads it, + recognizes the question is off-topic, and politely declines, steering you back to finance. + +See the [Part 3 blog post](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-its-capabilities) +for more on the `financial-agent-rules` skill — including the SKILL.md to publish to your Foundry toolbox. diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/ResearchAgent.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/ResearchAgent.cs new file mode 100644 index 00000000000..3d8ba56ff9c --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/ResearchAgent.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace ClawSample; + +/// +/// Builds the background "research" agent that the main claw fans work out to. +/// +/// +/// This sub-agent doesn't need any of the harness machinery, so it's a plain +/// with a single tool: the hosted web search. The parent claw +/// delegates a per-ticker research task to one of these and they run concurrently. +/// +internal static class ResearchAgent +{ + /// Creates a web-search-only background agent for delegated ticker research. + /// The chat client the background agent should use. + 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.", + // The only tool it needs: the same hosted web search the harness would have added. + tools: [new HostedWebSearchTool()]); +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/StockTools.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/StockTools.cs new file mode 100644 index 00000000000..bbac78ac087 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/StockTools.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace ClawSample; + +/// +/// A custom function tool that gives our "claw" access to (illustrative) stock prices. +/// +/// +/// The prices and earnings figures returned here are mock data for demonstration purposes only and +/// are not real market quotes. In a real assistant you would call a market-data API instead. The +/// trailing earnings-per-share value is included so the valuation skill has something to work with. +/// +internal static class StockTools +{ + /// A delayed, illustrative stock quote, including a trailing earnings-per-share figure. + public sealed record StockQuote(string Symbol, decimal Price, decimal TrailingEps, string Currency, DateTimeOffset AsOf); + + // A tiny in-memory book of (price, trailing EPS) so the sample runs without any external dependency. + 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. + /// + /// The stock ticker symbol, e.g. MSFT or AAPL. + [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)) + { + // Deterministic pseudo-values for unknown symbols so the sample stays self-contained. + // Derive a stable seed from the characters — string.GetHashCode() is randomized per + // process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output. + 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 wrapper used to expose the tool to the agent. + public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price"); +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/SubprocessScriptRunner.cs new file mode 100644 index 00000000000..ae4e29c53fd --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/SubprocessScriptRunner.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Sample subprocess-based skill script runner. +// Executes file-based skill scripts as local subprocesses. +// This is provided for demonstration purposes only. + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +/// +/// Executes file-based skill scripts as local subprocesses. +/// +/// +/// This runner uses the script's absolute path and converts the arguments +/// to CLI arguments. When the LLM sends a JSON array, each element is used +/// as a positional argument. It is intended for demonstration purposes only. +/// +internal sealed class SubprocessScriptRunner +{ + /// Maximum time a skill script is allowed to run before it is terminated. + private static readonly TimeSpan s_scriptTimeout = TimeSpan.FromSeconds(30); + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Optional logger factory. When provided, script outcomes (success output, stderr, non-zero + /// exit codes, and failures) are written to the log in addition to being returned to the LLM. + /// + 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 + { + // Windows Python installs commonly expose "python" rather than "python3". + ".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) + { + // Positional CLI arguments + 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 is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != 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."); + } + + // Bound the script's lifetime: cancel after a timeout, or when the caller cancels. + 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) + { + // The timeout fired (the caller did not cancel). Kill the process and report a timeout. + 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) + { + // The caller cancelled: kill the process to avoid leaving orphaned subprocesses, then rethrow. + 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_Step03_ScalingCapabilities/TradingTools.cs b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/TradingTools.cs new file mode 100644 index 00000000000..b17ba8f6dc2 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/TradingTools.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace ClawSample; + +/// +/// Sensitive "claw" tools that take real-world actions and therefore require human approval. +/// +/// +/// These tools only simulate their effects (no orders are placed, no email is sent). They exist +/// to demonstrate how the harness gates risky actions behind an approval prompt. +/// +internal static class TradingTools +{ + // + /// + /// Places a (simulated) buy or sell order for a given symbol and quantity. + /// + /// The stock ticker symbol to trade, e.g. MSFT. + /// Either buy or sell. + /// The number of shares to trade. + [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 for . + /// Wrapping the function in tells the harness to + /// surface an approval request before the function ever runs. + /// + public static AIFunction CreatePlaceTradeTool() => + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(PlaceTrade, "place_trade")); +} diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/risk-scoring/SKILL.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/risk-scoring/SKILL.md new file mode 100644 index 00000000000..3ced6ba1c5e --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/skills/risk-scoring/references/risk-bands.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/risk-scoring/references/risk-bands.md new file mode 100644 index 00000000000..a2beec75ba1 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/skills/risk-scoring/scripts/risk_score.py b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/risk-scoring/scripts/risk_score.py new file mode 100644 index 00000000000..a449b03f164 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/risk-scoring/scripts/risk_score.py @@ -0,0 +1,54 @@ +# 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 = [p for p in args.position if p > 0] + 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_Step03_ScalingCapabilities/skills/valuation/SKILL.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/valuation/SKILL.md new file mode 100644 index 00000000000..d84b57ebb14 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/skills/valuation/references/valuation-guide.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/valuation/references/valuation-guide.md new file mode 100644 index 00000000000..f6858ac0fc4 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/skills/valuation/scripts/valuation_metrics.py b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/skills/valuation/scripts/valuation_metrics.py new file mode 100644 index 00000000000..f91fbacdb70 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/working/confirmations/2025-06-21_nvda.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/working/confirmations/2025-06-21_nvda.txt new file mode 100644 index 00000000000..6350a8f7d2a --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/working/confirmations/SPY sell.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/working/confirmations/SPY sell.txt new file mode 100644 index 00000000000..14305ca985c --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/working/confirmations/conf_AAPL.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/working/confirmations/conf_AAPL.txt new file mode 100644 index 00000000000..a4655401d92 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/working/confirmations/copy of trade 3.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/working/confirmations/copy of trade 3.txt new file mode 100644 index 00000000000..28fb391fd59 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/working/confirmations/googl-jan.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/working/confirmations/googl-jan.txt new file mode 100644 index 00000000000..d8c0470002d --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/working/confirmations/trade confirmation 1.txt b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/working/confirmations/trade confirmation 1.txt new file mode 100644 index 00000000000..2d3f305d85b --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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_Step03_ScalingCapabilities/working/portfolio.csv b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/working/portfolio.csv new file mode 100644 index 00000000000..d2e0fbad5e9 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/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/README.md b/dotnet/samples/02-agents/Harness/README.md index 54338aba6bb..1cb84a8410c 100644 --- a/dotnet/samples/02-agents/Harness/README.md +++ b/dotnet/samples/02-agents/Harness/README.md @@ -18,3 +18,4 @@ Samples accompanying the [*Build your own agent harness or claw with Microsoft A | Sample | Description | | --- | --- | | [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_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 | diff --git a/python/samples/02-agents/harness/build_your_own_claw/README.md b/python/samples/02-agents/harness/build_your_own_claw/README.md index 49413a3b37a..05e3110c591 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/README.md +++ b/python/samples/02-agents/harness/build_your_own_claw/README.md @@ -6,6 +6,7 @@ series. Each step builds a personal finance / investing assistant on top of directory. - **Part 1 — `claw_step01_meet_your_claw.py`** — the minimal harness. +- **Part 3 — `claw_step03_scaling_capabilities.py`** — skills, shell, CodeAct, and background agents. ## Prerequisites @@ -59,3 +60,61 @@ The sample starts an interactive loop. Try these in order: --- +## Part 3 — Scaling its capabilities + +Makes the assistant *more capable* along four axes. + +### What this sample demonstrates + +- **Skills** — finance know-how (`valuation`, `risk-scoring`) is packaged as discoverable + `SKILL.md` files under `skills/`, which the agent loads on demand. The sample builds a + `FileSkillsSource(..., script_runner=subprocess_script_runner)` so the skills' Python + scripts can run. Optionally folds in centrally-managed **Foundry skills** served from a + Foundry Toolbox MCP endpoint via `MCPSkillsSource` (opt-in; see below). +- **Shell** — a `LocalShellTool` confined to the trade-confirmation vault + (`working/confirmations/`) lets the agent tidy the accumulated confirmation files (reorganize into + `year/month`, rename to `YYYY-MM-DD_TICKER_BUY|SELL.txt`). Guarded by a `ShellPolicy` deny-list + **and** a confined working directory; left at the default + `approval_mode="always_require"` so each command is surfaced for approval. +- **CodeAct** — a `MontyCodeActProvider` gives the agent a sandboxed, cross-platform Python + interpreter to crunch portfolio numbers by writing and running code. +- **Background agents** — a lean, web-search-only `TickerResearchAgent` is registered via + `create_harness_agent(background_agents=[...])`, so the main agent can fan out per-ticker research + concurrently and aggregate the findings. + +### Additional environment variables (optional) + +```bash +# Enable centrally-managed Foundry skills (Foundry Toolbox MCP endpoint URL): +export FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://.services.ai.azure.com/.../toolboxes//mcp?api-version=v1" +``` + +When this is not set, the sample runs with the local file skills only, and prints a note. + +### Running + +```bash +uv run python/samples/02-agents/harness/build_your_own_claw/claw_step03_scaling_capabilities.py +``` + +### What to expect + +Try these in order (the sample starts in **execute** mode — quick lookups don't need a plan): + +1. `Value MSFT for me.` — the agent loads the `valuation` skill and follows its instructions + (reading references and running its script). +2. `Score the risk of my portfolio.` — the agent reads `portfolio.csv` and loads the `risk-scoring` + skill. +3. `/mode plan`, then `Tidy up my trade confirmations.` — switching to plan mode first makes the + agent inspect `working/confirmations/` and propose a reorganization plan before touching anything; + once you approve it switches to execute and uses the shell to reorganize and rename the files, + **prompting you to approve** each command. +4. `Work out the total value of my portfolio.` — the agent writes and runs Python via CodeAct. +5. `Research MSFT, NVDA and SPY and summarize the latest news.` — the agent fans the tickers out to + the background research agent and aggregates the results. +6. `What's the capital of France?` — with a `financial-agent-rules` skill published to your Foundry + toolbox and Foundry skills enabled (`FOUNDRY_TOOLBOX_MCP_SERVER_URL`), the agent loads it, + recognizes the question is off-topic, and politely declines, steering you back to finance. + +See the [Part 3 blog post](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-its-capabilities) +for more on the `financial-agent-rules` skill — including the SKILL.md to publish to your Foundry toolbox. diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step03_scaling_capabilities.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step03_scaling_capabilities.py new file mode 100644 index 00000000000..b0eb5135f98 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step03_scaling_capabilities.py @@ -0,0 +1,339 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework", +# "agent-framework-tools", +# "agent-framework-monty", +# "mcp", +# "httpx", +# "textual>=6.2.1", +# "rich>=13.7.1", +# "azure-identity", +# "python-dotenv", +# ] +# /// +# Run with any PEP 723 compatible runner, e.g.: +# uv run python/samples/02-agents/harness/build_your_own_claw/claw_step03_scaling_capabilities.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Scaling its capabilities (Post 3) — Python. + +The third runnable sample from the "Build your own claw and agent harness with Microsoft Agent +Framework" blog series. See: https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-its-capabilities. +It builds on Post 2's personal finance assistant and makes it *more capable* in four ways: + +1. Skills — package finance know-how (valuation, risk-scoring) as discoverable SKILL.md + files the agent loads on demand. Optionally fold in centrally-managed Foundry + skills served from a Foundry Toolbox MCP endpoint (opt-in via + FOUNDRY_TOOLBOX_MCP_SERVER_URL). +2. Shell — a sandboxed shell, confined to the trade-confirmation vault, that the agent uses + to reorganize the accumulated confirmation files (year/month, rename, archive). + Guarded by an allow/deny-list policy and a confined working directory. +3. CodeAct — the agent writes and runs Python to crunch portfolio numbers, using the + cross-platform Monty interpreter. +4. Background agents — fan out a per-ticker research sub-agent so several tickers are researched + concurrently, then aggregated. + +This sample reuses the shared harness ``console`` package in the parent ``harness/`` directory. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL + FOUNDRY_MODEL — Model deployment name (defaults to gpt-5.4) + FOUNDRY_TOOLBOX_MCP_SERVER_URL — (optional) Foundry Toolbox MCP endpoint URL; enables Foundry skills + +Authentication: + Run ``az login`` before running this sample. +""" + +import asyncio +import os +import sys +import uuid +from collections.abc import Callable, Generator +from contextlib import AsyncExitStack +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Any, Literal + +import httpx +from agent_framework import ( + AggregatingSkillsSource, + Agent, + AgentModeProvider, + DeduplicatingSkillsSource, + FileAccessProvider, + FileSkillsSource, + FileSystemAgentFileStore, + MCPSkillsSource, + SkillsProvider, + SkillsSource, + create_harness_agent, + tool, +) +from agent_framework.foundry import FoundryChatClient +from agent_framework_monty import MontyCodeActProvider +from agent_framework_tools.shell import LocalShellTool, ShellPolicy +from azure.identity import AzureCliCredential, get_bearer_token_provider +from dotenv import load_dotenv +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from pydantic import Field + +# Reuse the shared harness console that lives in the parent ``harness/`` directory, and the local +# subprocess script runner used to execute file-based skill scripts. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from console import build_observers_with_planning, run_agent_async # noqa: E402 + +from subprocess_script_runner import subprocess_script_runner # noqa: E402 + +_SAMPLE_DIR = Path(__file__).resolve().parent +_WORKING_DIR = _SAMPLE_DIR / "working" +_VAULT_DIR = _WORKING_DIR / "confirmations" +_SKILLS_DIR = _SAMPLE_DIR / "skills" + +FINANCE_INSTRUCTIONS = """\ +## Personal Finance Assistant Instructions + +You are a personal finance and investing assistant. You help the user understand their portfolio +and watchlist, value individual stocks, gauge portfolio risk, research the market, and keep their +records tidy. + +### Working style + +- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools + before answering questions about their portfolio, and never modify it unless asked. +- You have skills for valuation and risk-scoring. When a question matches a skill, load it and + follow its instructions (read its references, run its scripts) rather than guessing. +- When asked to research several tickers, delegate each one to the background research agent so + they run concurrently, then summarize the findings together. +- The user's trade confirmations accumulate in the working/confirmations folder. When asked to tidy + or reorganize them, use the run_shell tool: inspect the folder first, then move files into a + year/month layout and rename them to YYYY-MM-DD_TICKER_BUY|SELL.txt. Explain your plan before + running commands that change anything. +- To buy or sell, use the place_trade tool. This takes a real action, so the user will be asked to + approve it before it runs — explain what you are about to do first. + +### Important + +You provide information and analysis only — you are not a licensed financial advisor and you must +not present your output as personalized investment advice. Remind the user to do their own +research before making decisions. +""" + +# A tiny in-memory book of (price, trailing EPS) so the sample runs without any external dependency. +# These are illustrative mock values, not real market data. +_PRICE_BOOK: dict[str, tuple[float, float]] = { + "MSFT": (462.97, 11.80), + "AAPL": (229.35, 6.13), + "GOOGL": (178.12, 7.54), + "AMZN": (201.45, 4.18), + "NVDA": (134.81, 2.95), + "SPY": (612.40, 23.10), +} + + +# +def get_stock_price( + symbol: Annotated[str, "The stock ticker symbol, e.g. MSFT or AAPL."], +) -> dict[str, object]: + """Get the latest (delayed, illustrative) stock price and trailing EPS for a ticker symbol.""" + ticker = symbol.upper() + data = _PRICE_BOOK.get(ticker) + if data is None: + # Deterministic pseudo-values for unknown symbols so the sample stays self-contained. + # The built-in hash() is randomized per process (PYTHONHASHSEED), so derive a stable seed. + seed = 0 + for ch in ticker: + seed = (seed * 31 + ord(ch)) % 1_000_000 + price = 50.0 + (seed % 45000) / 100.0 + data = (price, round(price / 20.0, 2)) + + return { + "symbol": ticker, + "price": round(data[0], 2), + "trailing_eps": round(data[1], 2), + "currency": "USD", + "as_of": datetime.now(timezone.utc).isoformat(), + } +# + + +# +@tool(approval_mode="always_require") +def place_trade( + symbol: Annotated[str, "The stock ticker symbol to trade, e.g. MSFT."], + action: Annotated[Literal["buy", "sell"], "Either 'buy' or 'sell'."], + quantity: Annotated[int, Field(gt=0, description="The number of shares to trade.")], +) -> str: + """Place a (simulated) buy or sell order. Marked approval-required, so the harness asks the + user to approve before this ever runs. No real order is placed. + + ``action`` and ``quantity`` are validated by the framework (pydantic) from their type hints: + the model can only pass 'buy'/'sell' and a quantity greater than zero. + """ + verb = "Sold" if action == "sell" else "Bought" + confirmation = f"TRADE-{uuid.uuid4().hex[:8].upper()}" + return f"{verb} {quantity} share(s) of {symbol.upper()}. Confirmation: {confirmation}." +# + + +# +async def _build_skills_provider(stack: AsyncExitStack) -> SkillsProvider: + """Build a skills provider over the local skills/ folder, plus optional Foundry-managed skills. + + File-based skills (valuation, risk-scoring) always load. When FOUNDRY_TOOLBOX_MCP_SERVER_URL is + set we also connect to a Foundry Toolbox MCP endpoint and surface its skills, so they can be + managed and updated centrally without changing this agent. + """ + # subprocess_script_runner lets the file-based skills run their Python scripts. + sources: list[SkillsSource] = [FileSkillsSource(str(_SKILLS_DIR), script_runner=subprocess_script_runner)] + + toolbox_url = os.environ.get("FOUNDRY_TOOLBOX_MCP_SERVER_URL") + if toolbox_url: + session = await _connect_foundry_toolbox(stack, toolbox_url) + sources.append(MCPSkillsSource(client=session)) + print("Foundry skills enabled (Toolbox MCP).") + else: + print("Foundry skills disabled. Set FOUNDRY_TOOLBOX_MCP_SERVER_URL to enable them.") + + source: SkillsSource = sources[0] if len(sources) == 1 else AggregatingSkillsSource(sources) + return SkillsProvider(DeduplicatingSkillsSource(source)) + + +class _ToolboxAuth(httpx.Auth): + """Attach a fresh Foundry bearer token to every request.""" + + def __init__(self, token_provider: Callable[[], str]): + self._get_token = token_provider + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = f"Bearer {self._get_token()}" + yield request + + +async def _connect_foundry_toolbox(stack: AsyncExitStack, url: str) -> ClientSession: + """Open an MCP session against a Foundry Toolbox endpoint, tied to ``stack``'s lifetime.""" + token_provider = get_bearer_token_provider(AzureCliCredential(), "https://ai.azure.com/.default") + http_client = await stack.enter_async_context( + httpx.AsyncClient( + auth=_ToolboxAuth(token_provider), + headers={"Foundry-Features": "Toolboxes=V1Preview"}, + timeout=httpx.Timeout(30.0, read=300.0), + follow_redirects=True, + ) + ) + read, write, _ = await stack.enter_async_context(streamable_http_client(url=url, http_client=http_client)) + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + return session +# + + +# +def _build_research_agent(client: FoundryChatClient) -> Any: + """Build the lean, web-search-only chat agent used for per-ticker research.""" + # This sub-agent doesn't need any harness machinery - it's a plain chat agent with a single + # tool: the same hosted web search the harness would have added. The parent still exposes the + # background_agents_* tools because it receives this agent via background_agents. + return Agent( + client=client, + name="TickerResearchAgent", + description="Searches the web for recent news and commentary about a single stock ticker.", + tools=[client.get_web_search_tool()], + instructions=( + "You research a single stock ticker. Use the web search tool to find the most recent, " + "relevant news and commentary, then return a short, factual summary (3-4 bullet points) " + "with no preamble." + ), + ) +# + + +# +def _build_shell() -> LocalShellTool: + """A sandboxed shell, confined to the trade-confirmation vault. + + ``confine_workdir`` re-anchors every command to the vault, and the deny-list pre-filters + obviously destructive command shapes. (Patterns are a UX guardrail, not a security boundary — + for hard isolation use DockerShellTool.) Left at the default ``approval_mode="always_require"`` + so each command is surfaced for approval. + """ + return LocalShellTool( + mode="persistent", + workdir=str(_VAULT_DIR), + confine_workdir=True, + policy=ShellPolicy( + denylist=[ + r"\brm\s+-rf\b", + r"\bsudo\b", + r":\(\)\s*\{", # fork-bomb shape + r"\bmkfs\b", + r">\s*/dev/sd", + ], + ), + timeout=15, + ) +# + + +async def main() -> None: + load_dotenv() + _WORKING_DIR.mkdir(exist_ok=True) + + # + # Construct a chat client (see Post 1). FoundryChatClient reads FOUNDRY_PROJECT_ENDPOINT and + # FOUNDRY_MODEL from the environment; AzureCliCredential handles auth (run `az login`). + client = FoundryChatClient(credential=AzureCliCredential()) + # + + async with AsyncExitStack() as stack: + skills_provider = await _build_skills_provider(stack) + research_agent = _build_research_agent(client) + shell = _build_shell() + + # + # CodeAct: a sandboxed Python interpreter the model can write and run code in to crunch + # numbers. Monty is a pure, cross-platform interpreter, so it needs no extra setup. + context_providers: list[Any] = [MontyCodeActProvider(approval_mode="never_require")] + print("CodeAct enabled (Monty).") + # + + # + # Turn the chat client into a harness agent. On top of Post 2's file access and approvals we + # add the four "scaling" capabilities: skills (our own provider), background agents, a + # confined shell, and optional CodeAct. Read-only file tools are auto-approved so reading the + # portfolio is frictionless while writes, trades, and shell commands still prompt. + agent = create_harness_agent( + client=client, + agent_instructions=FINANCE_INSTRUCTIONS, + tools=[get_stock_price, place_trade], + file_access_store=FileSystemAgentFileStore(str(_WORKING_DIR)), + skills_provider=skills_provider, + background_agents=[research_agent], + shell_executor=shell, + auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule], + context_providers=context_providers, + mode_provider=AgentModeProvider(default_mode="execute"), + ) + # + + # + session = agent.create_session() + + # Run the interactive console session. The default planning observers already include a tool + # approval observer, so the place_trade and run_shell approval prompts are surfaced + # automatically. + await run_agent_async( + agent, + session=session, + observers=build_observers_with_planning(agent), + initial_mode="execute", + title="💹 Finance Assistant", + placeholder="Value a stock, score your portfolio risk, research tickers, or tidy your confirmations...", + ) + # + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/harness/build_your_own_claw/skills/risk-scoring/SKILL.md b/python/samples/02-agents/harness/build_your_own_claw/skills/risk-scoring/SKILL.md new file mode 100644 index 00000000000..3ced6ba1c5e --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/skills/risk-scoring/references/risk-bands.md b/python/samples/02-agents/harness/build_your_own_claw/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/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/skills/risk-scoring/scripts/risk_score.py b/python/samples/02-agents/harness/build_your_own_claw/skills/risk-scoring/scripts/risk_score.py new file mode 100644 index 00000000000..a449b03f164 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/skills/risk-scoring/scripts/risk_score.py @@ -0,0 +1,54 @@ +# 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 = [p for p in args.position if p > 0] + 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/skills/valuation/SKILL.md b/python/samples/02-agents/harness/build_your_own_claw/skills/valuation/SKILL.md new file mode 100644 index 00000000000..d84b57ebb14 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/skills/valuation/references/valuation-guide.md b/python/samples/02-agents/harness/build_your_own_claw/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/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/skills/valuation/scripts/valuation_metrics.py b/python/samples/02-agents/harness/build_your_own_claw/skills/valuation/scripts/valuation_metrics.py new file mode 100644 index 00000000000..f91fbacdb70 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/python/samples/02-agents/harness/build_your_own_claw/subprocess_script_runner.py b/python/samples/02-agents/harness/build_your_own_claw/subprocess_script_runner.py new file mode 100644 index 00000000000..1f8764a72d4 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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}" diff --git a/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/2025-06-21_nvda.txt b/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/2025-06-21_nvda.txt new file mode 100644 index 00000000000..6350a8f7d2a --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/SPY sell.txt b/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/SPY sell.txt new file mode 100644 index 00000000000..14305ca985c --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/conf_AAPL.txt b/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/conf_AAPL.txt new file mode 100644 index 00000000000..a4655401d92 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/copy of trade 3.txt b/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/copy of trade 3.txt new file mode 100644 index 00000000000..28fb391fd59 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/googl-jan.txt b/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/googl-jan.txt new file mode 100644 index 00000000000..d8c0470002d --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/trade confirmation 1.txt b/python/samples/02-agents/harness/build_your_own_claw/working/confirmations/trade confirmation 1.txt new file mode 100644 index 00000000000..2d3f305d85b --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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/python/samples/02-agents/harness/build_your_own_claw/working/portfolio.csv b/python/samples/02-agents/harness/build_your_own_claw/working/portfolio.csv new file mode 100644 index 00000000000..d2e0fbad5e9 --- /dev/null +++ b/python/samples/02-agents/harness/build_your_own_claw/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