diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1d03842..941ba5b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -Project memory for **Andes.Extensions.AI** — a C#/.NET solution that ships **`Andes.Extensions.AI`** (core), **`Andes.Extensions.AI.Mcp`** (MCP satellite), and **`Andes.Extensions.AI.Agent`** (Agent Framework satellite), NuGet packages of composable **Microsoft.Extensions.AI `IChatClient` middlewares**. It loads automatically every session and governs how Claude Code works in this repository. +Project memory for **Andes.Extensions.AI** — a C#/.NET solution that ships **`Andes.Extensions.AI`** (core), **`Andes.Extensions.AI.Mcp`** (MCP satellite), **`Andes.Extensions.AI.Agent`** (Agent Framework satellite), and **`Andes.Extensions.AI.UI`** (UI status-contract satellite), NuGet packages of composable **Microsoft.Extensions.AI `IChatClient` middlewares**. It loads automatically every session and governs how Claude Code works in this repository. ## What this project is @@ -10,6 +10,7 @@ Project memory for **Andes.Extensions.AI** — a C#/.NET solution that ships **` - Emits progress statuses ("Calling {Tool} Tool" headers with tool-reported subheaders like "Extracting…") **in-band** as `ChatProgressContent` items merged into the streaming response via a `Channel` pump, and **out-of-band** to `IChatProgressObserver` implementations. Tool authors report subheaders through the ambient `ChatProgress.Report(...)` API (AsyncLocal; safe no-op outside a tracked request). - Records token usage (input/output/total, model id, provider name from `ChatClientMetadata`) per request, per model turn (streaming), and per tool-call scope — including usage reported inside tools (`ChatProgress.ReportUsage`) and totals of nested tracked pipelines (AsyncLocal ambient scope tree) — rolled up into a `ChatUsageReport` (streaming: final `UsageReportContent` update; non-streaming: `ChatResponse.AdditionalProperties["andes.ai.usage_report"]`). - Numeric progress is first-class: `ChatProgress.Report(status, progress, progressTotal)` and `IChatProgressReporter.Report(status, progress, progressTotal)` (default interface method) populate `ChatProgressUpdate.Progress`/`ProgressTotal` (doubles, nullable). +- **Nested tool scopes**: `ChatProgress.BeginToolScope(descriptor, owner)` (returns a public `ChatProgressToolScope` handle, `Fail()`/`Dispose()`) opens a child scope on the ambient tracker so nested operations render as child activity cards and appear as child `ToolCallUsage` entries. Dedup is by scope **owner identity** (`ToolScope.IsOwnedBy` — reference equality plus the `GetService` probe chain): when the outer tracker already opened the scope for the same function, the call returns an inactive no-op. Both satellite wrappers (`AgentTrackingAIFunction`, `McpTrackingAIFunction`) call it in `InvokeCoreAsync`, so an agent/MCP tool nested inside another agent or invoked directly inside a tool body gets its own child card; a recursive self-invocation stays flat (documented limitation). CallId is taken from `FunctionInvokingChatClient.CurrentContext` only when the context's `Function` IS the owner. Static-only by design — not on `IChatProgressReporter` (captured reporters may run off-flow). - **MCP tools ship via the satellite package `Andes.Extensions.AI.Mcp`** (references `ModelContextProtocol.Core` only — the core package must stay MCP-free): `WithTracking(...)` wraps an `McpClientTool` in the internal `McpTrackingAIFunction` (carries the server display name, bridges MCP progress notifications into `ToolProgress` updates via a per-invocation `WithProgress` + `McpProgressBridge` that captures `ChatProgress.Current` at invocation time — never ambiently at report time, since notifications arrive on the MCP receive loop), and `UseMcpToolClassification()` installs a composing `ToolClassifier` (`GetService(typeof(McpClientTool))` probe; wrapper name > resolver > default). User `DelegatingAIFunction` wrappers are never deep-unwrapped for bridging. Late notifications are dropped best-effort — no completion gate. - **Agent-Framework-agents-as-tools ship via the satellite package `Andes.Extensions.AI.Agent`** (references `Microsoft.Agents.AI` only — core and Mcp must stay Agent-Framework-free): `WithTracking(this AIAgent, ...)` wraps `agent.AsAIFunction()` in the internal `AgentTrackingAIFunction : DelegatingAIFunction` (exposes the **original** agent via `GetService` — the framework's `AsAIFunction()` exposes neither the agent nor `AgentResponse.Usage`), and `UseAgentToolClassification()` installs a composing `ToolClassifier` (`GetService()` probe; resolver > agent name > function name for `DisplayName`; `Source` = agent name, else `Id`). Usage capture (`trackUsage: true` default) is an internal `UsageReportingAIAgent : DelegatingAIAgent` that calls `ChatProgress.ReportUsage(response.Usage)` after each successful run — **ambient resolution at run time is correct here** (agents run in-process on the caller's async flow; the inverse of the MCP bridge's capture-at-invocation). Pass `trackUsage: false` when the agent's own pipeline uses `UseToolTracking()` (nested rollup would double-count). Opt-in `reportFunctionCalls: true` uses the Agent Framework's function-invocation middleware to report `"Calling {Function} Tool"` statuses (names only; local function-invoking agents only — hosted agents throw). @@ -30,7 +31,8 @@ Privacy invariant: progress events and reports never carry prompt content, tool - `tests\Andes.Extensions.AI.Agent.Unit.Test\` — Agent satellite unit tests; no network. Links the core test infrastructure files; inner agents are real `ChatClientAgent`s built with `scriptedChatClient.AsAIAgent(...)`. - `tests\Andes.Extensions.AI.Agent.Integration.Test\` — Agent satellite Azure OpenAI tests; links `AzureOpenAIFixture.cs` and the shared gitignored `appsettings.integration.json`; the inner agent runs over a raw (untracked) chat client built from the fixture settings. - `tests\Andes.Extensions.AI.TestMcpServer\` — stdio MCP console server ("Andes Test MCP": `echo`, `add`, `count_down`) used by the MCP integration tests via `ProjectReference` + `dotnet `. -- `docs\` — developer documentation (getting-started, architecture, mcp, agents). +- `docs\` — developer documentation (getting-started, architecture, mcp, agents, ui). +- `releases\` — per-release notes (`v{version}.md`, matching the release-tag convention); a new file is required for every version bump. - Build infrastructure: `Directory.Build.props` (warnings as errors, C# 14, deterministic builds, XML docs required), `Directory.Packages.props` (**central package management — all versions live here**), `global.json` (SDK pin), `.editorconfig` (style rules; `CA2007` is an error in the library, off in tests via `tests\.editorconfig`). ## Project-specific conventions @@ -41,7 +43,7 @@ Privacy invariant: progress events and reports never carry prompt content, tool - Events and logs must never carry prompt content, tool arguments, or tool results. Tool-argument capture exists only behind `ToolTrackingOptions.IncludeToolArguments` (default `false`). - Public API changes require XML docs (missing docs fail the build) and a matching update under `docs\`. - Packaging metadata lives in each package's csproj; `dotnet pack -c Release` must produce the nupkg + snupkg with the README embedded (root README for core, each satellite's own `README.md` for the satellites). -- The three packages version in **lockstep** (all `0.2.0` today); each satellite's `ProjectReference` to core becomes a `>= {version}` NuGet dependency automatically. +- The four packages version in **lockstep** (all `0.3.0` today); each satellite's `ProjectReference` to core becomes a `>= {version}` NuGet dependency automatically. ## C# coding standards (always) @@ -131,6 +133,7 @@ The full guidelines live in `.claude/rules/` and **load automatically when you e - **After implementing or modifying C# code**, delegate a quality review to the `csharp-code-reviewer` subagent. It reports findings; it does not edit files. - **After creating or modifying GitHub Actions workflow files** (`.github/workflows/*.yml` or composite actions), delegate a review to the `github-actions-reviewer` subagent. It reports findings; it does not edit files. - **When a new feature is implemented, or implementation details need documenting**, delegate to the `se-technical-writer` subagent to author or update Markdown docs under `docs/`. +- **On every release change** (a version bump in the package csprojs or a new release tag), delegate to the `se-technical-writer` subagent to author `releases/v{version}.md` documenting what was added, changed, and fixed relative to the previous release. Every claim must trace to git history or current sources — never invent dates or features. ## Common commands diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml index c7273aa..25a4849 100644 --- a/.github/workflows/nuget.yml +++ b/.github/workflows/nuget.yml @@ -1,4 +1,5 @@ -# Publishes Andes.Extensions.AI, Andes.Extensions.AI.Mcp, and Andes.Extensions.AI.Agent +# Publishes Andes.Extensions.AI, Andes.Extensions.AI.Mcp, Andes.Extensions.AI.Agent, +# and Andes.Extensions.AI.UI # to nuget.org via Trusted Publishing (OIDC) — no stored API key. # # nuget.org Trusted Publishing policy match (configured on nuget.org): @@ -51,13 +52,14 @@ jobs: - name: Test run: dotnet test Andes.Extensions.slnx --configuration Release --no-build - # Pack ONLY the three shipping projects into a dedicated output directory. + # Pack ONLY the four shipping projects into a dedicated output directory. # (Test projects are packable too, so never pack the solution or glob bin/.) - name: Pack run: | dotnet pack Andes.Extensions.AI/Andes.Extensions.AI.csproj --configuration Release --no-build --output packages dotnet pack Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj --configuration Release --no-build --output packages dotnet pack Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj --configuration Release --no-build --output packages + dotnet pack Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj --configuration Release --no-build --output packages # For release runs, also require the packed version to match the release tag — # otherwise --skip-duplicate would turn an unbumped into a green no-op. @@ -67,15 +69,15 @@ jobs: run: | nupkg_count=$(ls packages/*.nupkg | wc -l) snupkg_count=$(ls packages/*.snupkg | wc -l) - if [ "$nupkg_count" -ne 3 ] || [ "$snupkg_count" -ne 3 ]; then - echo "Expected 3 .nupkg + 3 .snupkg, found $nupkg_count .nupkg / $snupkg_count .snupkg" >&2 + if [ "$nupkg_count" -ne 4 ] || [ "$snupkg_count" -ne 4 ]; then + echo "Expected 4 .nupkg + 4 .snupkg, found $nupkg_count .nupkg / $snupkg_count .snupkg" >&2 exit 1 fi if [ -n "$RELEASE_TAG" ]; then expected="${RELEASE_TAG#v}" - for pkg in Andes.Extensions.AI Andes.Extensions.AI.Mcp Andes.Extensions.AI.Agent; do + for pkg in Andes.Extensions.AI Andes.Extensions.AI.Mcp Andes.Extensions.AI.Agent Andes.Extensions.AI.UI; do if [ ! -f "packages/$pkg.$expected.nupkg" ]; then - echo "Release tag $RELEASE_TAG expects $pkg.$expected.nupkg — bump in the three csprojs" >&2 + echo "Release tag $RELEASE_TAG expects $pkg.$expected.nupkg — bump in the four csprojs" >&2 exit 1 fi done @@ -120,7 +122,7 @@ jobs: with: user: rorrorojas3 - # Quoted glob: NuGet expands it and pushes all three packages; matching + # Quoted glob: NuGet expands it and pushes all four packages; matching # .snupkg symbol packages are pushed automatically alongside each .nupkg. - name: Push packages env: diff --git a/.gitignore b/.gitignore index 73a558a..9ca6b57 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ mono_crash.* [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ +# ...but keep the top-level release notes folder +!/releases/ x64/ x86/ [Ww][Ii][Nn]32/ @@ -483,3 +485,6 @@ $RECYCLE.BIN/ # Local integration-test secrets (see appsettings.integration.sample.json) appsettings.integration.json + +# Local demo secrets (see samples/Andes.Extensions.AI.Demo/appsettings.sample.json) +samples/**/appsettings.json diff --git a/Andes.Extensions.AI.Agent/AgentToolTrackingExtensions.cs b/Andes.Extensions.AI.Agent/AgentToolTrackingExtensions.cs index a4d125e..edd67b0 100644 --- a/Andes.Extensions.AI.Agent/AgentToolTrackingExtensions.cs +++ b/Andes.Extensions.AI.Agent/AgentToolTrackingExtensions.cs @@ -28,6 +28,13 @@ public static class AgentToolTrackingExtensions /// through already surface beneath the agent's header /// without any configuration here. /// + /// Nested invocations open their own child scope: when the wrapped function is used as a tool + /// of another agent, or invoked directly inside a tool body, the agent renders as its own + /// child activity (with its usage attributed to that child) instead of flat sub-statuses on + /// the enclosing tool. A recursive self-invocation (the same wrapped function invoked from + /// within its own run) is indistinguishable from the tracker's own delegation and stays flat. + /// + /// /// Pass as when the agent's own chat /// client pipeline uses UseToolTracking(): the nested pipeline already rolls its total /// usage up into the calling tool's scope, and reporting on diff --git a/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj b/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj index ef15264..81f95d3 100644 --- a/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj +++ b/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj @@ -4,11 +4,12 @@ net10.0 Andes.Extensions.AI Andes.Extensions.AI.Agent - 0.2.0 + 0.3.0 Rodrigo Rojas Microsoft Agent Framework support for Andes.Extensions.AI tool tracking: classifies agents exposed as function tools as agent tools ("Calling {Agent} Agent"), attributes each agent run's token usage to the calling tool's scope, and optionally reports the agent's own function invocations as progress statuses. AI;IChatClient;Microsoft.Extensions.AI;AgentFramework;Microsoft.Agents.AI;agents;middleware;progress;tools MIT + icon.png README.md https://github.com/RorroRojas3/Enterprise.AI true @@ -19,6 +20,7 @@ + diff --git a/Andes.Extensions.AI.Agent/Internal/AgentTrackingAIFunction.cs b/Andes.Extensions.AI.Agent/Internal/AgentTrackingAIFunction.cs index 4311df8..18d4e18 100644 --- a/Andes.Extensions.AI.Agent/Internal/AgentTrackingAIFunction.cs +++ b/Andes.Extensions.AI.Agent/Internal/AgentTrackingAIFunction.cs @@ -5,13 +5,21 @@ namespace Andes.Extensions.AI; /// /// Wraps the function produced by AIAgent.AsAIFunction() so that classification can -/// discover the agent behind it through . +/// discover the agent behind it through , and so +/// that nested invocations open their own child tracking scope. /// /// /// The wrapper answers requests with the original agent — never the /// internal usage-reporting decorator — so callers can also probe for concrete agent types. /// A user's own around the wrapper still classifies as an /// agent through the probe chain; wrappers are never unwrapped or bypassed. +/// +/// When invoked while an ambient tracked scope is active that was not opened for this function +/// (the agent is a tool of another agent, or invoked directly inside a tool body), the wrapper +/// opens a child scope via — the agent then renders as +/// its own nested activity, and its usage and statuses attach to the child. When the tracking +/// middleware wrapped this function itself, the owner check makes the call a no-op. +/// /// internal sealed class AgentTrackingAIFunction(AIFunction function, AIAgent agent) : DelegatingAIFunction(function) { @@ -31,4 +39,31 @@ internal sealed class AgentTrackingAIFunction(AIFunction function, AIAgent agent ? Agent : base.GetService(serviceType, serviceKey); } + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + using ChatProgressToolScope scope = ChatProgress.BeginToolScope(CreateDescriptor(), owner: this); + try + { + return await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); + } + catch + { + scope.Fail(); + throw; + } + } + + private ToolDescriptor CreateDescriptor() + { + // Mirrors UseAgentToolClassification's descriptor; a nested scope cannot consult the + // tracker's ToolClassifier (or its displayNameResolver), so the agent name is used directly. + return new ToolDescriptor + { + Name = Name, + DisplayName = string.IsNullOrEmpty(Agent.Name) ? Name : Agent.Name, + Kind = ToolKind.Agent, + Source = string.IsNullOrEmpty(Agent.Name) ? Agent.Id : Agent.Name, + }; + } } diff --git a/Andes.Extensions.AI.Agent/README.md b/Andes.Extensions.AI.Agent/README.md index 77bc8c5..84fa05c 100644 --- a/Andes.Extensions.AI.Agent/README.md +++ b/Andes.Extensions.AI.Agent/README.md @@ -39,7 +39,7 @@ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync("pr { foreach (ChatProgressContent progress in update.Contents.OfType()) { - // e.g. "Calling Weather Agent Agent" (header), then any statuses the agent's tools report + // e.g. "Calling Weather Agent" (header), then any statuses the agent's tools report Console.WriteLine(progress.Progress.Message); } } @@ -47,6 +47,8 @@ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync("pr `WithTracking` carries the agent identity used for classification and attributes each run's usage to the calling tool's scope. Pass `trackUsage: false` when the agent's own chat pipeline uses `UseToolTracking()` — the nested pipeline already rolls its total usage up, and reporting `AgentResponse.Usage` on top would double-count. Pass `reportFunctionCalls: true` to additionally report a `"Calling {Function} Tool"` status each time the agent invokes one of its function tools (local function-invoking agents only; names only). +Nested agents render as their own activity: when a `WithTracking`-wrapped agent is a tool of another agent, or is invoked directly inside a tool body, the wrapper opens a child tracking scope — the run streams as a child activity under the enclosing tool (`ParentScopeId`/`Depth` on its progress events) and lands as a child `ToolCallUsage` with its own usage and duration in the `ChatUsageReport`. A tool the tracking middleware wrapped itself still opens exactly one scope, and report totals are unchanged either way. + ## Notes - A function created by a plain `agent.AsAIFunction()` call exposes neither its agent nor its usage; without `WithTracking` it classifies as a regular function tool. The same applies to agents hidden inside your own `DelegatingAIFunction` wrappers, which this package never unwraps or bypasses. diff --git a/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj b/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj index c6dcac3..ddac1ab 100644 --- a/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj +++ b/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj @@ -4,11 +4,12 @@ net10.0 Andes.Extensions.AI Andes.Extensions.AI.Mcp - 0.2.0 + 0.3.0 Rodrigo Rojas Model Context Protocol (MCP) support for Andes.Extensions.AI tool tracking: classifies McpClientTool instances as MCP tools ("Calling {Server} MCP") and bridges MCP progress notifications into chat progress updates with numeric progress values. AI;IChatClient;Microsoft.Extensions.AI;MCP;ModelContextProtocol;middleware;progress;tools MIT + icon.png README.md https://github.com/RorroRojas3/Enterprise.AI true @@ -19,6 +20,7 @@ + diff --git a/Andes.Extensions.AI.Mcp/Internal/McpTrackingAIFunction.cs b/Andes.Extensions.AI.Mcp/Internal/McpTrackingAIFunction.cs index 41adf98..067ec72 100644 --- a/Andes.Extensions.AI.Mcp/Internal/McpTrackingAIFunction.cs +++ b/Andes.Extensions.AI.Mcp/Internal/McpTrackingAIFunction.cs @@ -12,6 +12,13 @@ namespace Andes.Extensions.AI; /// A user's own around an MCP tool is never unwrapped or /// bypassed; such tools still classify as MCP through the /// probe but receive no progress bridge. +/// +/// When invoked while an ambient tracked scope is active that was not opened for this function +/// (the MCP tool is a tool of an agent, or invoked directly inside a tool body), the wrapper +/// opens a child scope via so the call renders as its +/// own nested activity; the progress bridge then binds to that child scope. When the tracking +/// middleware wrapped this function itself, the owner check makes the call a no-op. +/// /// internal sealed class McpTrackingAIFunction(McpClientTool tool, string serverName, bool enableProgress) : DelegatingAIFunction(tool) { @@ -30,17 +37,40 @@ internal sealed class McpTrackingAIFunction(McpClientTool tool, string serverNam protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) { - // Captured here, inside the tracking scope pushed by the core middleware, because MCP - // progress notifications arrive on the client receive loop where the ambient flow is absent. - IChatProgressReporter reporter = ChatProgress.Current; - if (!EnableProgress || !reporter.IsActive) + // Opened before the reporter capture below so a nested invocation's bridge binds to the + // child scope; for invocations the tracker already wrapped, this is an inactive no-op. + using ChatProgressToolScope scope = ChatProgress.BeginToolScope(CreateDescriptor(), owner: this); + try { - return await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); + // Captured here, inside the tracking scope, because MCP progress notifications arrive + // on the client receive loop where the ambient flow is absent. + IChatProgressReporter reporter = ChatProgress.Current; + if (!EnableProgress || !reporter.IsActive) + { + return await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); + } + + // WithProgress is per-invocation by design: the IProgress must capture this invocation's + // reporter, so a cached progress-enabled tool cannot exist. + McpClientTool progressTool = _tool.WithProgress(new McpProgressBridge(reporter)); + return await progressTool.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + } + catch + { + scope.Fail(); + throw; } + } - // WithProgress is per-invocation by design: the IProgress must capture this invocation's - // reporter, so a cached progress-enabled tool cannot exist. - McpClientTool progressTool = _tool.WithProgress(new McpProgressBridge(reporter)); - return await progressTool.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + private ToolDescriptor CreateDescriptor() + { + // Mirrors UseMcpToolClassification's descriptor; a nested scope cannot consult the + // tracker's ToolClassifier, so the constructed server name is used directly. + return new ToolDescriptor + { + Name = Name, + Kind = ToolKind.McpTool, + Source = ServerName, + }; } } diff --git a/Andes.Extensions.AI.Mcp/README.md b/Andes.Extensions.AI.Mcp/README.md index e4020a5..12a7373 100644 --- a/Andes.Extensions.AI.Mcp/README.md +++ b/Andes.Extensions.AI.Mcp/README.md @@ -44,6 +44,8 @@ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync("pr `WithTracking` carries the server display name (`ServerInfo.Title ?? ServerInfo.Name`, or an explicit string) and enables the progress bridge; pass `enableProgress: false` to keep the header but skip bridging. Raw `McpClientTool`s that skip `WithTracking` are still classified as MCP (falling back to `UseMcpToolClassification`'s default server name) but receive no progress bridge — the same applies to MCP tools hidden inside your own `DelegatingAIFunction` wrappers, which this package never unwraps or bypasses. +Nested calls render as their own activity: when a `WithTracking`-wrapped tool runs inside an agent, or is invoked directly inside another tool's body, the wrapper opens a child tracking scope — the call streams as a child activity under the enclosing tool, appears as a child `ToolCallUsage` in the `ChatUsageReport`, and bridged progress notifications bind to that child scope. A tool the tracking middleware wrapped itself still opens exactly one scope, and report totals are unchanged either way. + ## Notes - MCP servers report progress as single-precision floats; fractional values may show float→double widening artifacts (`33.3f` → `33.29999923706055`). Integer step counts are unaffected. diff --git a/Andes.Extensions.AI.UI/ActivityState.cs b/Andes.Extensions.AI.UI/ActivityState.cs new file mode 100644 index 0000000..ceaa3db --- /dev/null +++ b/Andes.Extensions.AI.UI/ActivityState.cs @@ -0,0 +1,23 @@ +namespace Andes.Extensions.AI; + +/// +/// Represents the lifecycle state of the assistant request or one of its activities, for driving +/// spinners, checkmarks, and error styling in a UI. +/// +public enum ActivityState +{ + /// + /// The work is still in progress. + /// + Running, + + /// + /// The work finished successfully. + /// + Completed, + + /// + /// The work failed. + /// + Failed, +} diff --git a/Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj b/Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj new file mode 100644 index 0000000..696d24e --- /dev/null +++ b/Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + Andes.Extensions.AI + Andes.Extensions.AI.UI + 0.3.0 + Rodrigo Rojas + UI status contract for Andes.Extensions.AI tool tracking: serializable records (and a matching TypeScript interface) that project the tracked chat stream into an assistant-activity hierarchy — the assistant's own functions, MCP tools, and agents, each with sub-statuses, nested children, and token usage — for rendering live progress in Blazor, a console, or any TypeScript SPA. + AI;IChatClient;Microsoft.Extensions.AI;UI;Blazor;TypeScript;progress;streaming;middleware;tools + MIT + icon.png + README.md + https://github.com/RorroRojas3/Enterprise.AI + true + true + true + snupkg + + + + + + + + + + + + + + + + + diff --git a/Andes.Extensions.AI.UI/AssistantActivity.cs b/Andes.Extensions.AI.UI/AssistantActivity.cs new file mode 100644 index 0000000..7dcbebb --- /dev/null +++ b/Andes.Extensions.AI.UI/AssistantActivity.cs @@ -0,0 +1,61 @@ +namespace Andes.Extensions.AI; + +/// +/// One activity the assistant performed while producing its answer — a function, an MCP tool, or an +/// agent — rendered as a card. Activities nest: an agent's own tools appear in . +/// +/// +/// The card label is (the clean name only) plus as a +/// separate badge. The contract deliberately never carries a pre-composed header such as +/// "Calling {name} MCP", so the kind word is never repeated and the label localizes cleanly. +/// +public sealed record AssistantActivity +{ + /// + /// Gets the identifier of the tracked scope this activity corresponds to, stable across all of + /// its sub-statuses and lifecycle events. + /// + public required string ScopeId { get; init; } + + /// + /// Gets the clean display name of the activity — the function, MCP server, or agent name — with + /// no "Calling" prefix and no kind word appended (for example "GetForecast", "Andes Test MCP", + /// or "Research Agent"). + /// + public required string DisplayName { get; init; } + + /// + /// Gets the category of the activity, for a badge or icon. + /// + public ToolKind Kind { get; init; } + + /// + /// Gets the origin of the activity — an MCP server name or agent identifier — when applicable. + /// + public string? Source { get; init; } + + /// + /// Gets whether the activity is still running, completed, or failed. + /// + public ActivityState State { get; init; } = ActivityState.Running; + + /// + /// Gets the elapsed time in seconds, set when the activity completes or fails. + /// + public double? DurationSeconds { get; init; } + + /// + /// Gets the sub-status lines reported while the activity ran, oldest first. + /// + public IReadOnlyList SubStatuses { get; init; } = []; + + /// + /// Gets the activities invoked inside this one — for example an agent's own tools. + /// + public IReadOnlyList Children { get; init; } = []; + + /// + /// Gets the token usage attributed to this activity (including its children), when known. + /// + public UsageSummary? Usage { get; init; } +} diff --git a/Andes.Extensions.AI.UI/AssistantStatusReducer.cs b/Andes.Extensions.AI.UI/AssistantStatusReducer.cs new file mode 100644 index 0000000..4d2b13f --- /dev/null +++ b/Andes.Extensions.AI.UI/AssistantStatusReducer.cs @@ -0,0 +1,161 @@ +namespace Andes.Extensions.AI; + +/// +/// Folds a sequence of deltas into successive immutable +/// values, reconstructing the activity hierarchy from the +/// events' and . +/// +/// +/// A reducer is stateful and single-consumer: feed it the events of one request in order (the core +/// stream already serializes them). It is the C# counterpart of the TypeScript +/// foldAssistantEvents function shipped with this package, so a Blazor app and a SPA render +/// the same tree from the same events. +/// +/// +/// +/// var reducer = new AssistantStatusReducer(); +/// await foreach (AssistantUiEvent uiEvent in events) +/// { +/// AssistantStatusSnapshot snapshot = reducer.Apply(uiEvent); +/// Render(snapshot); +/// } +/// +/// +public sealed class AssistantStatusReducer +{ + private readonly Dictionary _byScope = []; + private readonly List _roots = []; + private string? _assistantStatus; + private ActivityState _phase = ActivityState.Running; + private string? _text; + private UsageSummary? _usage; + + /// + /// Applies one event to the accumulated state and returns the resulting snapshot. + /// + /// The event to fold in. + /// An immutable snapshot reflecting every event applied so far. + /// is . + public AssistantStatusSnapshot Apply(AssistantUiEvent uiEvent) + { + ArgumentNullException.ThrowIfNull(uiEvent); + + switch (uiEvent.Kind) + { + case AssistantUiEventKind.Status: + _assistantStatus = uiEvent.Message; + break; + + case AssistantUiEventKind.ActivityStarted: + StartActivity(uiEvent); + break; + + case AssistantUiEventKind.ActivityProgress: + if (uiEvent.ScopeId is { } progressScope && _byScope.TryGetValue(progressScope, out MutableActivity? owner)) + { + owner.SubStatuses.Add(new SubStatus + { + Message = uiEvent.Message ?? string.Empty, + Progress = uiEvent.Progress, + ProgressTotal = uiEvent.ProgressTotal, + }); + } + + break; + + case AssistantUiEventKind.ActivityCompleted or AssistantUiEventKind.ActivityFailed: + if (uiEvent.ScopeId is { } finishScope && _byScope.TryGetValue(finishScope, out MutableActivity? finished)) + { + finished.State = uiEvent.Kind == AssistantUiEventKind.ActivityFailed + ? ActivityState.Failed + : ActivityState.Completed; + finished.DurationSeconds = uiEvent.DurationSeconds; + } + + break; + + case AssistantUiEventKind.TextDelta: + _text = (_text ?? string.Empty) + uiEvent.Text; + break; + + case AssistantUiEventKind.Finished: + _phase = ActivityState.Completed; + _usage = uiEvent.Usage; + break; + } + + return BuildSnapshot(); + } + + private void StartActivity(AssistantUiEvent uiEvent) + { + var activity = new MutableActivity + { + ScopeId = uiEvent.ScopeId ?? string.Empty, + DisplayName = uiEvent.DisplayName ?? uiEvent.Source ?? uiEvent.ScopeId ?? string.Empty, + Kind = uiEvent.ToolKind, + Source = uiEvent.Source, + }; + + if (uiEvent.ScopeId is { } scopeId) + { + _byScope[scopeId] = activity; + } + + // A top-level activity's parent is the request root, which has no card, so it becomes a root. + if (uiEvent.ParentScopeId is { } parentId && _byScope.TryGetValue(parentId, out MutableActivity? parent)) + { + parent.Children.Add(activity); + } + else + { + _roots.Add(activity); + } + } + + private AssistantStatusSnapshot BuildSnapshot() + { + return new AssistantStatusSnapshot + { + AssistantStatus = _assistantStatus, + Phase = _phase, + Activities = [.. _roots.Select(root => root.ToImmutable())], + Text = _text, + Usage = _usage, + }; + } + + private sealed class MutableActivity + { + public required string ScopeId { get; init; } + + public required string DisplayName { get; init; } + + public ToolKind Kind { get; init; } + + public string? Source { get; init; } + + public ActivityState State { get; set; } = ActivityState.Running; + + public double? DurationSeconds { get; set; } + + public List SubStatuses { get; } = []; + + public List Children { get; } = []; + + public AssistantActivity ToImmutable() + { + return new AssistantActivity + { + ScopeId = ScopeId, + DisplayName = DisplayName, + Kind = Kind, + Source = Source, + State = State, + DurationSeconds = DurationSeconds, + SubStatuses = [.. SubStatuses], + Children = [.. Children.Select(child => child.ToImmutable())], + }; + } + } +} diff --git a/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs b/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs new file mode 100644 index 0000000..687ea6c --- /dev/null +++ b/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs @@ -0,0 +1,40 @@ +namespace Andes.Extensions.AI; + +/// +/// An immutable snapshot of everything the assistant is doing at one point in a streamed request: +/// the top-level status line, the hierarchy of activity cards, any answer text so far, and the +/// final token usage. A UI binds to this and re-renders whenever a new snapshot arrives. +/// +/// +/// Produce snapshots with (folding +/// s) or with ChatResponseUiExtensions.ToStatusSnapshotsAsync. +/// The type is serialization-friendly via . +/// +public sealed record AssistantStatusSnapshot +{ + /// + /// Gets the current request-level status line, such as "Thinking…", or + /// before the first status arrives. + /// + public string? AssistantStatus { get; init; } + + /// + /// Gets the overall state of the request. + /// + public ActivityState Phase { get; init; } = ActivityState.Running; + + /// + /// Gets the top-level activity cards, one per activity the assistant started, in order. + /// + public IReadOnlyList Activities { get; init; } = []; + + /// + /// Gets the assistant's answer text accumulated so far, when any has streamed. + /// + public string? Text { get; init; } + + /// + /// Gets the total token usage for the request, set once it finishes. + /// + public UsageSummary? Usage { get; init; } +} diff --git a/Andes.Extensions.AI.UI/AssistantUiEvent.cs b/Andes.Extensions.AI.UI/AssistantUiEvent.cs new file mode 100644 index 0000000..056a3b0 --- /dev/null +++ b/Andes.Extensions.AI.UI/AssistantUiEvent.cs @@ -0,0 +1,95 @@ +namespace Andes.Extensions.AI; + +/// +/// A single, flat, serialization-friendly delta that an API flushes to a UI on each stream event. +/// The discriminator tells the UI which fields are meaningful, so the same shape +/// maps cleanly to a TypeScript discriminated union. +/// +/// +/// Fold a sequence of these into an with +/// , or consume them directly. Project them from a tracked chat +/// stream with ChatResponseUiExtensions.ToUiEventsAsync. Like the core progress contract, +/// events never carry prompt content, tool arguments, or tool results. +/// +public sealed record AssistantUiEvent +{ + /// + /// Gets what this event represents. + /// + public required AssistantUiEventKind Kind { get; init; } + + /// + /// Gets the message text for the event: the request status line for + /// , or the sub-status for + /// . + /// + public string? Message { get; init; } + + /// + /// Gets the identifier of the activity scope this event belongs to, when it targets an activity. + /// + public string? ScopeId { get; init; } + + /// + /// Gets the identifier of the parent scope, or when the activity is + /// top-level (its parent is the request root, which has no card). + /// + public string? ParentScopeId { get; init; } + + /// + /// Gets the display depth: 0 for request-level events, 1 for a top-level activity, 2 for a + /// sub-status, and deeper for nested activities. + /// + public int Depth { get; init; } + + /// + /// Gets the category of the activity this event targets, when applicable. + /// + public ToolKind ToolKind { get; init; } + + /// + /// Gets the clean display name of the activity (no "Calling" prefix, no kind word appended), + /// when the event targets an activity. + /// + public string? DisplayName { get; init; } + + /// + /// Gets the origin of the activity — an MCP server name or agent identifier — when applicable. + /// + public string? Source { get; init; } + + /// + /// Gets the numeric progress (the numerator) for an + /// event, when supplied. + /// + public double? Progress { get; init; } + + /// + /// Gets the total amount of work — the denominator for — when known. + /// + public double? ProgressTotal { get; init; } + + /// + /// Gets the elapsed time in seconds for a completion event, or the request duration for + /// . + /// + public double? DurationSeconds { get; init; } + + /// + /// Gets the answer text chunk for a event. + /// + public string? Text { get; init; } + + /// + /// Gets the token usage for a event. + /// + public UsageSummary? Usage { get; init; } + + /// + /// Gets the time at which the underlying progress event was raised. Only meaningful for + /// status and activity events; and + /// events, which have no source progress event, + /// leave it at its default. Consume events in stream order rather than sorting by this value. + /// + public DateTimeOffset Timestamp { get; init; } +} diff --git a/Andes.Extensions.AI.UI/AssistantUiEventKind.cs b/Andes.Extensions.AI.UI/AssistantUiEventKind.cs new file mode 100644 index 0000000..fec2314 --- /dev/null +++ b/Andes.Extensions.AI.UI/AssistantUiEventKind.cs @@ -0,0 +1,43 @@ +namespace Andes.Extensions.AI; + +/// +/// Identifies what a single represents in the assistant's activity +/// stream, so a UI can react to each flush without inspecting the rest of the payload. +/// +public enum AssistantUiEventKind +{ + /// + /// A request-level status line, such as "Thinking…" — carried by . + /// + Status, + + /// + /// An activity (a function, MCP tool, or agent) started; a new card should appear. + /// + ActivityStarted, + + /// + /// A sub-status reported while an activity runs; append it beneath the activity's card. + /// + ActivityProgress, + + /// + /// An activity finished successfully. + /// + ActivityCompleted, + + /// + /// An activity failed. + /// + ActivityFailed, + + /// + /// A chunk of the assistant's answer text, carried by . + /// + TextDelta, + + /// + /// The request finished; the total token is available. + /// + Finished, +} diff --git a/Andes.Extensions.AI.UI/AssistantUiJsonContext.cs b/Andes.Extensions.AI.UI/AssistantUiJsonContext.cs new file mode 100644 index 0000000..d40961f --- /dev/null +++ b/Andes.Extensions.AI.UI/AssistantUiJsonContext.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Andes.Extensions.AI; + +/// +/// A source-generated for the UI contract, configured to match +/// the shipped TypeScript interface: camelCase property names, enums serialized as strings, and +/// values omitted. Use it for trim- and AOT-safe serialization, including in +/// Blazor WebAssembly. +/// +/// +/// +/// string json = JsonSerializer.Serialize(uiEvent, AssistantUiJsonContext.Default.AssistantUiEvent); +/// AssistantStatusSnapshot? snapshot = JsonSerializer.Deserialize( +/// json, AssistantUiJsonContext.Default.AssistantStatusSnapshot); +/// +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(AssistantUiEvent))] +[JsonSerializable(typeof(AssistantStatusSnapshot))] +public sealed partial class AssistantUiJsonContext : JsonSerializerContext +{ +} diff --git a/Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs b/Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs new file mode 100644 index 0000000..a7497bc --- /dev/null +++ b/Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs @@ -0,0 +1,202 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI; + +/// +/// Projects a tracked chat stream (the in-band and +/// emitted by UseToolTracking(), plus answer text) into the +/// serializable UI contract: deltas and +/// snapshots. +/// +/// +/// These projections derive each activity's clean from +/// the raw tool/server/agent name rather than the composed progress header, so the kind word is +/// never repeated and the label localizes cleanly. +/// +public static class ChatResponseUiExtensions +{ + /// + /// Translates a tracked streaming response into a stream of + /// deltas — one per progress flush, per answer-text chunk, and one final + /// event. + /// + /// The tracked streaming response. + /// A token to cancel enumeration. + /// The projected UI events, in stream order. + /// is . + /// + /// + /// await foreach (AssistantUiEvent uiEvent in client + /// .GetStreamingResponseAsync(prompt, chatOptions) + /// .ToUiEventsAsync()) + /// { + /// await WriteServerSentEventAsync(uiEvent); + /// } + /// + /// + public static async IAsyncEnumerable ToUiEventsAsync( + this IAsyncEnumerable updates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(updates); + + await foreach (ChatResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + foreach (AIContent content in update.Contents) + { + switch (content) + { + case ChatProgressContent progress: + yield return progress.Progress.ToUiEvent(); + break; + + case UsageReportContent usage: + yield return ToFinishedEvent(usage.Report); + break; + } + } + + if (update.Text is { Length: > 0 } text) + { + yield return new AssistantUiEvent + { + Kind = AssistantUiEventKind.TextDelta, + Text = text, + }; + } + } + } + + /// + /// Translates a tracked streaming response into a stream of immutable + /// values by folding its events through an + /// — one snapshot per event. + /// + /// The tracked streaming response. + /// A token to cancel enumeration. + /// The successive snapshots, each reflecting every event so far. + /// is . + /// + /// The final snapshot reaches when the request finishes. + /// Request-level failure is not observable here: the core raises it out-of-band to observers + /// only, so a faulted request's last in-band snapshot stays at + /// rather than . + /// + public static async IAsyncEnumerable ToStatusSnapshotsAsync( + this IAsyncEnumerable updates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(updates); + + var reducer = new AssistantStatusReducer(); + await foreach (AssistantUiEvent uiEvent in updates.ToUiEventsAsync(cancellationToken).ConfigureAwait(false)) + { + yield return reducer.Apply(uiEvent); + } + } + + /// + /// Projects a single core progress update into an , deriving the + /// clean from the tool source or name. + /// + /// The progress update to project. + /// The equivalent UI event. + /// is . + public static AssistantUiEvent ToUiEvent(this ChatProgressUpdate update) + { + ArgumentNullException.ThrowIfNull(update); + + return new AssistantUiEvent + { + Kind = MapKind(update.Kind), + Message = update.Message, + ScopeId = update.ScopeId, + ParentScopeId = update.ParentScopeId, + Depth = update.Depth, + ToolKind = update.ToolKind, + DisplayName = update.ToolSource ?? update.ToolName, + Source = update.ToolSource, + Progress = update.Progress, + ProgressTotal = update.ProgressTotal, + DurationSeconds = update.Duration?.TotalSeconds, + Timestamp = update.Timestamp, + }; + } + + /// + /// Flattens Microsoft.Extensions.AI token usage into a serialization-friendly + /// . + /// + /// The usage details to flatten. + /// The flattened usage summary. + /// is . + public static UsageSummary ToUsageSummary(this UsageDetails usage) + { + ArgumentNullException.ThrowIfNull(usage); + + return new UsageSummary + { + InputTokens = usage.InputTokenCount, + OutputTokens = usage.OutputTokenCount, + TotalTokens = usage.TotalTokenCount, + }; + } + + /// + /// Folds a completed usage report into an , materializing + /// the per-activity tree (including nested children) with token usage attributed per activity. + /// + /// The final usage report. + /// A completed snapshot built from the report's tool-call tree. + /// is . + public static AssistantStatusSnapshot ToSnapshot(this ChatUsageReport report) + { + ArgumentNullException.ThrowIfNull(report); + + return new AssistantStatusSnapshot + { + Phase = ActivityState.Completed, + Activities = [.. report.ToolCalls.Select(ToActivity)], + Usage = report.TotalUsage.ToUsageSummary(), + }; + } + + private static AssistantUiEvent ToFinishedEvent(ChatUsageReport report) + { + return new AssistantUiEvent + { + Kind = AssistantUiEventKind.Finished, + Usage = report.TotalUsage.ToUsageSummary(), + DurationSeconds = report.Duration.TotalSeconds, + }; + } + + private static AssistantActivity ToActivity(ToolCallUsage call) + { + return new AssistantActivity + { + ScopeId = call.CallId ?? call.ToolName, + DisplayName = call.Source ?? call.ToolName, + Kind = call.Kind, + Source = call.Source, + State = call.Succeeded ? ActivityState.Completed : ActivityState.Failed, + DurationSeconds = call.Duration.TotalSeconds, + SubStatuses = [], + Children = [.. call.Children.Select(ToActivity)], + Usage = call.Usage?.ToUsageSummary(), + }; + } + + private static AssistantUiEventKind MapKind(ChatProgressKind kind) + { + return kind switch + { + ChatProgressKind.ToolInvoking => AssistantUiEventKind.ActivityStarted, + ChatProgressKind.ToolProgress => AssistantUiEventKind.ActivityProgress, + ChatProgressKind.ToolCompleted => AssistantUiEventKind.ActivityCompleted, + ChatProgressKind.ToolFailed => AssistantUiEventKind.ActivityFailed, + _ => AssistantUiEventKind.Status, + }; + } +} diff --git a/Andes.Extensions.AI.UI/README.md b/Andes.Extensions.AI.UI/README.md new file mode 100644 index 0000000..5012334 --- /dev/null +++ b/Andes.Extensions.AI.UI/README.md @@ -0,0 +1,49 @@ +# Andes.Extensions.AI.UI + +UI status contract for [Andes.Extensions.AI](https://www.nuget.org/packages/Andes.Extensions.AI) tool tracking. Turns the tracked chat stream into a serializable, cross-language shape a UI can render — the same contract in C# (console, Blazor WebAssembly) and TypeScript (any SPA). Adds two things on top of the core middleware: + +- **A serializable status contract** — flat per-flush `AssistantUiEvent` deltas and a folded `AssistantStatusSnapshot` (the assistant's status line plus a hierarchy of `AssistantActivity` cards — functions, MCP tools, and agents, each with sub-statuses, nested children, and token usage). Each activity carries a clean `DisplayName` plus a separate `Kind` badge, so the kind word is never repeated in the label. +- **A mapper and reducer** — `ToUiEventsAsync()`/`ToStatusSnapshotsAsync()` project the in-band `ChatProgressContent`/`UsageReportContent` stream into the contract; `AssistantStatusReducer` folds events into snapshots. A matching TypeScript `foldAssistantEvents` ships in the package (`typescript/andes-assistant-ui.ts`) so a SPA reconstructs the same tree. + +## Install + +```bash +dotnet add package Andes.Extensions.AI.UI +``` + +## Quickstart + +```csharp +using Andes.Extensions.AI; +using Microsoft.Extensions.AI; + +IChatClient client = innerClient + .AsBuilder() + .UseToolTracking() + .UseFunctionInvocation() // tracking must be registered before function invocation + .Build(); + +// Stream immutable snapshots and bind them to the UI (Blazor, console, ...). +await foreach (AssistantStatusSnapshot snapshot in client + .GetStreamingResponseAsync("prompt", chatOptions) + .ToStatusSnapshotsAsync()) +{ + Console.WriteLine(snapshot.AssistantStatus); + foreach (AssistantActivity activity in snapshot.Activities) + { + // e.g. "Andes Test MCP" [McpTool] — name and kind are separate, never "Andes Test MCP MCP" + Console.WriteLine($"{activity.DisplayName} [{activity.Kind}] — {activity.State}"); + } +} +``` + +For an HTTP surface, stream `ToUiEventsAsync()` instead and serialize each event with `AssistantUiJsonContext` (camelCase, string enums, nulls omitted) over server-sent events; the browser folds them with `foldAssistantEvents` from the shipped `.ts` file. + +## Notes + +- `DisplayName` is the raw function/server/agent name with no "Calling" prefix and no kind word appended; render it once and show `Kind` as a badge. The contract carries no pre-composed header strings, so labels localize cleanly. +- `AssistantUiJsonContext` matches the TypeScript interface byte-for-byte: camelCase keys, string enum values (`"McpTool"`, `"Agent"`, …), and omitted `null`s. +- Progress values from MCP servers are single-precision floats widened to `double`; format with a rounding specifier such as `"0.#"` before display. +- Privacy posture matches the core package: events and snapshots never carry prompt content, tool arguments, or tool results — only headers, statuses, names, and token counts. + +Full documentation lives in the [repository docs](https://github.com/RorroRojas3/Enterprise.AI/tree/main/docs). diff --git a/Andes.Extensions.AI.UI/SubStatus.cs b/Andes.Extensions.AI.UI/SubStatus.cs new file mode 100644 index 0000000..b216340 --- /dev/null +++ b/Andes.Extensions.AI.UI/SubStatus.cs @@ -0,0 +1,33 @@ +namespace Andes.Extensions.AI; + +/// +/// A single sub-status line reported while an activity runs — for example "Extracting…" or an MCP +/// server's "step 2 of 5" — optionally with numeric progress for a progress bar. +/// +/// +/// +/// var line = new SubStatus { Message = "Downloading", Progress = 2, ProgressTotal = 5 }; +/// +/// +public sealed record SubStatus +{ + /// + /// Gets the human-readable status message. + /// + public required string Message { get; init; } + + /// + /// Gets the numeric progress (the numerator), when the reporter supplied one. + /// + /// + /// Sources that report progress as single-precision values (for example MCP servers) may + /// produce float-to-double widening artifacts for fractional values; integer step counts are + /// unaffected. Format with a rounding specifier such as "0.#" before display. + /// + public double? Progress { get; init; } + + /// + /// Gets the total amount of work — the denominator for — when known. + /// + public double? ProgressTotal { get; init; } +} diff --git a/Andes.Extensions.AI.UI/UsageSummary.cs b/Andes.Extensions.AI.UI/UsageSummary.cs new file mode 100644 index 0000000..093e0b0 --- /dev/null +++ b/Andes.Extensions.AI.UI/UsageSummary.cs @@ -0,0 +1,29 @@ +namespace Andes.Extensions.AI; + +/// +/// A serialization-friendly, flattened view of token usage projected from +/// . Each count is nullable because a provider +/// may report only some of them. +/// +/// +/// +/// var summary = new UsageSummary { InputTokens = 120, OutputTokens = 45, TotalTokens = 165 }; +/// +/// +public sealed record UsageSummary +{ + /// + /// Gets the number of input (prompt) tokens, when reported. + /// + public long? InputTokens { get; init; } + + /// + /// Gets the number of output (completion) tokens, when reported. + /// + public long? OutputTokens { get; init; } + + /// + /// Gets the total number of tokens, when reported. + /// + public long? TotalTokens { get; init; } +} diff --git a/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts b/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts new file mode 100644 index 0000000..ad36bb7 --- /dev/null +++ b/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts @@ -0,0 +1,250 @@ +/** + * Andes.Extensions.AI.UI — TypeScript contract. + * + * A 1:1 mirror of the C# `Andes.Extensions.AI.UI` records, matching the JSON produced by + * `AssistantUiJsonContext` exactly: camelCase property names, string enum values, and omitted + * `null`s (so nullable members are optional here). Import these types in any SPA that consumes the + * assistant-status stream, and use `foldAssistantEvents` to reconstruct the same activity tree the + * C# `AssistantStatusReducer` builds. + * + * Token counts are `number` (JavaScript float64); typical usage values are well within safe range. + */ + +/** The category of an activity, for a badge or icon. Mirrors the core `ToolKind` enum. */ +export type ToolKind = "Unknown" | "Function" | "McpTool" | "Agent"; + +/** What a single {@link AssistantUiEvent} represents. */ +export type AssistantUiEventKind = + | "Status" + | "ActivityStarted" + | "ActivityProgress" + | "ActivityCompleted" + | "ActivityFailed" + | "TextDelta" + | "Finished"; + +/** The lifecycle state of the request or one of its activities. */ +export type ActivityState = "Running" | "Completed" | "Failed"; + +/** A flattened view of token usage. Each count is optional because a provider may report only some. */ +export interface UsageSummary { + /** The number of input (prompt) tokens, when reported. */ + inputTokens?: number; + /** The number of output (completion) tokens, when reported. */ + outputTokens?: number; + /** The total number of tokens, when reported. */ + totalTokens?: number; +} + +/** A single sub-status line reported while an activity runs, optionally with numeric progress. */ +export interface SubStatus { + /** The human-readable status message. */ + message: string; + /** The numeric progress (numerator), when supplied. */ + progress?: number; + /** The total amount of work (denominator for {@link progress}), when known. */ + progressTotal?: number; +} + +/** + * One activity the assistant performed — a function, MCP tool, or agent — rendered as a card. + * The label is {@link displayName} (clean name only) plus {@link kind} as a separate badge; there + * is never a pre-composed "Calling … MCP/Agent/Tool" string, so the kind word is not repeated. + */ +export interface AssistantActivity { + /** The tracked scope identifier, stable across all of this activity's events. */ + scopeId: string; + /** The clean display name (no "Calling" prefix, no kind word), e.g. "Andes Test MCP". */ + displayName: string; + /** The activity category, for a badge or icon. */ + kind: ToolKind; + /** The origin — an MCP server name or agent identifier — when applicable. */ + source?: string; + /** Whether the activity is still running, completed, or failed. */ + state: ActivityState; + /** The elapsed time in seconds, set when the activity completes or fails. */ + durationSeconds?: number; + /** The sub-status lines reported while the activity ran, oldest first. */ + subStatuses: SubStatus[]; + /** The activities invoked inside this one — for example an agent's own tools. */ + children: AssistantActivity[]; + /** The token usage attributed to this activity (including its children), when known. */ + usage?: UsageSummary; +} + +/** + * An immutable snapshot of everything the assistant is doing at one point in a streamed request. + * Bind to this and re-render whenever a new snapshot arrives. + */ +export interface AssistantStatusSnapshot { + /** The current request-level status line, such as "Thinking…". */ + assistantStatus?: string; + /** The overall state of the request. */ + phase: ActivityState; + /** The top-level activity cards, one per activity the assistant started, in order. */ + activities: AssistantActivity[]; + /** The assistant's answer text accumulated so far. */ + text?: string; + /** The total token usage for the request, set once it finishes. */ + usage?: UsageSummary; +} + +/** + * A flat, per-flush delta an API sends the UI on each stream event. The {@link kind} discriminator + * tells the UI which fields are meaningful. Fold a sequence with {@link foldAssistantEvents}. + */ +export interface AssistantUiEvent { + /** What this event represents. */ + kind: AssistantUiEventKind; + /** The status line ("Status") or the sub-status message ("ActivityProgress"). */ + message?: string; + /** The identifier of the activity scope this event targets, when applicable. */ + scopeId?: string; + /** The parent scope identifier, or absent when the activity is top-level. */ + parentScopeId?: string; + /** The display depth: 0 request-level, 1 top-level activity, 2 sub-status, deeper for nesting. */ + depth: number; + /** The category of the targeted activity, when applicable. */ + toolKind: ToolKind; + /** The clean display name of the activity (no "Calling" prefix, no kind word). */ + displayName?: string; + /** The origin — MCP server name or agent identifier — when applicable. */ + source?: string; + /** The numeric progress (numerator) for an "ActivityProgress" event, when supplied. */ + progress?: number; + /** The total amount of work (denominator for {@link progress}), when known. */ + progressTotal?: number; + /** The elapsed seconds for a completion event, or the request duration for "Finished". */ + durationSeconds?: number; + /** The answer text chunk for a "TextDelta" event. */ + text?: string; + /** The token usage for a "Finished" event. */ + usage?: UsageSummary; + /** The ISO 8601 time at which the underlying progress event was raised. */ + timestamp: string; +} + +/** Returns the empty starting snapshot to fold events into. */ +export function createInitialSnapshot(): AssistantStatusSnapshot { + return { phase: "Running", activities: [] }; +} + +/** + * Folds one {@link AssistantUiEvent} into a snapshot and returns a new snapshot, reconstructing the + * activity hierarchy from `scopeId`/`parentScopeId`. The C# counterpart is `AssistantStatusReducer`. + * + * @example + * let snapshot = createInitialSnapshot(); + * for (const event of events) { + * snapshot = foldAssistantEvents(snapshot, event); + * render(snapshot); + * } + */ +export function foldAssistantEvents( + snapshot: AssistantStatusSnapshot, + event: AssistantUiEvent, +): AssistantStatusSnapshot { + switch (event.kind) { + case "Status": + return { ...snapshot, assistantStatus: event.message }; + + case "ActivityStarted": { + const activity: AssistantActivity = { + scopeId: event.scopeId ?? "", + displayName: event.displayName ?? event.source ?? event.scopeId ?? "", + kind: event.toolKind, + source: event.source, + state: "Running", + subStatuses: [], + children: [], + }; + return { ...snapshot, activities: addActivity(snapshot.activities, event.parentScopeId, activity) }; + } + + case "ActivityProgress": + return { + ...snapshot, + activities: updateActivity(snapshot.activities, event.scopeId, (activity) => ({ + ...activity, + subStatuses: [ + ...activity.subStatuses, + { message: event.message ?? "", progress: event.progress, progressTotal: event.progressTotal }, + ], + })), + }; + + case "ActivityCompleted": + case "ActivityFailed": + return { + ...snapshot, + activities: updateActivity(snapshot.activities, event.scopeId, (activity) => ({ + ...activity, + state: event.kind === "ActivityFailed" ? "Failed" : "Completed", + durationSeconds: event.durationSeconds, + })), + }; + + case "TextDelta": + return { ...snapshot, text: (snapshot.text ?? "") + (event.text ?? "") }; + + case "Finished": + return { ...snapshot, phase: "Completed", usage: event.usage }; + + default: + return snapshot; + } +} + +function addActivity( + activities: AssistantActivity[], + parentScopeId: string | undefined, + activity: AssistantActivity, +): AssistantActivity[] { + // A top-level activity's parent is the request root, which has no card, so it becomes a root. + if (parentScopeId != null) { + const inserted = tryInsertUnder(activities, parentScopeId, activity); + if (inserted != null) { + return inserted; + } + } + return [...activities, activity]; +} + +function tryInsertUnder( + activities: AssistantActivity[], + parentScopeId: string, + activity: AssistantActivity, +): AssistantActivity[] | null { + let found = false; + const next = activities.map((current) => { + if (found) { + return current; + } + if (current.scopeId === parentScopeId) { + found = true; + return { ...current, children: [...current.children, activity] }; + } + const nested = tryInsertUnder(current.children, parentScopeId, activity); + if (nested != null) { + found = true; + return { ...current, children: nested }; + } + return current; + }); + return found ? next : null; +} + +function updateActivity( + activities: AssistantActivity[], + scopeId: string | undefined, + update: (activity: AssistantActivity) => AssistantActivity, +): AssistantActivity[] { + if (scopeId == null) { + return activities; + } + return activities.map((current) => + current.scopeId === scopeId + ? update(current) + : { ...current, children: updateActivity(current.children, scopeId, update) }, + ); +} diff --git a/Andes.Extensions.AI/Andes.Extensions.AI.csproj b/Andes.Extensions.AI/Andes.Extensions.AI.csproj index b9e182f..39fe433 100644 --- a/Andes.Extensions.AI/Andes.Extensions.AI.csproj +++ b/Andes.Extensions.AI/Andes.Extensions.AI.csproj @@ -4,11 +4,12 @@ net10.0 Andes.Extensions.AI Andes.Extensions.AI - 0.2.0 + 0.3.0 Rodrigo Rojas Middleware extensions for Microsoft.Extensions.AI: per-request and per-tool token usage tracking, and streaming status/progress propagation for IChatClient pipelines. AI;IChatClient;Microsoft.Extensions.AI;middleware;tokens;usage;streaming;progress;tools MIT + icon.png README.md https://github.com/RorroRojas3/Enterprise.AI true @@ -19,6 +20,7 @@ + diff --git a/Andes.Extensions.AI/Internal/RequestTracker.cs b/Andes.Extensions.AI/Internal/RequestTracker.cs index e2df818..47faf8a 100644 --- a/Andes.Extensions.AI/Internal/RequestTracker.cs +++ b/Andes.Extensions.AI/Internal/RequestTracker.cs @@ -73,10 +73,11 @@ public ToolScope BeginToolScope( ToolDescriptor descriptor, string? callId, ToolScope? parent, - IReadOnlyDictionary? arguments) + IReadOnlyDictionary? arguments, + AIFunction? owner = null) { ToolScope effectiveParent = parent ?? RootScope; - var scope = new ToolScope(this, NextScopeId(), effectiveParent, descriptor, callId, effectiveParent.Depth + 1); + var scope = new ToolScope(this, NextScopeId(), effectiveParent, descriptor, callId, effectiveParent.Depth + 1, owner); effectiveParent.AddChild(scope); Emit(new ChatProgressUpdate { @@ -336,13 +337,24 @@ public ChatUsageReport BuildReport() internal static string DefaultHeader(ToolDescriptor descriptor) { + // The kind label ("MCP", "Agent", "Tool") is appended only when the display name (or the + // MCP server source) does not already end with it, so a server named "Andes Test MCP" or + // an agent named "Research Agent" reads as "Calling Andes Test MCP" / "Calling Research + // Agent" instead of doubling the word. return descriptor.Kind switch { - ToolKind.McpTool => $"Calling {descriptor.Source ?? descriptor.DisplayName} MCP", - ToolKind.Agent => $"Calling {descriptor.DisplayName} Agent", - ToolKind.Function => $"Calling {descriptor.DisplayName} Tool", + ToolKind.McpTool => Compose(descriptor.Source ?? descriptor.DisplayName, "MCP"), + ToolKind.Agent => Compose(descriptor.DisplayName, "Agent"), + ToolKind.Function => Compose(descriptor.DisplayName, "Tool"), _ => $"Calling {descriptor.DisplayName}", }; + + static string Compose(string name, string suffix) + { + return name.TrimEnd().EndsWith(suffix, StringComparison.OrdinalIgnoreCase) + ? $"Calling {name}" + : $"Calling {name} {suffix}"; + } } private static ToolCallUsage BuildToolCallUsage(ToolScope scope) diff --git a/Andes.Extensions.AI/Internal/ToolScope.cs b/Andes.Extensions.AI/Internal/ToolScope.cs index b781bd2..34f5e7d 100644 --- a/Andes.Extensions.AI/Internal/ToolScope.cs +++ b/Andes.Extensions.AI/Internal/ToolScope.cs @@ -12,7 +12,8 @@ internal sealed class ToolScope( ToolScope? parent, ToolDescriptor? descriptor, string? callId, - int depth) + int depth, + AIFunction? owner = null) { private readonly Lock _lock = new(); private readonly List _children = []; @@ -33,6 +34,24 @@ internal sealed class ToolScope( public int Depth { get; } = depth; + /// + /// Gets the function this scope was opened for (the unwrapped tool the tracker delegated to), + /// or for the request root and for scopes with no known owner. + /// + public AIFunction? Owner { get; } = owner; + + /// + /// Determines whether this scope was opened for , either directly + /// or behind a user's chain (probed via + /// , the same convention the classifiers use). + /// + public bool IsOwnedBy(AIFunction candidate) + { + return Owner is { } owner + && (ReferenceEquals(owner, candidate) + || ReferenceEquals(owner.GetService(candidate.GetType()), candidate)); + } + public TimeSpan Duration { get; set; } public bool Succeeded { get; set; } = true; diff --git a/Andes.Extensions.AI/Progress/ChatProgress.cs b/Andes.Extensions.AI/Progress/ChatProgress.cs index 06fc809..eceb5dc 100644 --- a/Andes.Extensions.AI/Progress/ChatProgress.cs +++ b/Andes.Extensions.AI/Progress/ChatProgress.cs @@ -60,6 +60,81 @@ public static void Report(string status, double? progress, double? progressTotal } } + /// + /// Opens a nested tool-call scope on the ambient tracker, so that the operation renders as its + /// own child activity beneath the currently executing tool and any usage reported inside it + /// (via ) is attributed to the child in the final . + /// + /// + /// + /// Returns an inactive handle (a safe no-op) when no tracked request is active, or when the + /// ambient scope was already opened for — the case where the tracking + /// middleware wrapped the function itself — which prevents a duplicate scope for the same + /// invocation. Nested scopes never carry tool arguments. + /// + /// + /// This method is deliberately not exposed on : a captured + /// reporter may be invoked off the original async flow (for example, an MCP receive loop), + /// where pushing an ambient scope would have no effect. Dispose the returned handle on the + /// same async flow that opened it. + /// + /// + /// Describes how the nested activity is classified and displayed. + /// The ambient tracker's applies to the + /// header; its does not. + /// The function opening the scope, used to suppress a duplicate scope when + /// the tracker already opened one for this invocation and to correlate the scope with the + /// model's function call. Pass to always open a scope. + /// A handle that completes the scope when disposed; never . + /// is . + /// + /// + /// using ChatProgressToolScope scope = ChatProgress.BeginToolScope( + /// new ToolDescriptor { Name = "summarize", DisplayName = "Summarizer", Kind = ToolKind.Agent }); + /// try + /// { + /// ChatProgress.Report("Condensing findings…"); + /// // ... nested work ... + /// } + /// catch + /// { + /// scope.Fail(); + /// throw; + /// } + /// + /// + public static ChatProgressToolScope BeginToolScope(ToolDescriptor descriptor, AIFunction? owner = null) + { + ArgumentNullException.ThrowIfNull(descriptor); + + if (AmbientScope.Current is not { } ambient) + { + return ChatProgressToolScope.Inactive; + } + + if (owner is not null && ambient.IsOwnedBy(owner)) + { + return ChatProgressToolScope.Inactive; + } + + // The function-invocation call id is trusted only when the current invocation context is + // for the owner itself (a nested function-invoking loop); otherwise the context still + // describes the enclosing tool's call and would mis-correlate the child. + string? callId = null; + if (owner is not null + && FunctionInvokingChatClient.CurrentContext is { } context + && ReferenceEquals(context.Function, owner)) + { + callId = context.CallContent.CallId; + } + + RequestTracker tracker = ambient.Tracker; + ToolScope scope = tracker.BeginToolScope(descriptor, callId, ambient, arguments: null, owner); + long startTimestamp = tracker.Options.TimeProvider.GetTimestamp(); + AmbientScope.ScopeRestorer restorer = AmbientScope.Push(scope); + return new ChatProgressToolScope(scope, restorer, startTimestamp); + } + /// /// Attributes token usage (for example, from a nested SDK or agent call made inside the tool) /// to the currently executing tool's scope. diff --git a/Andes.Extensions.AI/Progress/ChatProgressToolScope.cs b/Andes.Extensions.AI/Progress/ChatProgressToolScope.cs new file mode 100644 index 0000000..67ec160 --- /dev/null +++ b/Andes.Extensions.AI/Progress/ChatProgressToolScope.cs @@ -0,0 +1,82 @@ +namespace Andes.Extensions.AI; + +/// +/// Represents a nested tool-call scope opened with +/// . +/// Disposing the handle completes the scope — emitting a completion (or failure) event with the +/// elapsed duration — and restores the previously ambient scope. +/// +/// +/// Dispose the handle on the same async flow that opened it, and dispose nested handles in +/// nesting order (the pattern guarantees both); disposing out of order +/// leaves a completed scope ambient, so later reports attach to a finished activity. A handle +/// that is never disposed leaves the scope open, and it is then reported as succeeded with no +/// duration. An inactive handle (returned when no tracked request is active, or when the +/// tracking middleware already opened a scope for the same invocation) is a safe no-op. +/// +public sealed class ChatProgressToolScope : IDisposable +{ + internal static readonly ChatProgressToolScope Inactive = new(); + + private readonly ToolScope? _scope; + private readonly AmbientScope.ScopeRestorer _restorer; + private readonly long _startTimestamp; + private bool _failed; + private int _disposed; + + private ChatProgressToolScope() + { + } + + internal ChatProgressToolScope(ToolScope scope, AmbientScope.ScopeRestorer restorer, long startTimestamp) + { + _scope = scope; + _restorer = restorer; + _startTimestamp = startTimestamp; + } + + /// + /// Gets a value indicating whether the handle is bound to an open tracking scope. + /// + public bool IsActive => _scope is not null; + + /// + /// Gets the identifier of the opened scope, or when the handle is inactive. + /// + public string? ScopeId => _scope?.ScopeId; + + /// + /// Marks the scope as failed, so that disposal emits a failure event instead of a completion. + /// Must be called before ; once the handle is disposed the completion + /// event has already been emitted and a later call has no effect. + /// + /// + /// No exception details are recorded — progress events never carry error messages, matching + /// the library's privacy invariant. + /// + public void Fail() + { + if (_scope is null) + { + return; + } + + _failed = true; + } + + /// + /// Completes the scope and restores the previously ambient scope. Idempotent — the completion + /// event is emitted exactly once, even under concurrent disposal. + /// + public void Dispose() + { + if (_scope is null || Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + TimeSpan elapsed = _scope.Tracker.Options.TimeProvider.GetElapsedTime(_startTimestamp); + _scope.Tracker.CompleteToolScope(_scope, elapsed, succeeded: !_failed); + _restorer.Dispose(); + } +} diff --git a/Andes.Extensions.AI/ToolTrackingOptions.cs b/Andes.Extensions.AI/ToolTrackingOptions.cs index c1f2c2b..aa2803f 100644 --- a/Andes.Extensions.AI/ToolTrackingOptions.cs +++ b/Andes.Extensions.AI/ToolTrackingOptions.cs @@ -59,7 +59,9 @@ public sealed class ToolTrackingOptions /// /// Gets or sets a callback that formats the header message for a tool invocation. /// When , the default produces "Calling {DisplayName} Tool" for functions, - /// "Calling {Source} MCP" for MCP tools, and "Calling {DisplayName} Agent" for agents. + /// "Calling {Source} MCP" for MCP tools, and "Calling {DisplayName} Agent" for agents. The kind + /// word is omitted when the name already ends with it (case-insensitive), so a server named + /// "Andes Test MCP" or an agent named "Research Agent" is not doubled. /// public Func? HeaderFormatter { get; set; } diff --git a/Andes.Extensions.AI/Tools/TrackingAIFunction.cs b/Andes.Extensions.AI/Tools/TrackingAIFunction.cs index 872203a..70ca822 100644 --- a/Andes.Extensions.AI/Tools/TrackingAIFunction.cs +++ b/Andes.Extensions.AI/Tools/TrackingAIFunction.cs @@ -23,7 +23,9 @@ public TrackingAIFunction(AIFunction innerFunction, ToolDescriptor descriptor, R { string? callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; ToolScope parent = AmbientScope.Current ?? _tracker.RootScope; - ToolScope scope = _tracker.BeginToolScope(_descriptor, callId, parent, CaptureArguments(arguments)); + // The unwrapped function is recorded as the scope's owner so a satellite wrapper invoking + // ChatProgress.BeginToolScope for the same invocation is deduplicated to a single scope. + ToolScope scope = _tracker.BeginToolScope(_descriptor, callId, parent, CaptureArguments(arguments), InnerFunction); long startTimestamp = _tracker.Options.TimeProvider.GetTimestamp(); using AmbientScope.ScopeRestorer restorer = AmbientScope.Push(scope); try diff --git a/Andes.Extensions.slnx b/Andes.Extensions.slnx index e5cd952..edf3099 100644 --- a/Andes.Extensions.slnx +++ b/Andes.Extensions.slnx @@ -1,4 +1,7 @@ + + + @@ -6,9 +9,11 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index f69a1e1..cc6184f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + diff --git a/README.md b/README.md index 60b1f50..a87d4c2 100644 --- a/README.md +++ b/README.md @@ -115,15 +115,68 @@ IChatClient client = innerClient var chatOptions = new ChatOptions { Tools = [weatherAgent.WithTracking()] }; ``` +Agents nest (v0.3): a `WithTracking`-wrapped agent used as a tool of another agent — or invoked directly inside a tool body — opens its own child scope, rendering live as a child activity card with its own statuses, duration, and token usage in the report: + +```text +✓ Research Agent agent 6.0s · 1,317 tok + ├── Calling SearchNotes Tool + ├── Searching notes… + ├── Calling Packing_Agent Tool + └── ✓ Packing Agent agent 2.4s · 504 tok + └── Checking essentials… +``` + +The same applies to nested MCP tools, and any tool can give a sub-operation its own child card with `ChatProgress.BeginToolScope(new ToolDescriptor { ... })`. + See [Agent support](docs/agents.md) for details. +## UI + +A serializable status contract for streaming progress to a UI ships as its own satellite package — a matching C# and TypeScript shape, so a Blazor app and a SPA render the same activity tree from the same JSON: + +```shell +dotnet add package Andes.Extensions.AI.UI +``` + +Stream `AssistantStatusSnapshot` instead of parsing `ChatResponseUpdate` yourself. Each activity carries a clean `DisplayName` plus a separate `Kind` badge — never a composed "Calling … MCP/Agent/Tool" string, so the kind word is never repeated: + +```csharp +await foreach (AssistantStatusSnapshot snapshot in client + .GetStreamingResponseAsync("prompt", chatOptions) + .ToStatusSnapshotsAsync()) +{ + foreach (AssistantActivity activity in snapshot.Activities) + { + Console.WriteLine($"{activity.DisplayName} [{activity.Kind}] — {activity.State}"); + } +} +``` + +For an HTTP surface, stream `ToUiEventsAsync()` instead and serialize each event with the package's `AssistantUiJsonContext` over server-sent events; a browser or Blazor client folds them with the shipped TypeScript `foldAssistantEvents` or the C# `AssistantStatusReducer`. See [UI support](docs/ui.md) for details. + +## Samples + +`samples/Andes.Extensions.AI.Demo` is an interactive console chat that exercises all four packages in one tracked pipeline and renders live activity — function/MCP/agent cards, progress bars, token usage — Claude-Code-style with Spectre.Console: + +```bash +cp samples/Andes.Extensions.AI.Demo/appsettings.sample.json samples/Andes.Extensions.AI.Demo/appsettings.json +# fill in the AzureOpenAI section, then: +dotnet run --project samples/Andes.Extensions.AI.Demo +``` + +See the [sample README](samples/Andes.Extensions.AI.Demo/README.md) for what each file demonstrates. + ## Documentation - [Getting started](docs/getting-started.md) - [Architecture](docs/architecture.md) - [MCP support](docs/mcp.md) - [Agent support](docs/agents.md) +- [UI support](docs/ui.md) - [Example: the Progress Board — every tool kind in one stream](docs/examples/progress-board.md) +- [Example: the UI contract, three ways](docs/examples/ui-contract.md) +- [Sample: the interactive demo console app](samples/Andes.Extensions.AI.Demo/README.md) +- [Release notes](releases/) ## License diff --git a/docs/agents.md b/docs/agents.md index e35d778..1ded05b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -2,12 +2,12 @@ `Andes.Extensions.AI.Agent` adds [Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/) support to the core [`Andes.Extensions.AI`](getting-started.md) tool-tracking middleware. The framework already lets any `AIAgent` act as a tool — [`AsAIFunction()`](https://learn.microsoft.com/dotnet/api/microsoft.agents.ai.aiagentextensions.asaifunction) turns an agent into an `AIFunction` another model can call — but that function is a plain product of `AIFunctionFactory.Create`: it exposes neither the agent behind it (nothing to discover via `GetService`) nor the run's `AgentResponse.Usage` (it returns only the response text). Without this package, an agent-as-tool classifies as an ordinary `ToolKind.Function` and reports zero token usage. This satellite package supplies both: -1. **Classification** — agents wrapped with `WithTracking()` are recognized as `ToolKind.Agent` and render with the core's `"Calling {Agent} Agent"` header in progress events and usage reports (the header and the `ToolKind.Agent` value have existed in core since v0.2, reserved for exactly this). +1. **Classification** — agents wrapped with `WithTracking()` are recognized as `ToolKind.Agent` and render with the core's `"Calling {Agent} Agent"` header in progress events and usage reports (the header and the `ToolKind.Agent` value have existed in core since v0.2, reserved for exactly this). The trailing `Agent` is dropped when the agent's name already ends with it, so `"Research Agent"` renders as `"Calling Research Agent"`, not `"Calling Research Agent Agent"`. 2. **Usage capture** — each run's `AgentResponse.Usage` is attributed to the calling tool's scope, so the agent's token consumption lands in the `ChatUsageReport` even when its pipeline cannot be instrumented. A third capability is opt-in: reporting the agent's own function calls as progress statuses — see [Seeing the agent's own function calls](#seeing-the-agents-own-function-calls). -This guide covers installation, how classification and usage capture work, the double-count interaction with nested tracked pipelines, and the package's deliberate limits. For the core middleware's design, see [Architecture](architecture.md); for core usage, see [Getting started](getting-started.md). +This guide covers installation, how classification and usage capture work, the double-count interaction with nested tracked pipelines, [nested agents](#nested-agents) (agents inside agents or inside tool bodies, rendering as child activities since v0.3), and the package's deliberate limits. For the core middleware's design, see [Architecture](architecture.md); for core usage, see [Getting started](getting-started.md). ## Prerequisites and installation @@ -18,7 +18,7 @@ This guide covers installation, how classification and usage capture work, the d dotnet add package Andes.Extensions.AI.Agent ``` -Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.2.0) and [`Microsoft.Agents.AI`](https://www.nuget.org/packages/Microsoft.Agents.AI) (>= 1.15.0, stable). +Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.3.0) and [`Microsoft.Agents.AI`](https://www.nuget.org/packages/Microsoft.Agents.AI) (>= 1.15.0, stable). ## Quickstart @@ -49,7 +49,7 @@ A typical rendering while the outer model delegates to the agent: ```text [RequestStarted] Starting request [Thinking] Thinking... - [ToolInvoking] Calling Weather Agent Agent + [ToolInvoking] Calling Weather Agent [ToolProgress] Calling GetWeather Tool [ToolProgress] Extracting... [ToolCompleted] Weather_Agent completed @@ -133,6 +133,58 @@ The outer model only ever sees the agent's final text — [by design](https://le The opt-in has a constraint, which is why it defaults to `false`: function-invocation middleware requires an agent whose pipeline performs **local** function invocation, such as a `ChatClientAgent` over an `IChatClient`. Hosted, service-side agents (Foundry agents, for example) run their tools server-side, and the framework throws `InvalidOperationException` when the middleware cannot find a `FunctionInvokingChatClient` to intercept. +Both paths surface **sub-statuses on the agent's scope** — plain function tools the agent invokes get no scope of their own. A tool that is itself a `WithTracking`-wrapped agent is different: it opens its own child scope and renders as a nested activity — see [Nested agents](#nested-agents). + +## Nested agents + +An agent does not have to be a top-level tool. Since v0.3, a `WithTracking` wrapper invoked while another tool's scope is ambient opens its **own child scope** (via the core's [`ChatProgress.BeginToolScope`](architecture.md#the-ambient-scope-tree)): the nested run renders live as its own child activity — a `ToolInvoking` header carrying `ParentScopeId`/`Depth`, its own sub-statuses, completion, and duration — and lands as a child `ToolCallUsage` (with its own usage, duration, and `Succeeded` flag) under the enclosing call in the `ChatUsageReport`. Previously such a run surfaced only flat: sub-statuses and usage on the enclosing tool's scope. Two shapes reach this path: + +**A — an agent as a tool of another agent.** The inner agent is registered as a tool of the outer agent, whose own function-invocation loop calls the wrapped function. Because `FunctionInvokingChatClient.CurrentContext` then describes the wrapper itself, the child scope is correlated with the **inner loop's function-call id** — `CallId` on the child's events is the inner model's call id. + +```csharp +AIFunction packing = packingAgent.WithTracking(); +AIAgent research = researchClient.AsAIAgent( + instructions: "You research travel topics. Consult the Packing Agent tool for packing advice.", + name: "Research Agent", + tools: [AIFunctionFactory.Create(SearchNotes), packing]); + +var chatOptions = new ChatOptions { Tools = [research.WithTracking(reportFunctionCalls: true)] }; +``` + +```text + [ToolInvoking] Calling Research Agent + [ToolProgress] Calling SearchNotes Tool + [ToolProgress] Searching notes… + [ToolProgress] Calling Packing_Agent Tool + [ToolInvoking] Calling Packing Agent ← child scope, CallId = inner loop's call id + [ToolProgress] Checking essentials… + [ToolCompleted] Packing Agent completed + [ToolCompleted] Research Agent completed +``` + +With `reportFunctionCalls: true` on the parent, **both** lines appear for the nested agent: the `"Calling Packing_Agent Tool"` sub-status (the middleware reporting the outer agent's function call, on the parent's scope) and the child card (the wrapper's own scope). They are complementary, not duplicates — turn off `reportFunctionCalls` to keep only the card. + +**B — an agent invoked directly inside a tool body.** User code calls the wrapped function itself from inside a plain function tool. There is no inner invocation loop for the wrapper, and the enclosing tool's `CurrentContext` would mis-correlate the child, so the child's `CallId` is **`null`** — the tree wiring (`ParentScopeId`/`Depth`) and usage attribution are unaffected. + +```csharp +AIFunction planTrip = AIFunctionFactory.Create( + async (string city, CancellationToken cancellationToken) => + { + object? advice = await packing.InvokeAsync( + new AIFunctionArguments { ["query"] = $"What should I pack for {city}?" }, + cancellationToken); + return $"Trip plan for {city}. Packing advice: {advice}"; + }, + "PlanTrip"); +``` + +The mechanics, in both shapes: + +- **Exactly one scope per invocation.** When the tracking middleware itself wrapped the function — the normal top-level registration on `ChatOptions.Tools` — the scope the tracker opened records the wrapper as its owner, and the wrapper's own `BeginToolScope` call is an inactive no-op (an owner-identity check that also traverses user `DelegatingAIFunction` chains via the `GetService` probe). Top-level behavior is byte-for-byte what it was before v0.3. +- **The [`trackUsage` matrix](#avoid-double-counting) is unchanged — it now applies at the child scope.** With the default `trackUsage: true` and an untracked inner pipeline, `AgentResponse.Usage` is attributed to the **child's** `ToolCallUsage`; with `trackUsage: false` and an untracked pipeline the child appears with `Usage = null`; a self-tracked inner pipeline rolls up into the child. Report totals are numerically invariant either way: the parent's rollup has always included its children, so `TotalUsage` and the parent's `Usage` match the old flat attribution exactly. +- **A failed nested run marks the child failed.** The child scope emits `ToolFailed` and its `ToolCallUsage.Succeeded` is `false`, while the enclosing tool decides for itself whether to catch and succeed. No exception details are recorded — the privacy invariant is unchanged. +- **Recursive self-invocation stays flat.** The same wrapped function invoked from within its own run is indistinguishable from the tracker's own delegation (same owner), so no child scope opens — a [known limitation](architecture.md#known-limitations-v03). + ## Wrappers and limits Classification requires the `WithTracking` wrapper. With `UseAgentToolClassification()` installed, the combinations are: @@ -154,7 +206,7 @@ Unchanged from the core: progress events and reports **never carry prompt conten Two test projects exercise the package against the real Agent Framework — the agent plumbing is never mocked: -- `tests\Andes.Extensions.AI.Agent.Unit.Test` — no network: inner agents are real `ChatClientAgent`s built with `scriptedChatClient.AsAIAgent(...)` over the linked `ScriptedChatClient` fake, driven end to end through the real tracked pipeline. The 20 tests cover classification and header rendering, usage attribution in both double-count directions, function-call reporting, and the in-process ambient flow that carries tool statuses out of the agent. +- `tests\Andes.Extensions.AI.Agent.Unit.Test` — no network: inner agents are real `ChatClientAgent`s built with `scriptedChatClient.AsAIAgent(...)` over the linked `ScriptedChatClient` fake, driven end to end through the real tracked pipeline. The 28 tests cover classification and header rendering, usage attribution in both double-count directions, function-call reporting, the in-process ambient flow that carries tool statuses out of the agent, and nested child scopes in both shapes (`NestedAgentScopeTests`: agent-inside-agent with the inner call id, direct invocation with a `null` call id, single-scope dedup, child failure, and the `trackUsage: false` child). - `tests\Andes.Extensions.AI.Agent.Integration.Test` — drives a real Azure OpenAI deployment with an agent as a tracked tool, end to end. Tests are `[SkippableFact]` and skip cleanly when configuration is missing. The project **links the same gitignored `appsettings.integration.json`** as the other integration projects — configure it once (copy the `.sample` file, fill in the `AzureOpenAI` section) and every integration project picks it up. Never environment variables. ```shell diff --git a/docs/architecture.md b/docs/architecture.md index e3fe34b..c063b0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,7 +34,7 @@ If the order is reversed, the function-invoking client executes the *unwrapped* Two smaller consequences of sitting outside the loop: -- Non-`AIFunction` tools (declarations the pipeline cannot invoke, hosted tools) pass through unwrapped; see [Known limitations](#known-limitations-v02). +- Non-`AIFunction` tools (declarations the pipeline cannot invoke, hosted tools) pass through unwrapped; see [Known limitations](#known-limitations-v03). - The tracker resolves `ChatClientMetadata` from the inner client to stamp `ProviderName` on the report and to fall back to the client's default model id when the provider does not report one. ## Streaming design: channel merge @@ -78,6 +78,8 @@ Progress and usage attribution both hang off a per-request **scope tree**, flowe - Each tool invocation opens a **child scope** (depth = parent + 1). The parent is *the scope ambient at invoke time*, which makes both concurrent tool invocations and nested tools safe without any bookkeeping in user code: parallel invocations each become independent children of the root, and a tool invoked from inside another tool becomes a grandchild. - `ChatProgress.Report(...)` inside a tool resolves the ambient scope and attaches the sub-status to *that* tool's scope, one level deeper than its header — no plumbing through tool signatures. - A **nested tracked pipeline** — a second `UseToolTracking()` pipeline run inside a tool, as an agent exposed as a function would — captures the ambient scope as its parent on entry, and on completion rolls its report's `TotalUsage` up into that tool's scope. This is the mechanism the [`Andes.Extensions.AI.Agent` satellite](agents.md) rides on — its `WithTracking(trackUsage: false)` opt-out exists precisely because this rollup already happens for self-tracked agents — and it works with no package beyond the core (covered by `NestedUsageAttributionTests`). +- **Opening a child scope is public API** (v0.3): `ChatProgress.BeginToolScope(descriptor, owner)` opens a real child scope on the ambient tracker and returns a disposable `ChatProgressToolScope` handle. The nested operation renders as its own child activity — a `ToolInvoking`/`ToolCompleted` pair carrying `ParentScopeId`/`Depth`, in-band and to observers — and lands as a child `ToolCallUsage` in the report. The [MCP](mcp.md#nested-mcp-tools) and [Agent](agents.md#nested-agents) satellite wrappers call it in `InvokeCoreAsync` with `owner: this`, which is how an agent or MCP tool invoked *inside* another tool gets its own card; any tool author can call it directly (a `null` owner always opens) to give a sub-operation its own child activity. `Fail()` before disposal records a failure — with no exception details, per the privacy posture. The method is static-only by design, deliberately absent from `IChatProgressReporter`: a captured reporter may run off the original async flow (the MCP receive loop, for example), where pushing an ambient scope would have no effect. +- **Owner-identity dedup keeps the single-level case unchanged.** `TrackingAIFunction` records the unwrapped function it delegates to as the scope's owner; `BeginToolScope` returns an inactive no-op handle when the ambient scope was already opened for the caller — checked by reference identity plus the same `GetService` probe chain the classifiers use, so a user's `DelegatingAIFunction` around a satellite wrapper still deduplicates. A tool the tracker wrapped itself therefore opens exactly one scope, byte-for-byte the pre-v0.3 behavior. The model's function-call id is trusted only when `FunctionInvokingChatClient.CurrentContext.Function` *is* the owner (a nested function-invoking loop, as when an inner agent's own loop calls the tool); a wrapped function invoked directly inside a tool body gets a `null` `CallId`. Scopes carry the `ToolDescriptor`, the model's `CallId` (correlated via `FunctionInvokingChatClient.CurrentContext`), duration, success flag, attributed usage, and children. Every `ChatProgressUpdate` exposes `ScopeId`/`ParentScopeId`/`Depth`, so consumers can reconstruct the tree without holding any state beyond the events themselves. `ToolProgress` events additionally carry optional numeric `Progress`/`ProgressTotal` values (v0.2) when the reporter supplied them — a tool calling the `ChatProgress.Report(status, progress, progressTotal)` overload, or a bridged MCP progress notification. @@ -91,6 +93,8 @@ Usage flows into the report from exactly three sources: | 2 | `ChatProgress.ReportUsage(...)` called inside a tool (for example, from an SDK call the tool makes) | That **tool's scope** | | 3 | A nested tracked pipeline completing inside a tool | That **tool's scope** (the nested report's `TotalUsage`) | +The sources are unchanged in v0.3, but sources 2 and 3 resolve the *innermost* ambient scope — which can now be a nested scope opened with `ChatProgress.BeginToolScope`, so usage reported inside a child is attributed to the child's `ToolCallUsage`. Totals are invariant either way: a parent's rollup has always been its own usage plus its children's recursive rollups, so `TotalUsage` and every per-call rollup are numerically identical to the flat attribution of earlier versions. + At report time each scope's own usage is combined with its children's rollups (a `ToolCallUsage.Usage` of `null` means nothing was attributed), and the request total is assistant usage plus the top-level tool rollups. All arithmetic uses nullable-aware addition (`UsageMath`): an absent token count means "not reported", never zero, and provider-specific `AdditionalCounts` are summed by key. The report shape: @@ -114,12 +118,14 @@ Delivery differs by call style. Streaming: the report arrives as the final `Usag Progress events and reports **never carry prompt content, tool arguments, or tool results**. The only opt-in is `ToolTrackingOptions.IncludeToolArguments` (default `false`), which populates `ChatProgressUpdate.Arguments` on `ToolInvoking` events with *stringified* argument values only — nothing else changes, and results remain excluded even then. Sub-status text passed to `ChatProgress.Report` is documented as display text and must not carry prompt content or tool results. -## Known limitations (v0.2) +## Known limitations (v0.3) - **Synthetic content does not serialize.** `ChatProgressContent` and `UsageReportContent` are not part of the `AIJsonUtilities` polymorphic serialization contract for `AIContent`. Call `StripProgressContent()` on responses (or individual messages) before persisting them into conversation history or serializing them. - **Non-`AIFunction` tools get best-effort headers only.** Tool declarations the pipeline cannot invoke are recognized purely by observing `FunctionCallContent` on the stream: they receive a `ToolInvoking` event (kind `ToolKind.Unknown`, parented to the root) but no completion event, duration, or usage attribution. - **`FunctionInvokingChatClient.AdditionalTools` are invisible.** The tracker only wraps tools found on `ChatOptions.Tools`; tools injected by the inner client's own configuration are executed unwrapped. - **Turn boundaries are a heuristic.** Streamed usage entries are keyed by an iteration counter that advances when a `FunctionResultContent` is observed. Providers that echo function results unusually, or interleave turns, may attribute usage to a neighboring turn — `AssistantUsage` and `TotalUsage` are unaffected either way. +- **A never-disposed `ChatProgressToolScope` leaves its scope open.** Dispose the handle on the same async flow that opened it (the `using` pattern); an undisposed handle emits no completion event, and the scope lands in the report as succeeded with no duration. +- **Recursive self-invocation stays flat.** A wrapped function invoked from within its own run presents the same owner as the tracker's own delegation, so the owner-identity dedup suppresses the child scope and the recursive call surfaces flat on the enclosing scope. ## Satellite packages @@ -127,6 +133,10 @@ MCP tools ("Calling {Server} MCP") shipped in v0.2 as the satellite package **`A Microsoft Agent Framework agents as tools ("Calling {Agent} Agent") shipped as the satellite package **`Andes.Extensions.AI.Agent`** — and as more than classification, because the framework's `AsAIFunction()` exposes neither the agent behind the function nor the run's `AgentResponse.Usage`: `WithTracking(...)` wraps the agent-as-function so `UseAgentToolClassification()` can recognize it (including inside user delegating chains) as `ToolKind.Agent` with the agent's name as `Source`, attributes each run's usage to the calling tool's scope (opt out with `trackUsage: false` for self-tracked agents, whose nested pipelines already roll up), and can optionally report the agent's own function calls as progress statuses. The tracking mechanics underneath — scope nesting, ambient reporting, nested-pipeline usage rollup — needed no changes, exactly as the nested-pipeline tests had demonstrated. See [Agent tool tracking](agents.md) for the classification precedence, the run-time ambient design (the inverse of the MCP bridge's capture-at-invocation), and the double-count matrix. +In v0.3 both satellite wrappers additionally open a child scope (`ChatProgress.BeginToolScope`, [above](#the-ambient-scope-tree)) when invoked inside another tracked tool, so nested agents and MCP tools render as their own child activities instead of flat sub-statuses — see [Nested agents](agents.md#nested-agents) and [Nested MCP tools](mcp.md#nested-mcp-tools). + +A serializable UI status contract shipped as the satellite package **`Andes.Extensions.AI.UI`** — not a new tracking capability, but a translation layer: `ToUiEventsAsync()`/`ToStatusSnapshotsAsync()` project the in-band `ChatProgressContent`/`UsageReportContent` stream into flat `AssistantUiEvent` deltas and folded `AssistantStatusSnapshot` trees (an `AssistantActivity` per function, MCP tool, or agent, with nested `Children` for tools invoked inside another — the same scope-tree shape this document describes above, made serializable). Every activity carries a clean `DisplayName` and a separate `Kind` badge rather than a composed header string, so a UI label never repeats the kind word (a server named "Andes Test MCP" is `DisplayName: "Andes Test MCP"` plus `Kind: McpTool`, never "Andes Test MCP MCP"). A `System.Text.Json` source-generated context and a byte-for-byte matching TypeScript file ship together, so a Blazor client and a hand-rolled SPA fold the identical JSON into the identical tree. See [UI status contract](ui.md) for the two DTO layers, the mapper, the reducer, and the shipped TypeScript file. + ## References The design is grounded in the official Microsoft.Extensions.AI documentation: diff --git a/docs/examples/progress-board.md b/docs/examples/progress-board.md index bcec6bb..270e7dc 100644 --- a/docs/examples/progress-board.md +++ b/docs/examples/progress-board.md @@ -3,7 +3,7 @@ This example wires **all three tool kinds** into one tracked `IChatClient` pipeline — a plain function tool (`ToolKind.Function`), MCP tools via [`Andes.Extensions.AI.Mcp`](../mcp.md) (`ToolKind.McpTool`), and a Microsoft Agent Framework agent-as-tool via [`Andes.Extensions.AI.Agent`](../agents.md) (`ToolKind.Agent`) — and shows how the merged stream propagates to a UI. Two things make it more than a bigger [quickstart](../getting-started.md): 1. **The app talks to the UI before the pipeline does.** The streaming service yields its own status messages through the same `IAsyncEnumerable` *before* `GetStreamingResponseAsync` is ever called, so "Connecting to tools…"-style lines and the pipeline's in-band events reach the consumer through one channel. -2. **A `ProgressBoard` folds the event stream into a hierarchy of boxes.** Built purely on the public `ChatProgressUpdate` contract, it turns each tool call into a separate box — a title (the header, e.g. "Calling Andes Test MCP MCP") plus subtitle lines (MCP numeric progress, the agent's inner tool statuses, function-reported statuses) — the shape a real UI would render as cards. +2. **A `ProgressBoard` folds the event stream into a hierarchy of boxes.** Built purely on the public `ChatProgressUpdate` contract, it turns each tool call into a separate box — a title (the header, e.g. "Calling Andes Test MCP") plus subtitle lines (MCP numeric progress, the agent's inner tool statuses, function-reported statuses) — the shape a real UI would render as cards. The five listings form a runnable console application. The MCP leg uses the in-repo stdio test server `tests\Andes.Extensions.AI.TestMcpServer` ("Andes Test MCP", with `echo`, `add`, and `count_down`, which streams one progress notification per step), so nothing external is needed beyond a model provider: the reader supplies `CreateProviderClient()` (any `IChatClient` — see [Getting started](../getting-started.md#end-to-end-with-azure-openai) for an Azure OpenAI version), and to run the MCP leg, build the test server and place its dll in (or point the stdio arguments at) the app's output directory, exactly as `Program.cs` does — a `ProjectReference` to the server project is the simplest way, the same technique the MCP integration tests use. @@ -217,7 +217,7 @@ public sealed class ProgressBox /// The scope identifier this box tracks (from ). public required string ScopeId { get; init; } - /// The box title — the tool header, e.g. "Calling Andes Test MCP MCP". + /// The box title — the tool header, e.g. "Calling Andes Test MCP". public required string Title { get; init; } /// The tool category, for a badge or icon. @@ -333,7 +333,7 @@ In this example, three different mechanisms land as subtitles — and every one - **The function tool's own `ChatProgress.Report(...)` calls** — "Contacting the forecast service…", then "Crunching the numbers…" with numeric progress, which `FormatSubtitle` renders as `(2/3)`. - **The MCP server's bridged `notifications/progress`** — `count_down` reports one notification per step, which the [progress bridge](../mcp.md#how-the-progress-bridge-works) turns into "step 1 of 3 (1/3)", "step 2 of 3 (2/3)", "step 3 of 3 (3/3)" under the server's header. -- **Inside the agent box, both status paths**: the `reportFunctionCalls: true` middleware's "Calling SearchDocs Tool" line, and the inner tool's own "Summarizing…" report, which flows out through the [in-process ambient flow](../agents.md#seeing-the-agents-own-function-calls) with no configuration at all. Neither creates a child box — the agent's inner pipeline is untracked, so no nested `ToolInvoking` scope opens; both surface as sub-statuses on the agent's scope. (The `Children`/`ParentScopeId` wiring earns its keep when a tool runs a [nested tracked pipeline](../architecture.md#the-ambient-scope-tree), whose tool calls do open child scopes.) +- **Inside the agent box, both status paths**: the `reportFunctionCalls: true` middleware's "Calling SearchDocs Tool" line, and the inner tool's own "Summarizing…" report, which flows out through the [in-process ambient flow](../agents.md#seeing-the-agents-own-function-calls) with no configuration at all. Both surface as sub-statuses on the agent's scope because `SearchDocs` is a plain function tool — it has no scope of its own. Give the agent a `WithTracking`-wrapped tool instead — [another agent](../agents.md#nested-agents), or an [MCP tool](../mcp.md#nested-mcp-tools) — and that call **does** create a child box (since v0.3): the satellite wrapper opens a real child scope, so a nested `ToolInvoking` arrives with `ParentScopeId` set and the board's existing wiring nests it, no board changes required. The same `Children`/`ParentScopeId` wiring also handles a tool running a [nested tracked pipeline](../architecture.md#the-ambient-scope-tree), whose tool calls open child scopes too. One deliberate omission: **the board has no locking**, and it does not need any — for in-band consumption. The core's channel pump serializes every event into stream order, and the `await foreach` consumes them one at a time on a single logical flow, so `Apply` is never called concurrently. An out-of-band `IChatProgressObserver` feeding the same board **would** need synchronization: observers are invoked from multiple threads, including the MCP receive loop — see [Ordering and threading](../mcp.md#ordering-and-threading). @@ -418,11 +418,11 @@ Condensed (the renderer reprints the board on every event; this is the two app-a ┌ Calling GetForecast Tool [done in 0.2s] │ Contacting the forecast service… │ Crunching the numbers… (2/3) -┌ Calling Andes Test MCP MCP [done in 0.8s] +┌ Calling Andes Test MCP [done in 0.8s] │ step 1 of 3 (1/3) │ step 2 of 3 (2/3) │ step 3 of 3 (3/3) -┌ Calling Research Agent Agent [done in 2.1s] +┌ Calling Research Agent [done in 2.1s] │ Calling SearchDocs Tool │ Summarizing… A day in Quito: sunny all week, countdown complete, and the old town ... @@ -437,7 +437,7 @@ Each box is separate, with its own title and its own subtitles — exactly the U - **MCP-bridged events can interleave.** Bridged `ToolProgress` notifications arrive from the MCP receive loop and are not ordered relative to request-path events — a notification can land anywhere between the tool's header and its completion. Harmless for the board in-band, but see [Ordering and threading](../mcp.md#ordering-and-threading) before adding observers. - **The agent's usage lands via `trackUsage: true`** (the default), which is correct here because the inner agent's pipeline is untracked. For a self-tracked agent, pass `trackUsage: false` or the tokens count twice — see [Avoid double counting](../agents.md#avoid-double-counting). - **Privacy invariant, unchanged.** Everything on the board is headers, statuses, and tool names — never prompt content, arguments, or results. The sole opt-in remains `ToolTrackingOptions.IncludeToolArguments` (default `false`) — see [Privacy posture](../architecture.md#privacy-posture). -- **Non-invocable tools become boxes that never complete.** Tool declarations the pipeline cannot invoke get a [best-effort `ToolInvoking` header](../architecture.md#known-limitations-v02) parented to the root — the board shows them as root boxes stuck in `Running`, with no subtitles, duration, or completion. A production board may want a timeout-based visual state for those. +- **Non-invocable tools become boxes that never complete.** Tool declarations the pipeline cannot invoke get a [best-effort `ToolInvoking` header](../architecture.md#known-limitations-v03) parented to the root — the board shows them as root boxes stuck in `Running`, with no subtitles, duration, or completion. A production board may want a timeout-based visual state for those. ## References diff --git a/docs/examples/ui-contract.md b/docs/examples/ui-contract.md new file mode 100644 index 0000000..6996483 --- /dev/null +++ b/docs/examples/ui-contract.md @@ -0,0 +1,326 @@ +# Example: The UI Contract, Three Ways + +This example takes one tracked `IChatClient` pipeline and shows the [`Andes.Extensions.AI.UI`](../ui.md) contract landing on three different consumer surfaces without changing shape at any hop: + +1. **An ASP.NET Core minimal API** streams `AssistantUiEvent` as JSON over server-sent events, serialized with the package's own `AssistantUiJsonContext`. +2. **A Blazor WebAssembly component** consumes that same stream directly in C# — no second serialization format, no hand-rolled DTOs — and folds it into `AssistantStatusSnapshot` with the same `AssistantStatusReducer` a console app would use. +3. **A TypeScript SPA** consumes the identical stream with the browser's native `EventSource`, `JSON.parse`s each payload into the shipped `AssistantUiEvent` interface, and folds it with `foldAssistantEvents` — the TypeScript twin of the C# reducer. + +The point isn't the tool-tracking pipeline itself — [Getting started](../getting-started.md) and the [Progress Board example](progress-board.md) already cover a pipeline with all three tool kinds side by side. The point is that **the wire format between step 1 and steps 2/3 is one small, versioned, serializable contract**, so a Blazor app and a hand-rolled SPA render the same activity tree from the same bytes. + +## A. The ASP.NET Core producer + +Any tracked pipeline works — this one keeps a single reporting function tool so the listing stays focused on the streaming plumbing, not the tools. Swap in MCP tools or an agent-as-tool exactly as the [Progress Board example](progress-board.md#the-pipeline) does; nothing downstream changes. + +```csharp +using Andes.Extensions.AI; +using Microsoft.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +// Register the UI contract's source-generated context so minimal APIs serialize AssistantUiEvent +// exactly the way AssistantUiJsonContext documents: camelCase, string enums, nulls omitted -- +// the same JSON the shipped TypeScript file and the Blazor client below both expect. +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AssistantUiJsonContext.Default)); + +builder.Services.AddSingleton(_ => +{ + IChatClient innerClient = CreateProviderClient(); // any IChatClient: Azure OpenAI, OpenAI, Ollama, ... + return innerClient + .AsBuilder() + .UseToolTracking() + .UseFunctionInvocation() // tracking before function invocation (core invariant) + .Build(); +}); + +WebApplication app = builder.Build(); + +app.MapGet("/chat/stream", (string prompt, IChatClient client, CancellationToken cancellationToken) => +{ + AIFunction forecast = AIFunctionFactory.Create( + (string city) => + { + ChatProgress.Report("Contacting the forecast service…"); + ChatProgress.Report("Crunching the numbers…", progress: 2, progressTotal: 3); + return $"Sunny in {city} all week."; + }, + "GetForecast"); + + var chatOptions = new ChatOptions { Tools = [forecast] }; + + IAsyncEnumerable events = client + .GetStreamingResponseAsync(prompt, chatOptions, cancellationToken) + .ToUiEventsAsync(cancellationToken); + + // TypedResults.ServerSentEvents (.NET 10) writes "event: assistant-ui\ndata: {...}\n\n" per item, + // using the JsonOptions configured above -- no manual header setting or line encoding needed. + return TypedResults.ServerSentEvents(events, eventType: "assistant-ui"); +}); + +app.Run(); + +static IChatClient CreateProviderClient() => + throw new NotImplementedException("Plug in your provider client (Azure OpenAI, OpenAI, Ollama, ...)."); +``` + +Three things to notice: + +- **`ToUiEventsAsync(cancellationToken)` is the only translation step.** The endpoint never touches `ChatProgressContent` or `UsageReportContent` directly — the mapper already turned them into flat `AssistantUiEvent` values in stream order. +- **One `ConfigureHttpJsonOptions` call wires the whole contract.** `TypedResults.ServerSentEvents` serializes each `SseItem.Data` with the `JsonOptions` resolved from `HttpContext.RequestServices`; inserting `AssistantUiJsonContext.Default` at the front of the `TypeInfoResolverChain` makes it use the contract's source-generated, trim-safe metadata instead of reflection. +- **The named SSE event (`"assistant-ui"`) lets consumers filter cheaply.** Both consumers below listen for that event name specifically, so a page that also multiplexes other SSE traffic (heartbeats, unrelated notifications) never has to inspect payloads it doesn't care about. + +## B. The Blazor WebAssembly consumer + +Two standalone components: a container that owns the stream and folds it, and a small recursive card that renders one `AssistantActivity` — including its own children, so nesting needs no per-depth markup. + +`AssistantStatusBoard.razor` opens the stream, feeds every event through an `AssistantStatusReducer`, and re-renders once per folded snapshot: + +```razor +@implements IAsyncDisposable +@inject HttpClient Http +@using System.Net.ServerSentEvents +@using System.Text.Json + +
+

@_snapshot.AssistantStatus

+ + @foreach (AssistantActivity activity in _snapshot.Activities) + { + + } + + @if (_snapshot.Text is { Length: > 0 } text) + { +

@text

+ } + + @if (_snapshot.Usage is { TotalTokens: { } tokens }) + { +

@tokens tokens total

+ } +
+ +@code { + [Parameter, EditorRequired] + public required string Prompt { get; set; } + + private readonly AssistantStatusReducer _reducer = new(); + private AssistantStatusSnapshot _snapshot = new(); + private CancellationTokenSource? _cts; + + protected override void OnInitialized() + { + _cts = new CancellationTokenSource(); + + // Fire-and-forget by design: the loop below drives StateHasChanged itself, and disposal + // (below) cancels the token that stops it -- the standard pattern for a streaming component. + _ = StreamAsync(_cts.Token); + } + + private async Task StreamAsync(CancellationToken cancellationToken) + { + // Blazor WebAssembly streams HttpClient responses by default as of .NET 10, so this reads + // the server-sent events incrementally rather than waiting for the whole response to buffer. + using Stream stream = await Http.GetStreamAsync( + $"chat/stream?prompt={Uri.EscapeDataString(Prompt)}", cancellationToken); + + SseParser parser = SseParser.Create( + stream, + (_, data) => JsonSerializer.Deserialize(data, AssistantUiJsonContext.Default.AssistantUiEvent)!); + + await foreach (SseItem item in parser.EnumerateAsync(cancellationToken)) + { + // One fold, one re-render, per event -- never per raw byte chunk the transport delivers. + _snapshot = _reducer.Apply(item.Data); + await InvokeAsync(StateHasChanged); + } + } + + public async ValueTask DisposeAsync() + { + if (_cts is not null) + { + await _cts.CancelAsync(); + _cts.Dispose(); + } + } +} +``` + +`ActivityCard.razor` is the recursive leaf — one component renders every depth of the tree, so there is exactly one place that knows how to draw a card: + +```razor +@* Renders one activity card, then its own Children with the same component -- the recursion is + what keeps a three-level agent-with-tools tree from needing three near-duplicate templates. *@ +@using System.Globalization + +
+
+ @Activity.DisplayName + @Activity.Kind + @if (Activity.DurationSeconds is { } seconds) + { + @seconds.ToString("0.0", CultureInfo.InvariantCulture)s + } +
+ + @if (Activity.SubStatuses.Count > 0) + { +
    + @foreach (SubStatus sub in Activity.SubStatuses) + { +
  • @FormatSubStatus(sub)
  • + } +
+ } + + @if (Activity.Children.Count > 0) + { +
+ @foreach (AssistantActivity child in Activity.Children) + { + + } +
+ } +
+ +@code { + [Parameter, EditorRequired] + public required AssistantActivity Activity { get; set; } + + private static string FormatSubStatus(SubStatus sub) + { + if (sub.Progress is not { } progress) + { + return sub.Message; + } + + string value = progress.ToString("0.#", CultureInfo.InvariantCulture); + return sub.ProgressTotal is { } total + ? $"{sub.Message} ({value}/{total.ToString("0.#", CultureInfo.InvariantCulture)})" + : $"{sub.Message} ({value})"; + } +} +``` + +Two design notes on rendering cost, the Blazor equivalent of an `OnPush` change-detection strategy: + +- **`AssistantStatusReducer` rebuilds the whole activity tree on every `Apply` call** (it folds into new immutable records, never mutates in place), so every `ActivityCard`'s `Activity` parameter is a new reference on every event — reference-equality change detection would gain nothing here, because it would never short-circuit. `ActivityCard` is a plain, stateless, presentational component instead: Blazor's own render-tree diffing already skips real DOM writes for markup that comes out identical, which is where the actual savings live for a card whose fields didn't change. +- **`StateHasChanged()` is called exactly once per folded snapshot**, not once per raw chunk the transport delivers or per `SseItem` field — `AssistantStatusBoard` is the only place that decides when to re-render, and it decides once per `AssistantUiEvent`. That single call re-renders the whole card tree in one pass, which is cheap because Blazor diffs virtual output, not because any single card was skipped. + +## C. The TypeScript SPA consumer + +The browser's built-in `EventSource` already understands the wire format the producer writes — no fetch-and-parse plumbing needed, and no dependency beyond the file the package ships: + +```typescript +import { + createInitialSnapshot, + foldAssistantEvents, + type AssistantUiEvent, + type AssistantStatusSnapshot, +} from "./andes-assistant-ui"; + +let snapshot: AssistantStatusSnapshot = createInitialSnapshot(); + +export function streamAssistantReply(prompt: string, render: (snapshot: AssistantStatusSnapshot) => void): void { + const source = new EventSource(`/chat/stream?prompt=${encodeURIComponent(prompt)}`); + + // The producer names its events "assistant-ui" (TypedResults.ServerSentEvents(events, eventType: + // "assistant-ui")); listening for that name specifically means unrelated SSE traffic on the same + // page -- heartbeats, other notifications -- never reaches this handler. + source.addEventListener("assistant-ui", (message: MessageEvent) => { + const event = JSON.parse(message.data) as AssistantUiEvent; + snapshot = foldAssistantEvents(snapshot, event); + render(snapshot); + + if (event.kind === "Finished") { + source.close(); + } + }); + + source.onerror = () => source.close(); +} +``` + +Rendering the tree is a small recursive function — the TypeScript counterpart of the Blazor `ActivityCard` recursion above, and of the console `ProgressBoard`'s `RenderBox` from the [Progress Board example](progress-board.md#what-the-user-sees): + +```typescript +import type { AssistantActivity, AssistantStatusSnapshot } from "./andes-assistant-ui"; + +export function renderSnapshot(snapshot: AssistantStatusSnapshot, root: HTMLElement): void { + root.innerHTML = ""; + + const status = document.createElement("p"); + status.className = "assistant-status-line"; + status.textContent = snapshot.assistantStatus ?? ""; + root.append(status); + + for (const activity of snapshot.activities) { + root.append(renderActivity(activity)); + } + + if (snapshot.text) { + const answer = document.createElement("p"); + answer.className = "assistant-answer"; + answer.textContent = snapshot.text; + root.append(answer); + } +} + +function renderActivity(activity: AssistantActivity): HTMLElement { + const card = document.createElement("div"); + card.className = `activity-card activity-card--${activity.state.toLowerCase()}`; + + const header = document.createElement("header"); + header.innerHTML = ` + ${activity.displayName} + ${activity.kind} + `; + card.append(header); + + for (const sub of activity.subStatuses) { + const line = document.createElement("div"); + line.className = "activity-substatus"; + line.textContent = + sub.progress != null + ? `${sub.message} (${sub.progress.toFixed(1)}${sub.progressTotal != null ? `/${sub.progressTotal}` : ""})` + : sub.message; + card.append(line); + } + + // Same recursion as the Blazor ActivityCard and the C# ProgressBoard's RenderBox: one function + // renders every depth, so an agent's nested tool calls need no special-casing. + for (const child of activity.children) { + card.append(renderActivity(child)); + } + + return card; +} +``` + +## What each consumer needed, and what it didn't + +| Consumer | Serialization | Reconstructs the tree with | Never had to | +| --- | --- | --- | --- | +| ASP.NET Core minimal API (producer) | `AssistantUiJsonContext` via `ConfigureHttpJsonOptions` | N/A — only emits flat events | Parse `ChatResponseUpdate`, know about `ChatProgressContent`/`UsageReportContent`, or hand-write SSE framing | +| Blazor WebAssembly | `System.Text.Json` + `AssistantUiJsonContext.Default.AssistantUiEvent` (same context, client side) | `AssistantStatusReducer` (C#) | Write a second parser for the same JSON shape, or hand-roll SSE framing (`SseParser` from `System.Net.ServerSentEvents` does it) | +| TypeScript SPA | `JSON.parse` (the shape matches `AssistantUiEvent` by construction) | `foldAssistantEvents` (the shipped `.ts`) | Reimplement the fold logic, or agree on a JSON shape by hand — the interface and the reducer both ship with the package | + +## Notes + +- **Privacy invariant, unchanged.** Every field on the wire is a header-turned-name, a status, activity metadata, or a token count — never prompt content, tool arguments, or tool results. See [UI: Privacy posture](../ui.md#privacy-posture). +- **The Blazor client and the TypeScript client consume literally the same bytes.** Neither is a "primary" implementation the other approximates — both read the identical `AssistantUiJsonContext`-produced JSON over the identical named SSE event, which is the entire point of shipping one contract instead of one C# shape and a hand-maintained TypeScript approximation of it. +- **Keep the shipped `.ts` file in step with the NuGet package version it came from.** The file travels inside the package (`typescript/andes-assistant-ui.ts`) rather than as a separate npm package specifically so a frontend and a backend on different versions don't silently drift — see [UI: The TypeScript file](../ui.md#the-typescript-file). +- **A production endpoint needs the usual hardening this example skips** — authentication on `/chat/stream`, a request size/time limit, and reconnect handling on the client (`EventSource` retries automatically with the `retry:` field; a Blazor client restarting a dropped stream needs to do so explicitly). None of that changes the contract itself. + +## References + +- [UI status contract](../ui.md) — the two DTO layers, the mapper, the reducer, and the clean-name design +- [Getting started](../getting-started.md) — the core pipeline and `ChatProgress.Report` +- [Example: the Progress Board](progress-board.md) — the same contract's console-rendering ancestor, and a pipeline with all three tool kinds side by side +- [What's new in ASP.NET Core in .NET 10](https://learn.microsoft.com/aspnet/core/release-notes/aspnetcore-10.0) — native `TypedResults.ServerSentEvents` support for minimal APIs +- [`SseParser`](https://learn.microsoft.com/dotnet/api/system.net.serversentevents.sseparser-1) — the BCL client-side SSE parser used by the Blazor component +- [`EventSource` (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) — the browser API the TypeScript consumer uses +- [Blazor WebAssembly streaming HTTP responses](https://learn.microsoft.com/dotnet/core/compatibility/networking/10.0/default-http-streaming) — enabled by default as of .NET 10 diff --git a/docs/getting-started.md b/docs/getting-started.md index 36bf3a9..229b071 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -243,7 +243,7 @@ All options live on `ToolTrackingOptions`, configured via the `UseToolTracking(o | `IncludeToolArguments` | `bool` | `false` | Include stringified tool arguments in `ChatProgressUpdate.Arguments` on `ToolInvoking` events. Off by default: progress events never carry prompt content, tool arguments, or tool results unless explicitly opted in. | | `Observers` | `IList` | empty | Out-of-band observers notified of every progress event and request completion. DI-registered observers are appended automatically by `UseToolTracking`. | | `ToolClassifier` | `Func?` | `null` | Classifies a tool into a `ToolDescriptor`. Default: `AIFunction` tools become `ToolKind.Function`, everything else `ToolKind.Unknown` — exposed as `ToolDescriptor.CreateDefault` for custom classifiers to fall back on. The [`Andes.Extensions.AI.Mcp` package](mcp.md) installs an MCP-aware classifier via `UseMcpToolClassification()`; the [`Andes.Extensions.AI.Agent` package](agents.md) installs an agent-aware classifier via `UseAgentToolClassification()`. | -| `HeaderFormatter` | `Func?` | `null` | Formats tool headers. Default: "Calling {DisplayName} Tool" for functions, "Calling {Source} MCP" for [MCP tools](mcp.md), "Calling {DisplayName} Agent" for [agent tools](agents.md). | +| `HeaderFormatter` | `Func?` | `null` | Formats tool headers. Default: "Calling {DisplayName} Tool" for functions, "Calling {Source} MCP" for [MCP tools](mcp.md), "Calling {DisplayName} Agent" for [agent tools](agents.md) — the kind word is dropped when the name already ends with it (case-insensitive), so "Andes Test MCP" or "Research Agent" is not doubled. | | `TimeProvider` | `TimeProvider` | `TimeProvider.System` | Clock for timestamps and durations; replace with a fake in tests for deterministic timing. | ## End-to-end with Azure OpenAI diff --git a/docs/mcp.md b/docs/mcp.md index ad131cc..1c8ef74 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -16,7 +16,7 @@ This guide covers installation, how classification and the progress bridge work, dotnet add package Andes.Extensions.AI.Mcp ``` -Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.2.0) and [`ModelContextProtocol.Core`](https://www.nuget.org/packages/ModelContextProtocol.Core) (>= 1.4.1). Apps that build MCP clients or servers with the full `ModelContextProtocol` package are unaffected — the satellite only needs the Core types. +Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.3.0) and [`ModelContextProtocol.Core`](https://www.nuget.org/packages/ModelContextProtocol.Core) (>= 1.4.1). Apps that build MCP clients or servers with the full `ModelContextProtocol` package are unaffected — the satellite only needs the Core types. ## Quickstart @@ -122,6 +122,10 @@ Bridging is independent of classification: a `WithTracking` wrapper in a pipelin Bridged `ToolProgress` events arrive on a different thread than the request path and are **not ordered** relative to request-path events: a notification can interleave anywhere between the tool's `ToolInvoking` header and its `ToolCompleted` event. `IChatProgressObserver` implementations must be thread-safe — already the documented observer contract. Notifications are also fire-and-forget on the server side and race the tool result, so a late notification arriving as the request completes is dropped best-effort: the in-band write to the completed channel is a no-op, though an observer may still see one late event. Real progress-reporting tools are long-running, so the race is invisible in practice. +## Nested MCP tools + +Since v0.3, a `WithTracking` wrapper invoked while another tool's scope is ambient — an MCP tool given to an agent as one of its tools, or invoked directly inside a plain tool's body — opens its **own child scope** (via the core's [`ChatProgress.BeginToolScope`](architecture.md#the-ambient-scope-tree)) instead of surfacing flat on the enclosing tool: the call renders live as its own child activity (`ToolInvoking` with `ParentScopeId`/`Depth`, completion, duration) and lands as a child `ToolCallUsage` under the enclosing call in the report. The wrapper opens the child scope **before** capturing the reporter, so the [progress bridge](#how-the-progress-bridge-works) binds to the child — bridged notifications land under the nested tool's own header, not the enclosing tool's. When the tracking middleware wrapped the function itself (the normal top-level registration), the owner check makes the call an inactive no-op — exactly one scope, the pre-v0.3 behavior. A nested call invoked directly from a tool body carries a `null` `CallId` (there is no function-invoking loop for the wrapper to correlate with); an agent's own invocation loop calling the tool supplies its call id. + ## Numeric progress values Core v0.2 adds two optional fields to `ChatProgressUpdate`, populated only on `ToolProgress` events whose reporter supplied values: @@ -160,7 +164,7 @@ Unchanged from the core: progress events and reports **never carry prompt conten Three test projects exercise the package against real MCP plumbing — the protocol is never mocked: -- `tests\Andes.Extensions.AI.Mcp.Unit.Test` — no network or child processes: the `Infrastructure\InMemoryMcpFixture` hosts a genuine MCP client/server pair over in-process pipe streams, so tests classify and invoke real `McpClientTool` instances and observe real bridged notifications (including the synthesized-message fallback and the completion race). +- `tests\Andes.Extensions.AI.Mcp.Unit.Test` — no network or child processes: the `Infrastructure\InMemoryMcpFixture` hosts a genuine MCP client/server pair over in-process pipe streams, so tests classify and invoke real `McpClientTool` instances and observe real bridged notifications (including the synthesized-message fallback, the completion race, and — `NestedMcpScopeTests` — child scopes with the bridge bound to them). - `tests\Andes.Extensions.AI.TestMcpServer` — a runnable stdio MCP server ("Andes Test MCP") with three deterministic tools: `echo`, `add`, and `count_down`, which reports one progress notification per step. Useful for manual experiments as well as the integration tests. - `tests\Andes.Extensions.AI.Mcp.Integration.Test` — drives a real Azure OpenAI deployment against the stdio test server, end to end through the tracked pipeline. Tests are `[SkippableFact]` and skip cleanly when configuration is missing. The project **links the same gitignored `appsettings.integration.json`** as `tests\Andes.Extensions.AI.Integration.Test` — configure it once (copy the `.sample` file, fill in the `AzureOpenAI` section) and both integration projects pick it up. Never environment variables. diff --git a/docs/ui.md b/docs/ui.md new file mode 100644 index 0000000..53304d4 --- /dev/null +++ b/docs/ui.md @@ -0,0 +1,173 @@ +# UI Status Contract + +`Andes.Extensions.AI.UI` adds a serializable, cross-language status contract on top of the core [`Andes.Extensions.AI`](getting-started.md) tool-tracking middleware. The core middleware already streams everything a UI needs — request-level statuses, tool headers, sub-statuses, and a final usage report — in-band as `ChatProgressContent`/`UsageReportContent` items inside the `ChatResponseUpdate` stream. But that stream is shaped for a .NET consumer holding a reference to `Microsoft.Extensions.AI` types, not for an API boundary: an ASP.NET endpoint streaming JSON to a browser, or a Blazor WebAssembly component, needs a shape it can serialize and a TypeScript type it can share. This satellite package supplies both: + +1. **A serializable contract** — flat per-flush `AssistantUiEvent` deltas and a folded `AssistantStatusSnapshot` (the request's status line plus a hierarchy of `AssistantActivity` cards — functions, MCP tools, and agents, each with sub-statuses, nested children, and token usage). Every activity carries a clean `DisplayName` plus a separate `Kind` badge — there is no pre-composed "Calling … MCP/Agent/Tool" string anywhere in the contract, so the kind word is never repeated. See [Clean names, not composed headers](#clean-names-not-composed-headers). +2. **A mapper and a reducer** — `ToUiEventsAsync()`/`ToStatusSnapshotsAsync()` project the tracked stream into the contract; `AssistantStatusReducer` folds events into snapshots on the .NET side. A byte-for-byte matching TypeScript file (`typescript/andes-assistant-ui.ts`) ships in the package, with its own `foldAssistantEvents` reducer, so a SPA reconstructs the identical tree from the identical JSON. + +This guide covers installation, the two DTO layers, the mapper and reducer, the shipped TypeScript file, and the package's privacy posture. For the core middleware's design, see [Architecture](architecture.md); for core usage, see [Getting started](getting-started.md). For a runnable end-to-end example with three consumer surfaces sharing this contract, see [Example: the UI contract, three ways](examples/ui-contract.md). + +## Prerequisites and installation + +- .NET SDK **10.0** or later (the package targets `net10.0`). +- The core pipeline from [Getting started](getting-started.md) — `UseToolTracking()` before `UseFunctionInvocation()`. + +```shell +dotnet add package Andes.Extensions.AI.UI +``` + +Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.3.0) and `Microsoft.Extensions.AI.Abstractions` — nothing else. The package does not reference the [MCP](mcp.md) or [Agent](agents.md) satellites; it doesn't need to, because `ToolKind` (the `Unknown`/`Function`/`McpTool`/`Agent` badge every activity carries) already lives in core, shared by every satellite. + +## Quickstart + +Build the tracked pipeline exactly as in [Getting started](getting-started.md), then stream `AssistantStatusSnapshot` instead of parsing `ChatResponseUpdate` yourself: + +```csharp +using Andes.Extensions.AI; +using Microsoft.Extensions.AI; + +IChatClient client = innerClient + .AsBuilder() + .UseToolTracking() + .UseFunctionInvocation() // tracking before function invocation (core invariant) + .Build(); + +// Stream immutable snapshots and bind them to the UI (Blazor, console, ...). +await foreach (AssistantStatusSnapshot snapshot in client + .GetStreamingResponseAsync("prompt", chatOptions) + .ToStatusSnapshotsAsync()) +{ + Console.WriteLine(snapshot.AssistantStatus); + foreach (AssistantActivity activity in snapshot.Activities) + { + // e.g. "Andes Test MCP" [McpTool] — name and kind are separate, never "Andes Test MCP MCP" + Console.WriteLine($"{activity.DisplayName} [{activity.Kind}] — {activity.State}"); + } +} +``` + +For an HTTP surface, stream `ToUiEventsAsync()` instead and serialize each event with `AssistantUiJsonContext` over server-sent events; the browser folds them with `foldAssistantEvents` from the shipped `.ts` file — see the [full example](examples/ui-contract.md). + +## Two DTO layers: events and snapshots + +The contract is deliberately split into two shapes, mirroring a common streaming-UI pattern (deltas over the wire, state in memory): + +| Layer | Type | Shape | Use it for | +| --- | --- | --- | --- | +| Wire | `AssistantUiEvent` | Flat, one per stream flush, discriminated by `Kind` | Sending over HTTP (SSE, WebSocket) — small, order-sensitive, cheap to serialize | +| Render | `AssistantStatusSnapshot` | Nested, immutable, one per fold | Binding to a UI — `Activities` is already a tree, ready to render without any client-side bookkeeping beyond calling the reducer | + +### `AssistantUiEvent` — the wire shape + +`AssistantUiEvent` is a flat record with one `AssistantUiEventKind` discriminator and every field any kind might need; unused fields are `null` (and, once serialized through `AssistantUiJsonContext`, omitted): + +```text +AssistantUiEventKind +├── Status — Message is the new request-level status line ("Thinking…") +├── ActivityStarted — ScopeId/ParentScopeId/Depth/ToolKind/DisplayName/Source describe the new card +├── ActivityProgress — ScopeId targets the owning activity; Message/Progress/ProgressTotal are the sub-status +├── ActivityCompleted — ScopeId targets the activity; DurationSeconds is set +├── ActivityFailed — same as ActivityCompleted, but the activity failed +├── TextDelta — Text is a chunk of the assistant's answer +└── Finished — Usage carries the final token totals; DurationSeconds is the whole request +``` + +`ScopeId`/`ParentScopeId`/`Depth` are carried over unchanged from the core's `ChatProgressUpdate`, so the same tree-reconstruction rules from [Getting started](getting-started.md#consume-streaming-progress) and the [Progress Board example](examples/progress-board.md#the-progress-board-hierarchy-from-the-event-contract) apply here — this contract just makes them serializable. + +### `AssistantStatusSnapshot` — the render shape + +`AssistantStatusSnapshot` is the folded result: an immutable value with the current `AssistantStatus` line, the overall `Phase` (`ActivityState.Running`/`Completed`/`Failed`), the answer `Text` accumulated so far, the final `Usage`, and — the interesting part — `Activities`, an already-nested `IReadOnlyList`: + +```csharp +public sealed record AssistantActivity +{ + public required string ScopeId { get; init; } + public required string DisplayName { get; init; } + public ToolKind Kind { get; init; } + public string? Source { get; init; } + public ActivityState State { get; init; } = ActivityState.Running; + public double? DurationSeconds { get; init; } + public IReadOnlyList SubStatuses { get; init; } = []; + public IReadOnlyList Children { get; init; } = []; + public UsageSummary? Usage { get; init; } +} +``` + +`Children` is what makes the tree recursive, and as of v0.3 it fills **live**: the [MCP](mcp.md#nested-mcp-tools) and [Agent](agents.md#nested-agents) satellite wrappers open a real child scope when invoked inside another tool, so a nested agent or MCP tool streams in as a child `AssistantActivity` under the enclosing card while it runs — as do a nested tracked pipeline's tool calls and any child scope a tool opens itself with `ChatProgress.BeginToolScope` (see [Architecture: the ambient scope tree](architecture.md#the-ambient-scope-tree)). This package needed zero changes for that: the reducer has always attached activities by `ParentScopeId`, whoever opens the scope. A UI renders children with the same component, one level deeper, with no special-casing. + +`SubStatus` (`Message`, `Progress`, `ProgressTotal`) and `UsageSummary` (`InputTokens`, `OutputTokens`, `TotalTokens`, all nullable) are small, flat leaf records — `UsageSummary` is `Microsoft.Extensions.AI.UsageDetails` flattened to primitives so it serializes without pulling that type's shape into the wire contract. + +## The mapper: `ChatResponseUiExtensions` + +Four static members turn the tracked, in-band stream into the contract: + +| Member | Input | Output | +| --- | --- | --- | +| `ToUiEventsAsync(this IAsyncEnumerable, CancellationToken)` | The tracked streaming response | `IAsyncEnumerable` — one event per `ChatProgressContent`, one `TextDelta` per non-empty `update.Text`, one final `Finished` from `UsageReportContent` | +| `ToStatusSnapshotsAsync(this IAsyncEnumerable, CancellationToken)` | The tracked streaming response | `IAsyncEnumerable` — `ToUiEventsAsync` piped through a private `AssistantStatusReducer`, one snapshot per event | +| `ToUiEvent(this ChatProgressUpdate)` | A single core progress update | The equivalent `AssistantUiEvent` | +| `ToUsageSummary(this UsageDetails)` | A core usage value | The flattened `UsageSummary` | +| `ToSnapshot(this ChatUsageReport)` | A completed usage report (for example the non-streaming `ChatResponse.AdditionalProperties` report) | A `Completed`-phase snapshot built directly from the report's `ToolCalls` tree — useful when all you have is the final report, not the live stream | + +The mapper is where the clean-name design lives: `ToUiEvent` sets `DisplayName = update.ToolSource ?? update.ToolName` — the raw server/agent/function name — never `update.Message`, which is the *composed* header text ("Calling GetWeather Tool", "Calling Andes Test MCP"). `ToSnapshot`'s `ToActivity` helper does the same from a `ToolCallUsage`: `DisplayName = call.Source ?? call.ToolName`. It also recurses `ToolCallUsage.Children`, so nested activities — including the v0.3 satellite child scopes — arrive with their own per-node `Usage`, matching the live tree's shape. See [Clean names, not composed headers](#clean-names-not-composed-headers) for why this matters. + +## The reducer: `AssistantStatusReducer` + +`AssistantStatusReducer` is a small stateful fold: construct one per request, and call `Apply(AssistantUiEvent)` for every event in order — it returns the resulting `AssistantStatusSnapshot` each time: + +```csharp +var reducer = new AssistantStatusReducer(); +await foreach (AssistantUiEvent uiEvent in events) +{ + AssistantStatusSnapshot snapshot = reducer.Apply(uiEvent); + Render(snapshot); +} +``` + +It rebuilds the activity tree the same way the [Progress Board example](examples/progress-board.md#the-progress-board-hierarchy-from-the-event-contract) does: `ActivityStarted` opens a scope and either attaches it under `ParentScopeId` (if that scope is already known) or adds it as a root — a top-level activity's parent is the request root, which has no card. `ActivityProgress` appends a `SubStatus` to the owning scope; `ActivityCompleted`/`ActivityFailed` set `State` and `DurationSeconds`. `TextDelta` accumulates `Text`; `Finished` sets `Phase = Completed` and `Usage`. + +`ToStatusSnapshotsAsync` already wraps this for you over a live stream — reach for `AssistantStatusReducer` directly only when you're consuming events from somewhere else (deserialized off the wire, replayed from storage, or, in a Blazor WebAssembly app, received over SignalR or fetched as SSE). + +**A reducer is single-consumer and stateful.** Feed it the events of exactly one request, in order; the core stream and the SSE encoding described in the [example](examples/ui-contract.md) already preserve that order, so a new reducer per request is all a consumer needs — no locking. + +## The TypeScript file + +`typescript/andes-assistant-ui.ts` ships inside the NuGet package (`PackagePath: typescript\`) as a 1:1 mirror of the C# types and the JSON `AssistantUiJsonContext` produces: + +- `ToolKind`, `AssistantUiEventKind`, and `ActivityState` as string-literal unions (`"Unknown" | "Function" | "McpTool" | "Agent"`, and so on) — matching the string enum values `AssistantUiJsonContext` serializes. +- `UsageSummary`, `SubStatus`, `AssistantActivity`, `AssistantStatusSnapshot`, and `AssistantUiEvent` interfaces with camelCase properties, each optional member marked `?` (mirroring the C# nullable members that `AssistantUiJsonContext` omits when `null`). +- `createInitialSnapshot()` — the empty starting snapshot (`{ phase: "Running", activities: [] }`) to fold events into. +- `foldAssistantEvents(snapshot, event)` — the TypeScript counterpart of `AssistantStatusReducer.Apply`, immutable and pure: it returns a new `AssistantStatusSnapshot` rather than mutating the one you pass in, which is what makes it a natural fit for a React/Vue/Svelte render loop. + +```typescript +import { createInitialSnapshot, foldAssistantEvents, type AssistantUiEvent } from "./andes-assistant-ui"; + +let snapshot = createInitialSnapshot(); +for (const event of events as AssistantUiEvent[]) { + snapshot = foldAssistantEvents(snapshot, event); + render(snapshot); +} +``` + +Because the file ships in the package rather than as a separately versioned npm dependency, copy it into your frontend project (or reference it from the extracted nupkg's `typescript/` folder) and keep it alongside the `Andes.Extensions.AI.UI` version your backend uses — the two are meant to travel together. See the [full SPA example](examples/ui-contract.md#c-the-typescript-spa-consumer) for consuming it over server-sent events end to end. + +## Clean names, not composed headers + +This is the whole reason the contract exists, so it's worth stating plainly: **every activity carries a clean `DisplayName` and a separate `Kind` badge — never a pre-composed header string.** + +The core middleware's progress headers are composed text meant for a plain-text console or log line: `ToolTrackingOptions.HeaderFormatter`'s default produces `"Calling {DisplayName} Tool"` for functions, `"Calling {Source} MCP"` for MCP tools, and `"Calling {DisplayName} Agent"` for agents — and, as of core v0.2, that formatter already avoids doubling the kind word when the name ends with it (`"Andes Test MCP"` renders as `"Calling Andes Test MCP"`, not `"Calling Andes Test MCP MCP"`; `"Research Agent"` renders as `"Calling Research Agent"`). That fix helps *console and log output*, but it's still one composed English string — not something a UI can restyle, badge, or localize. + +The UI contract sidesteps the composition problem entirely instead of patching it further: `AssistantActivity.DisplayName` (and `AssistantUiEvent.DisplayName`) is always the *raw* name — the function's registered name, the MCP server's title, or the agent's name — with no "Calling" prefix and no kind word appended, ever. `Kind` (the core `ToolKind`: `Function`, `McpTool`, or `Agent`) travels alongside it as its own field, meant to render as a badge or icon rather than be concatenated into the label. A server named "Andes Test MCP" renders as `DisplayName: "Andes Test MCP"` with `Kind: McpTool` — a UI shows the name once and the badge once, and the string "MCP" never appears twice no matter how the server was named. Because there's no English text baked into the field, a UI can localize the `Kind` badge (a fixed, small enum) independently of the `DisplayName` (arbitrary, unlocalized, developer-supplied text) — something a composed header string could never support. + +## Privacy posture + +Unchanged from the core: events and snapshots **never carry prompt content, tool arguments, or tool results** — only headers-turned-names, statuses, sub-status text, activity metadata (`ScopeId`, `Kind`, `Source`, timing), and token counts. The mapper reads exclusively from `ChatProgressUpdate`/`ChatUsageReport`, which already enforce this at the core layer (the sole opt-in there remains `ToolTrackingOptions.IncludeToolArguments`, default `false`); this package adds no new opt-in and cannot re-introduce content the core never emitted. See [Architecture: Privacy posture](architecture.md#privacy-posture). + +## References + +- [Getting started](getting-started.md) — the core pipeline, `ChatProgress.Report`, and the streamed `ChatProgressContent`/`UsageReportContent` this package projects +- [Architecture](architecture.md) — the channel merge, the ambient scope tree, and the usage report shape this package mirrors +- [MCP tool tracking](mcp.md) and [Agent tool tracking](agents.md) — the satellites that populate `ToolKind.McpTool`/`ToolKind.Agent` and the composed headers this package's `DisplayName`/`Kind` split replaces for UI rendering +- [Example: the UI contract, three ways](examples/ui-contract.md) — a runnable SSE producer, Blazor WebAssembly consumer, and TypeScript SPA sharing this contract +- [`System.Text.Json` source generation](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) — the basis for `AssistantUiJsonContext` +- [Server-sent events (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) and [ASP.NET Core Blazor hosting models](https://learn.microsoft.com/aspnet/core/blazor/hosting-models) diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..30ae4d7 Binary files /dev/null and b/icon.png differ diff --git a/releases/v0.2.0.md b/releases/v0.2.0.md new file mode 100644 index 0000000..d5bf0e5 --- /dev/null +++ b/releases/v0.2.0.md @@ -0,0 +1,50 @@ +# 0.2.0 + +**Tag:** `v0.2.0` · **Date:** 2026-07-25 + +The initial public release. This release ships **three** NuGet packages, versioned in lockstep at 0.2.0, all targeting `net10.0`: composable [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) `IChatClient` middlewares for tool tracking, streaming progress, and token-usage reporting. + +## `Andes.Extensions.AI` (core) + +The tool-tracking middleware: + +- **`UseToolTracking()`** — registered **before** `UseFunctionInvocation()`, so the tracker wraps the tools the function-invoking client executes and observes the merged stream from outside the invocation loop. Each `AIFunction` is wrapped in a request-scoped `TrackingAIFunction`; the caller's `ChatOptions` is cloned, never mutated. +- **In-band progress** — synthetic `ChatProgressContent` updates ("Calling {Tool} Tool" headers, tool-reported sub-statuses, completions) merged into the live `ChatResponseUpdate` stream, plus a final `UsageReportContent`. +- **Out-of-band observers** — the same events and the final report delivered to `IChatProgressObserver` implementations, isolated so a faulty observer cannot corrupt the stream. +- **Ambient reporting** — `ChatProgress.Report(...)` and `ChatProgress.ReportUsage(...)` from inside a tool body (AsyncLocal; safe no-op outside a tracked request). +- **Numeric progress** — `ChatProgress.Report(status, progress, progressTotal)` and the matching `IChatProgressReporter` default interface method populate `ChatProgressUpdate.Progress`/`ProgressTotal`. +- **`ChatUsageReport`** — token usage (input/output/total, model id, provider name from `ChatClientMetadata`) per request, per model turn, and per tool-call scope, including usage reported inside tools and the totals of nested tracked pipelines rolled up through the ambient scope tree. + - Streaming: a final `UsageReportContent` update. + - Non-streaming: `ChatResponse.AdditionalProperties["andes.ai.usage_report"]`. +- **`StripProgressContent()`** — removes the synthetic content from a response (or individual messages) before it is persisted into conversation history or serialized. +- **`ToolTrackingOptions`** — `ToolClassifier`, `HeaderFormatter`, `Observers`, and the sole capture opt-in, `IncludeToolArguments` (default `false`; stringified argument values only). +- **Privacy invariant** — progress events and reports never carry prompt content, tool arguments, or tool results. + +## `Andes.Extensions.AI.Mcp` + +MCP tools as first-class tracked tools (references `ModelContextProtocol.Core` only; the core package stays MCP-free): + +- **`McpClientTool.WithTracking(...)`** — carries the server display name and bridges the server's progress notifications into `ToolProgress` updates with numeric `Progress`/`ProgressTotal`. + - The `McpProgressBridge` captures the reporter at invocation time — never ambiently at report time, since notifications arrive on the MCP receive loop. + - Late notifications are dropped best-effort. +- **`UseMcpToolClassification()`** — installs a composing `ToolClassifier` so MCP tools classify as `ToolKind.McpTool` and render as `"Calling {Server} MCP"`. User `DelegatingAIFunction` wrappers are never deep-unwrapped. + +## `Andes.Extensions.AI.Agent` + +Microsoft Agent Framework agents as tracked tools (references `Microsoft.Agents.AI` only; core and Mcp stay Agent-Framework-free): + +- **`AIAgent.WithTracking(...)`** — wraps `agent.AsAIFunction()` so the agent classifies as `ToolKind.Agent` and renders as `"Calling {Agent} Agent"`. + - The internal `UsageReportingAIAgent` attributes each run's `AgentResponse.Usage` to the calling tool's scope — a plain `AsAIFunction()` exposes neither the agent nor its usage. + - `trackUsage` (default `true`) — pass `false` when the agent's own pipeline is tracked, to avoid double-counting. + - `reportFunctionCalls` (opt-in) — `"Calling {Function} Tool"` statuses, names only; local function-invoking agents only. +- **`UseAgentToolClassification()`** — installs the composing `ToolClassifier` for agent tools. + +## Release infrastructure + +- **NuGet Trusted Publishing** — a GitHub Actions workflow (`.github/workflows/nuget.yml`) publishes the three packages to nuget.org via OIDC (no stored API key), with deny-all default permissions and a package-set verification gate. +- **Documentation** — `docs/`: getting started, architecture, MCP support, Agent support, and the Progress Board example (every tool kind in one stream). +- **Tests** — unit suites with no network: + - Core over the `ScriptedChatClient` fake driving the real `FunctionInvokingChatClient`. + - MCP over a real in-process client/server pair. + - Agent over real `ChatClientAgent`s built on the scripted fake. + - `[SkippableFact]` Azure OpenAI integration tests (core, MCP over a stdio test server, Agent) skip cleanly when the gitignored `appsettings.integration.json` is absent. diff --git a/releases/v0.3.0.md b/releases/v0.3.0.md new file mode 100644 index 0000000..48bd926 --- /dev/null +++ b/releases/v0.3.0.md @@ -0,0 +1,55 @@ +# 0.3.0 — Unreleased + +All changes since [`v0.2.0`](v0.2.0.md). + +## Added + +### New package: `Andes.Extensions.AI.UI` (first release) + +A serializable, cross-language status contract on top of the core tracking stream — this package did not ship in 0.2.0: + +- **Contract types** — `AssistantUiEvent` (flat wire deltas, discriminated by `AssistantUiEventKind`), `AssistantStatusSnapshot` (folded render state), `AssistantActivity` (nested activity cards), `SubStatus`, and `UsageSummary`. + - Every activity carries a clean `DisplayName` plus a separate `ToolKind` badge — never a composed "Calling … MCP/Agent/Tool" string. +- **Mappers** — `ToUiEventsAsync()` and `ToStatusSnapshotsAsync()` project the tracked `ChatResponseUpdate` stream into the contract; `ChatUsageReport.ToSnapshot()` builds a `Completed`-phase snapshot directly from a final report. +- **Reducer** — the stateful `AssistantStatusReducer` folds events into snapshots (one reducer per request), for events consumed off the wire or replayed from storage. +- **Serialization** — the source-generated `AssistantUiJsonContext` (camelCase properties, string enums, `null`s omitted, trim/AOT-safe). +- **TypeScript mirror** — `typescript/andes-assistant-ui.ts` ships inside the nupkg: a 1:1 mirror of the types and JSON, with `createInitialSnapshot()` and the pure `foldAssistantEvents` reducer, so a SPA reconstructs the identical activity tree from the identical JSON. + +### Nested tool scopes (core + both satellites) + +- **`ChatProgress.BeginToolScope(descriptor, owner)`** — new public core API returning a `ChatProgressToolScope` handle (`Fail()`, idempotent `Dispose()`), so any tool can give a sub-operation its own child activity. +- The Agent and MCP `WithTracking` wrappers open a **child scope** when invoked while another tool's scope is ambient — an agent as a tool of another agent, or an agent/MCP tool invoked directly inside a tool body. + - The nested call renders live as its own child activity card and lands as a child `ToolCallUsage` (own usage, duration, and `Succeeded` flag) under the enclosing call in the report. + - **Owner-identity dedup** guarantees exactly one scope on the normal wrapped path: when the tracking middleware itself wrapped the function, the wrapper's `BeginToolScope` call is an inactive no-op — top-level behavior is unchanged from 0.2.0. + - **`CallId`** on the child's events is the inner invocation loop's function-call id in the agent-in-agent shape, and `null` on direct invocation from a tool body. + - The **MCP progress bridge binds to the child scope**, so bridged notifications land under the nested tool's own header. +- **Behavior note** — nested `WithTracking` tools previously surfaced flat: sub-statuses and usage on the enclosing tool's scope. They now render as child cards. All report totals are numerically unchanged: a parent's rollup has always included its children, so `TotalUsage` and every per-call rollup match the old flat attribution exactly. +- **Documented limitations** — recursive self-invocation stays flat (same owner as the tracker's own delegation); a never-disposed `ChatProgressToolScope` leaves its scope open (no completion event; the scope lands in the report as succeeded with no duration). + +### Sample: `samples/Andes.Extensions.AI.Demo` + +An interactive, Claude-Code-style console chat (Spectre.Console; Azure OpenAI configured via a gitignored `appsettings.json`) exercising all four packages in a single tracked pipeline: + +- Live activity tree with `fn`/`mcp`/`agent` badges and per-step progress bars. +- Nested-agent child cards in both shapes (agent-in-agent, agent invoked inside a tool body). +- Per-activity token badges in the persistent final frame (`ChatUsageReport.ToSnapshot()` merged with the last live snapshot). +- Non-interactive fallbacks for piped input and CI. +- `IsPackable=false` — it never ships to NuGet. + +## Changed + +- **Progress headers de-duplicate the kind word** — a server named "Andes Test MCP" renders as `"Calling Andes Test MCP"` and an agent named "Research Agent" as `"Calling Research Agent"`, instead of doubling ("… MCP MCP", "… Agent Agent"). +- **Docs expanded**: + - New [UI support](../docs/ui.md) guide and [UI contract example](../docs/examples/ui-contract.md). + - New [Nested agents](../docs/agents.md#nested-agents) and [Nested MCP tools](../docs/mcp.md#nested-mcp-tools) sections. + - Architecture updates for the ambient scope tree and known limitations. + - The [sample README](../samples/Andes.Extensions.AI.Demo/README.md) and a Samples section in the root README. +- **All four packages version in lockstep at 0.3.0**; the satellites depend on core `>= 0.3.0`. + +## Fixed + +- The release workflow (`.github/workflows/nuget.yml`) packed and verified only the three 0.2.0 packages, so `Andes.Extensions.AI.UI` could never reach nuget.org. It now packs, verifies (4 nupkg + 4 snupkg, and each package version matching the release tag), and publishes all four. + +## Verification + +127 unit tests and 9 Azure OpenAI integration tests green, including a live nested-agent integration test. Both nesting demo scenarios — an agent as a tool of another agent, and an agent invoked directly inside a tool body — verified against a real Azure OpenAI deployment. diff --git a/samples/.editorconfig b/samples/.editorconfig new file mode 100644 index 0000000..d185c81 --- /dev/null +++ b/samples/.editorconfig @@ -0,0 +1,3 @@ +# Sample apps relax library-only rules. +[*.cs] +dotnet_diagnostic.CA2007.severity = none diff --git a/samples/Andes.Extensions.AI.Demo/Andes.Extensions.AI.Demo.csproj b/samples/Andes.Extensions.AI.Demo/Andes.Extensions.AI.Demo.csproj new file mode 100644 index 0000000..d58ab26 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/Andes.Extensions.AI.Demo.csproj @@ -0,0 +1,31 @@ + + + + Exe + net10.0 + Andes.Extensions.AI.Demo + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs b/samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs new file mode 100644 index 0000000..d8c5d53 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Configuration; + +namespace Andes.Extensions.AI.Demo; + +/// +/// Azure OpenAI connection settings loaded from the gitignored appsettings.json +/// (copy appsettings.sample.json and fill in the AzureOpenAI section). +/// +internal sealed class AzureOpenAISettings +{ + public string? Endpoint { get; set; } + + public string? ApiKey { get; set; } + + public string? Deployment { get; set; } + + public bool IsConfigured => + Uri.TryCreate(Endpoint, UriKind.Absolute, out _) && + !string.IsNullOrWhiteSpace(ApiKey) && !ApiKey.StartsWith('<') && + !string.IsNullOrWhiteSpace(Deployment) && !Deployment.StartsWith('<'); + + public static AzureOpenAISettings Load() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true) + .Build(); + + return configuration.GetSection("AzureOpenAI").Get() ?? new AzureOpenAISettings(); + } +} diff --git a/samples/Andes.Extensions.AI.Demo/DemoAgents.cs b/samples/Andes.Extensions.AI.Demo/DemoAgents.cs new file mode 100644 index 0000000..24faddb --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/DemoAgents.cs @@ -0,0 +1,64 @@ +using System.ComponentModel; +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.Demo; + +/// +/// Agents-as-tools showcasing the Agent satellite: each agent is exposed via WithTracking, +/// which attributes its run's token usage to its own tracking scope. The Packing Agent nests +/// inside the Research Agent (and inside the PlanTrip tool), rendering as a child activity card. +/// +internal static class DemoAgents +{ + public static AIAgent CreateResearchAgent(AzureOpenAISettings settings, AIFunction packingAgent) + { + return CreateRawClient(settings).AsAIAgent( + instructions: "You are a travel researcher. Answer briefly. Use the SearchNotes tool " + + "for destination facts and consult the Packing Agent tool for packing advice.", + name: "Research Agent", + description: "Researches travel topics, such as what to pack for a destination, and summarizes findings.", + tools: [AIFunctionFactory.Create(SearchNotes), packingAgent]); + } + + public static AIAgent CreatePackingAgent(AzureOpenAISettings settings) + { + return CreateRawClient(settings).AsAIAgent( + instructions: "You suggest what to pack for a destination. Use the CheckEssentials tool, " + + "then answer in one short sentence.", + name: "Packing Agent", + description: "Suggests what to pack for a destination.", + tools: [AIFunctionFactory.Create(CheckEssentials)]); + } + + private static IChatClient CreateRawClient(AzureOpenAISettings settings) + { + // Inner agents run over raw (untracked) chat clients: the outer pipeline attributes each + // agent's usage via WithTracking's usage capture (trackUsage: true default); a tracked + // inner pipeline would double-count the same tokens. + return new AzureOpenAIClient( + new Uri(settings.Endpoint!), + new AzureKeyCredential(settings.ApiKey!)) + .GetChatClient(settings.Deployment!) + .AsIChatClient(); + } + + [Description("Searches the travel notes knowledge base for a topic.")] + private static string SearchNotes([Description("The topic to search for.")] string topic) + { + // Ambient reporting flows from inside the agent's own function into the outer + // pipeline's activity tree — no plumbing required. The status omits the argument: + // progress events never carry tool arguments unless explicitly opted in. + ChatProgress.Report("Searching notes…"); + return $"Notes on {topic}: pack layers, sunscreen, and a rain shell; altitude 2,850m."; + } + + [Description("Checks the packing essentials list for a destination type.")] + private static string CheckEssentials([Description("The destination type, such as mountain, beach, or city.")] string destinationType) + { + ChatProgress.Report("Checking essentials…"); + return $"Essentials for {destinationType}: layers, sunscreen, a rain shell, and comfortable shoes."; + } +} diff --git a/samples/Andes.Extensions.AI.Demo/DemoMcpServer.cs b/samples/Andes.Extensions.AI.Demo/DemoMcpServer.cs new file mode 100644 index 0000000..6d3291a --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/DemoMcpServer.cs @@ -0,0 +1,124 @@ +using System.ComponentModel; +using System.IO.Pipelines; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Andes.Extensions.AI.Demo; + +/// +/// Hosts a genuine MCP client/server pair over in-process pipe streams — no processes or +/// network — so the demo can expose real instances through +/// WithTracking, with MCP progress notifications bridged into chat progress updates. +/// +internal sealed class DemoMcpServer : IAsyncDisposable +{ + public const string ServerName = "Andes Demo MCP"; + + private McpServer? _server; + private Task? _serverTask; + + public McpClient Client { get; private set; } = null!; + + public IList Tools { get; private set; } = null!; + + public static async Task StartAsync() + { + Pipe clientToServer = new(); + Pipe serverToClient = new(); + + var serverOptions = new McpServerOptions + { + ServerInfo = new Implementation + { + Name = ServerName, + Version = "1.0.0", + }, + ToolCollection = + [ + McpServerTool.Create( + async Task ( + [Description("The city to forecast.")] string city, + [Description("The number of days to forecast (1-10).")] int days, + IProgress progress, + CancellationToken cancellationToken) => + { + // Clamp the model-chosen argument so a "100-day forecast" prompt + // cannot stall the demo. + int clampedDays = Math.Clamp(days, 1, 10); + + // A real progress-reporting tool is long-running; the per-step delay + // also keeps the fire-and-forget notifications ahead of the result. + for (int day = 1; day <= clampedDays; day++) + { + progress.Report(new ProgressNotificationValue + { + Progress = day, + Total = clampedDays, + Message = $"forecast day {day} of {clampedDays}", + }); + await Task.Delay(350, cancellationToken); + } + + return $"{clampedDays}-day outlook for {city}: mild, 18-24C, light winds."; + }, + new McpServerToolCreateOptions + { + Name = "get_forecast", + Description = "Gets a multi-day weather forecast for a city, reporting per-day progress.", + }), + ], + }; + + var demo = new DemoMcpServer(); + var serverTransport = new StreamServerTransport( + clientToServer.Reader.AsStream(), + serverToClient.Writer.AsStream()); + demo._server = McpServer.Create(serverTransport, serverOptions); + demo._serverTask = demo._server.RunAsync(CancellationToken.None); + + try + { + var clientTransport = new StreamClientTransport( + serverInput: clientToServer.Writer.AsStream(), + serverOutput: serverToClient.Reader.AsStream()); + demo.Client = await McpClient.CreateAsync(clientTransport); + demo.Tools = await demo.Client.ListToolsAsync(); + } + catch + { + // The instance never reaches the caller's `await using` on a failed handshake, + // so tear the already-running server down here. + await demo.DisposeAsync(); + throw; + } + + return demo; + } + + public async ValueTask DisposeAsync() + { + if (Client is not null) + { + await Client.DisposeAsync(); + } + + if (_server is not null) + { + await _server.DisposeAsync(); + } + + if (_serverTask is not null) + { + try + { + await _serverTask; + } + catch (Exception) + { + // Server shutdown races with transport teardown; irrelevant to the demo. + } + } + } +} diff --git a/samples/Andes.Extensions.AI.Demo/DemoTools.cs b/samples/Andes.Extensions.AI.Demo/DemoTools.cs new file mode 100644 index 0000000..4a9ff21 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/DemoTools.cs @@ -0,0 +1,51 @@ +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.Demo; + +/// +/// Local function tools showcasing the core package: +/// emits sub-statuses with numeric progress that surface under the tool's activity card, and +/// invokes an agent from inside a plain tool body — the agent opens +/// its own child scope and renders as a nested activity card under the tool. +/// +internal static class DemoTools +{ + [Description("Gets the current weather for a city.")] + public static async Task GetWeather( + [Description("The city to get the weather for.")] string city, + CancellationToken cancellationToken) + { + const int steps = 4; + for (int i = 1; i <= steps; i++) + { + // Statuses deliberately omit the tool's arguments: progress events stay + // argument-free unless ToolTrackingOptions.IncludeToolArguments is opted in. + ChatProgress.Report($"Checking station {i} of {steps}…", i, steps); + await Task.Delay(250, cancellationToken); + } + + return $"The weather in {city} is sunny with a high of 25C."; + } + + public static AIFunction CreatePlanTrip(AIFunction packingAgent) + { + return AIFunctionFactory.Create( + async ([Description("The destination city.")] string city, CancellationToken cancellationToken) => + { + ChatProgress.Report("Building itinerary…"); + await Task.Delay(300, cancellationToken); + + // Invoking a WithTracking-wrapped agent inside a tool body opens a child scope: + // the Packing Agent renders as its own card (with its usage) under PlanTrip. + object? packing = await packingAgent.InvokeAsync( + new AIFunctionArguments { ["query"] = $"What should I pack for {city}?" }, + cancellationToken); + + ChatProgress.Report("Finalizing plan…"); + return $"Trip plan for {city}: day 1 old town, day 2 viewpoints, day 3 markets. Packing advice: {packing}"; + }, + "PlanTrip", + "Plans a short trip to a city, consulting the Packing Agent for what to bring."); + } +} diff --git a/samples/Andes.Extensions.AI.Demo/FinalSnapshot.cs b/samples/Andes.Extensions.AI.Demo/FinalSnapshot.cs new file mode 100644 index 0000000..ae54754 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/FinalSnapshot.cs @@ -0,0 +1,48 @@ +namespace Andes.Extensions.AI.Demo; + +/// +/// Builds the persistent end-of-turn snapshot: activity cards with per-activity token usage come +/// from the final (live snapshots never carry usage), while the +/// streamed answer text and sub-status lines come from the last live snapshot. +/// +internal static class FinalSnapshot +{ + public static AssistantStatusSnapshot? Merge(ChatUsageReport? report, AssistantStatusSnapshot? live) + { + if (report is null) + { + return live; + } + + AssistantStatusSnapshot final = report.ToSnapshot(); + return final with + { + Text = live?.Text ?? final.Text, + Activities = MergeActivities(final.Activities, live?.Activities ?? []), + }; + } + + private static IReadOnlyList MergeActivities( + IReadOnlyList fromReport, + IReadOnlyList fromLive) + { + // Pairing positionally assumes every tool in the request is tracker-wrapped (true in this + // demo). A live tree can also contain best-effort cards for tools the tracker could not + // wrap (hosted/declaration-only tools), which the report omits — those would shift the + // pairing, and no key-based join exists (live ids are scope-N; report ids fall back to + // CallId/ToolName). Keep report values when a live activity is missing at an index. + var merged = new List(fromReport.Count); + for (int i = 0; i < fromReport.Count; i++) + { + AssistantActivity reportActivity = fromReport[i]; + AssistantActivity? liveActivity = i < fromLive.Count ? fromLive[i] : null; + merged.Add(reportActivity with + { + SubStatuses = liveActivity?.SubStatuses ?? reportActivity.SubStatuses, + Children = MergeActivities(reportActivity.Children, liveActivity?.Children ?? []), + }); + } + + return merged; + } +} diff --git a/samples/Andes.Extensions.AI.Demo/Program.cs b/samples/Andes.Extensions.AI.Demo/Program.cs new file mode 100644 index 0000000..a204229 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/Program.cs @@ -0,0 +1,163 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using Andes.Extensions.AI; +using Andes.Extensions.AI.Demo; +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using Spectre.Console; + +Console.OutputEncoding = Encoding.UTF8; + +AzureOpenAISettings settings = AzureOpenAISettings.Load(); +if (!settings.IsConfigured) +{ + AnsiConsole.Write(new Panel(new Markup( + "[yellow]Azure OpenAI is not configured.[/]\n\n" + + "Copy [bold]appsettings.sample.json[/] to [bold]appsettings.json[/] next to this project\n" + + "and fill in the [bold]AzureOpenAI[/] section ([grey]Endpoint, ApiKey, Deployment[/]).")) + .Header("Andes.Extensions.AI demo") + .BorderColor(Color.Yellow)); + return; +} + +await using DemoMcpServer mcp = await DemoMcpServer.StartAsync(); + +// The one ordering invariant: UseToolTracking BEFORE UseFunctionInvocation, so the tracker +// wraps the tools the invoker executes and observes the merged stream from outside the loop. +IChatClient client = new AzureOpenAIClient( + new Uri(settings.Endpoint!), + new AzureKeyCredential(settings.ApiKey!)) + .GetChatClient(settings.Deployment!) + .AsIChatClient() + .AsBuilder() + .UseToolTracking(options => + { + options.UseMcpToolClassification(); + options.UseAgentToolClassification(); + // Out-of-band alternative: options.Observers.Add(new MyProgressObserver()); + // Skipped here — a console-writing observer would interleave with the Live region. + }) + .UseFunctionInvocation() + .Build(); + +// The Packing Agent is shared by both nesting scenarios: as a tool of the Research Agent +// (agent inside agent) and invoked inside the PlanTrip tool body (agent inside a function). +// Either way it opens its own child scope and renders as a nested activity card. +AIFunction packingAgent = DemoAgents.CreatePackingAgent(settings).WithTracking(); + +// No Temperature: reasoning-model deployments reject non-default values. +var chatOptions = new ChatOptions +{ + Tools = + [ + AIFunctionFactory.Create(DemoTools.GetWeather), + DemoTools.CreatePlanTrip(packingAgent), + .. mcp.Tools.WithTracking(mcp.Client), + DemoAgents.CreateResearchAgent(settings, packingAgent).WithTracking(reportFunctionCalls: true), + ], +}; + +AnsiConsole.Write(new Rule("[bold]Andes.Extensions.AI[/] [dim]demo[/]").LeftJustified()); +AnsiConsole.MarkupLine("[dim]A tracked IChatClient pipeline rendered live from Andes.Extensions.AI.UI snapshots.[/]"); +AnsiConsole.MarkupLine("[dim]Try:[/] [italic]Get the weather in Quito and the 5-day forecast.[/]"); +AnsiConsole.MarkupLine("[dim] [/] [italic]Ask the Research Agent what to pack for Quito.[/] [dim](agent nested in an agent)[/]"); +AnsiConsole.MarkupLine("[dim] [/] [italic]Plan a trip to Cusco.[/] [dim](agent nested in a plain tool)[/]"); +AnsiConsole.MarkupLine("[dim]Press Enter on an empty line (or type 'exit') to quit.[/]"); +AnsiConsole.WriteLine(); + +List history = []; + +while (true) +{ + string prompt = ReadPrompt(); + if (string.IsNullOrWhiteSpace(prompt) || prompt.Trim().ToLowerInvariant() is "exit" or "quit") + { + break; + } + + // Checkpoint so a failed turn can roll back everything it added to history. + int checkpoint = history.Count; + history.Add(new ChatMessage(ChatRole.User, prompt)); + List updates = []; + + // Tee: record the raw updates for chat history while the same stream drives the renderer. + async IAsyncEnumerable StreamTurn( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions, cancellationToken)) + { + updates.Add(update); + yield return update; + } + } + + AssistantStatusSnapshot? last = null; + + // Snapshots arrive per text delta; throttle redraws to stay flicker-free. + async Task ConsumeAsync(Action? render) + { + var throttle = Stopwatch.StartNew(); + await foreach (AssistantStatusSnapshot snapshot in StreamTurn().ToStatusSnapshotsAsync()) + { + last = snapshot; + if (render is not null && throttle.ElapsedMilliseconds >= 80) + { + render(snapshot); + throttle.Restart(); + } + } + } + + try + { + if (AnsiConsole.Profile.Capabilities.Interactive) + { + await AnsiConsole.Live(Text.Empty) + .AutoClear(true) + .Overflow(VerticalOverflow.Ellipsis) + .StartAsync(context => ConsumeAsync(snapshot => context.UpdateTarget(StatusRenderer.RenderLive(snapshot)))); + } + else + { + // Redirected output (scripts, CI): the Live region needs a real terminal, so only + // the persistent final frame below is rendered. + await ConsumeAsync(render: null); + } + + // The final frame renders the report-derived tree (per-activity tokens live only there) + // merged with the live snapshot's text and sub-statuses. + ChatUsageReport? report = updates + .SelectMany(update => update.Contents) + .OfType() + .FirstOrDefault()?.Report; + if (FinalSnapshot.Merge(report, last) is { } final) + { + AnsiConsole.Write(StatusRenderer.RenderFinal(final)); + } + + // Strip in-band progress/usage content before it re-enters the next request. + history.AddRange(updates.ToChatResponse().StripProgressContent().Messages); + } + catch (Exception exception) + { + history.RemoveRange(checkpoint, history.Count - checkpoint); + AnsiConsole.Write(StatusRenderer.RenderFailed(last, exception)); + } + + AnsiConsole.WriteLine(); +} + +static string ReadPrompt() +{ + if (AnsiConsole.Profile.Capabilities.Interactive) + { + return AnsiConsole.Prompt(new TextPrompt("[bold green]›[/]").AllowEmpty()); + } + + // Piped or redirected input (scripts, CI): Spectre's interactive prompt would throw, + // so fall back to plain line reading. Null at end-of-input exits the loop. + AnsiConsole.Markup("[bold green]›[/] "); + return Console.ReadLine() ?? string.Empty; +} diff --git a/samples/Andes.Extensions.AI.Demo/README.md b/samples/Andes.Extensions.AI.Demo/README.md new file mode 100644 index 0000000..4f19cee --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/README.md @@ -0,0 +1,78 @@ +# Andes.Extensions.AI Demo + +An interactive, Claude-Code-style console chat that exercises all four packages — [`Andes.Extensions.AI`](../../README.md), `Andes.Extensions.AI.Mcp`, `Andes.Extensions.AI.Agent`, and `Andes.Extensions.AI.UI` — in a single tracked `IChatClient` pipeline. Every turn streams through `ToStatusSnapshotsAsync()` and is rendered live with [Spectre.Console](https://spectreconsole.net/): a status header, an activity tree with per-kind badges and progress bars, the streamed answer, and a token-usage footer. The project is intentionally not packable (`samples/Directory.Build.props` sets `IsPackable=false`) — it never ships to NuGet. + +## What it demonstrates + +| File | Package | Feature | +| --- | --- | --- | +| `DemoTools.cs` | `Andes.Extensions.AI` (core) | A local function tool (`GetWeather`) reporting sub-statuses with numeric progress via `ChatProgress.Report(status, progress, progressTotal)`; `CreatePlanTrip` builds a `PlanTrip` tool that invokes the Packing Agent directly in its body — the agent opens its own child scope and renders as a nested card under `PlanTrip` | +| `DemoMcpServer.cs` | `Andes.Extensions.AI.Mcp` | A genuine in-process MCP client/server pair over pipe streams; `get_forecast` reports MCP progress notifications that the satellite bridges into chat progress; tools exposed via `WithTracking(client)` | +| `DemoAgents.cs` | `Andes.Extensions.AI.Agent` | A "Research Agent" and a "Packing Agent", both over raw (untracked) Azure OpenAI clients. The Research Agent is a top-level tool (`WithTracking(reportFunctionCalls: true)`); the Packing Agent is wrapped once with `WithTracking()` and nests two ways — as a tool of the Research Agent and inside the `PlanTrip` tool body — rendering as a child activity card with its own usage either way. Inner clients stay untracked because `WithTracking`'s usage capture already attributes each agent's tokens — a tracked inner pipeline would double-count them | +| `StatusRenderer.cs` + `Program.cs` | `Andes.Extensions.AI.UI` | `ToStatusSnapshotsAsync()` → `AssistantStatusSnapshot` → Spectre.Console `Live` rendering: an activity tree with `fn`/`mcp`/`agent` badges, nested child cards, per-step progress bars, durations, and token usage | +| `FinalSnapshot.cs` | `Andes.Extensions.AI.UI` | The persistent end-of-turn frame: `ChatUsageReport.ToSnapshot()`'s report-derived tree (per-activity token usage lives only there) merged positionally with the last live snapshot's answer text and sub-status lines | + +## Prerequisites + +- .NET SDK **10.0** or later. +- An Azure OpenAI resource with a chat deployment. + +The demo never sets `Temperature` — reasoning-model deployments reject non-default values. + +## Configure + +Copy the sample settings file next to it in this folder and fill in the `AzureOpenAI` section: + +```shell +cp samples/Andes.Extensions.AI.Demo/appsettings.sample.json samples/Andes.Extensions.AI.Demo/appsettings.json +``` + +```json +{ + "AzureOpenAI": { + "Endpoint": "https://your-resource.openai.azure.com/", + "ApiKey": "", + "Deployment": "" + } +} +``` + +`appsettings.json` is gitignored, so secrets never land in git. Environment variables are deliberately not used — the file is the single configuration source. + +## Run + +From the repo root: + +```shell +dotnet run --project samples/Andes.Extensions.AI.Demo +``` + +Try the prompts printed at startup — each lights up a different flow: + +> Get the weather in Quito and the 5-day forecast. + +Function and MCP tools side by side: numeric sub-status progress from `GetWeather`, bridged MCP notifications from `get_forecast`. + +> Ask the Research Agent what to pack for Quito. + +An agent nested in an agent: the Research Agent consults its Packing Agent tool, which opens its own child scope and renders as a nested activity card with its own duration and token usage. + +> Plan a trip to Cusco. + +An agent nested in a plain tool: `PlanTrip` invokes the Packing Agent directly in its body — the same child card, opened from user code instead of an invocation loop. + +Exit with an empty line, `exit`, or `quit`. In a non-interactive console (piped input or redirected output — scripts, CI) the Spectre `Live` region is skipped and prompts are read as plain lines; only the persistent final frame of each turn is rendered. + +## How it fits together + +`Program.cs` builds the pipeline with the one ordering invariant: `UseToolTracking()` **before** `UseFunctionInvocation()`, so the tracker wraps the tools the invoker executes and observes the merged stream from outside the invocation loop. `UseMcpToolClassification()` and `UseAgentToolClassification()` install the satellite classifiers so MCP tools and agents get their own badges and display names. + +The Packing Agent is created once and shared by both nesting scenarios: registered as a tool of the Research Agent, and captured by the `PlanTrip` tool body. Either invocation path opens its own child scope, so the live tree and the final usage report both show it as a child of whatever called it. + +Each turn tees the raw `ChatResponseUpdate` stream: one side drives the live renderer through `ToStatusSnapshotsAsync()`, the other is recorded for history. After the stream drains, `FinalSnapshot.Merge` builds the persistent frame — the report's `ToSnapshot()` tree (the only place per-activity token usage exists) merged with the live snapshot's text and sub-statuses. Before the next turn, the app calls `StripProgressContent()` on the recorded response so the synthetic in-band progress and usage content never re-enters the request. + +## See also + +- [Root README](../../README.md) — package overview and quickstarts. +- [Getting started](../../docs/getting-started.md) — the core pipeline, step by step. +- [UI support](../../docs/ui.md) — the `AssistantStatusSnapshot` contract this demo renders. diff --git a/samples/Andes.Extensions.AI.Demo/StatusRenderer.cs b/samples/Andes.Extensions.AI.Demo/StatusRenderer.cs new file mode 100644 index 0000000..ba7c74a --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/StatusRenderer.cs @@ -0,0 +1,174 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace Andes.Extensions.AI.Demo; + +/// +/// Renders instances from the UI package as +/// Claude-Code-style console frames: a status header, an activity tree with per-kind +/// badges and progress bars, the streamed answer text, and a token-usage footer. +/// +internal static class StatusRenderer +{ + public static IRenderable RenderLive(AssistantStatusSnapshot snapshot) + { + // The Live region cannot scroll, so only the tail of the streamed answer is shown + // while working; RenderFinal prints the full text once the turn completes. + const int liveTextTailLines = 10; + + var rows = new List { Header(snapshot) }; + AppendActivities(rows, snapshot); + if (!string.IsNullOrEmpty(snapshot.Text)) + { + rows.Add(TextPanel(TailLines(snapshot.Text, liveTextTailLines))); + } + + return new Rows(rows); + } + + public static IRenderable RenderFinal(AssistantStatusSnapshot snapshot) + { + var rows = new List(); + AppendActivities(rows, snapshot); + if (!string.IsNullOrEmpty(snapshot.Text)) + { + rows.Add(TextPanel(snapshot.Text)); + } + + if (UsageLine(snapshot.Usage) is { } usage) + { + rows.Add(usage); + } + + return new Rows(rows); + } + + public static IRenderable RenderFailed(AssistantStatusSnapshot? snapshot, Exception exception) + { + var rows = new List(); + if (snapshot is not null) + { + // Request-level failure is reported out-of-band (observers only), so the last + // snapshot still says Running — flip the running cards to failed for display. + AppendActivities(rows, snapshot, forceRunningToFailed: true); + } + + rows.Add(new Panel(new Markup($"[red]{Markup.Escape(exception.Message)}[/]")) + .Header("[red]request failed[/]") + .BorderColor(Color.Red)); + return new Rows(rows); + } + + private static IRenderable Header(AssistantStatusSnapshot snapshot) + { + return snapshot.Phase switch + { + ActivityState.Completed => new Markup("[green]✓[/] [bold]Done[/]"), + ActivityState.Failed => new Markup("[red]✗[/] [bold]Failed[/]"), + _ => new Markup($"[yellow]●[/] [bold]{Markup.Escape(snapshot.AssistantStatus ?? "Working…")}[/]"), + }; + } + + private static void AppendActivities( + List rows, + AssistantStatusSnapshot snapshot, + bool forceRunningToFailed = false) + { + if (snapshot.Activities.Count == 0) + { + return; + } + + var tree = new Tree("[dim]activity[/]"); + foreach (AssistantActivity activity in snapshot.Activities) + { + AddActivityNode(tree, activity, forceRunningToFailed); + } + + rows.Add(tree); + } + + private static void AddActivityNode(IHasTreeNodes parent, AssistantActivity activity, bool forceRunningToFailed) + { + ActivityState state = forceRunningToFailed && activity.State == ActivityState.Running + ? ActivityState.Failed + : activity.State; + string glyph = state switch + { + ActivityState.Completed => "[green]✓[/]", + ActivityState.Failed => "[red]✗[/]", + _ => "[yellow]●[/]", + }; + string badge = activity.Kind switch + { + ToolKind.Function => "[white on blue] fn [/]", + ToolKind.McpTool => "[white on purple] mcp [/]", + ToolKind.Agent => "[black on green] agent [/]", + _ => "[black on grey] tool [/]", + }; + string duration = activity.DurationSeconds is { } seconds ? $" [dim]{seconds:0.0}s[/]" : string.Empty; + string usage = activity.Usage?.TotalTokens is { } tokens ? $" [dim]· {tokens:N0} tok[/]" : string.Empty; + + TreeNode node = parent.AddNode(new Markup( + $"{glyph} [bold]{Markup.Escape(activity.DisplayName)}[/] {badge}{duration}{usage}")); + + foreach (SubStatus subStatus in activity.SubStatuses) + { + node.AddNode(new Markup(SubStatusMarkup(subStatus))); + } + + foreach (AssistantActivity child in activity.Children) + { + AddActivityNode(node, child, forceRunningToFailed); + } + } + + private static string SubStatusMarkup(SubStatus subStatus) + { + string text = $"[grey]{Markup.Escape(subStatus.Message)}[/]"; + if (subStatus is { Progress: { } progress, ProgressTotal: { } total } && total > 0) + { + const int width = 20; + int filled = Math.Clamp((int)Math.Round(width * progress / total), 0, width); + text += $" [green]{new string('█', filled)}[/][grey]{new string('░', width - filled)}[/]" + + $" [dim]{progress / total:P0}[/]"; + } + + return text; + } + + private static IRenderable TextPanel(string text) + { + return new Panel(new Markup(Markup.Escape(text))) + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey) + .Header("[dim]assistant[/]"); + } + + private static string TailLines(string text, int maxLines) + { + string[] lines = text.Split('\n'); + if (lines.Length <= maxLines) + { + return text; + } + + return "…\n" + string.Join('\n', lines[^maxLines..]); + } + + private static IRenderable? UsageLine(UsageSummary? usage) + { + if (usage is null) + { + return null; + } + + return new Markup( + $"[dim]tokens: in {Format(usage.InputTokens)} · out {Format(usage.OutputTokens)} · total {Format(usage.TotalTokens)}[/]"); + + static string Format(long? tokens) + { + return tokens?.ToString("N0") ?? "—"; + } + } +} diff --git a/samples/Andes.Extensions.AI.Demo/appsettings.sample.json b/samples/Andes.Extensions.AI.Demo/appsettings.sample.json new file mode 100644 index 0000000..6414e25 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo/appsettings.sample.json @@ -0,0 +1,7 @@ +{ + "AzureOpenAI": { + "Endpoint": "https://your-resource.openai.azure.com/", + "ApiKey": "", + "Deployment": "" + } +} diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props new file mode 100644 index 0000000..d8cf863 --- /dev/null +++ b/samples/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + + false + false + $(NoWarn);CS1591 + + + diff --git a/tests/Andes.Extensions.AI.Agent.Integration.Test/AgentToolTrackingIntegrationTests.cs b/tests/Andes.Extensions.AI.Agent.Integration.Test/AgentToolTrackingIntegrationTests.cs index a939d26..5c811cc 100644 --- a/tests/Andes.Extensions.AI.Agent.Integration.Test/AgentToolTrackingIntegrationTests.cs +++ b/tests/Andes.Extensions.AI.Agent.Integration.Test/AgentToolTrackingIntegrationTests.cs @@ -19,21 +19,41 @@ private static string GetWeather([Description("The city to get the weather for." private AIAgent CreateWeatherAgent() { - // The inner agent runs over a raw (untracked) chat client, so the only usage the outer - // pipeline can attribute to the agent tool comes from WithTracking's usage capture. - IChatClient rawClient = new AzureOpenAIClient( - new Uri(_fixture.Settings.Endpoint!), - new AzureKeyCredential(_fixture.Settings.ApiKey!)) - .GetChatClient(_fixture.Settings.Deployment!) - .AsIChatClient(); - - return rawClient.AsAIAgent( + return CreateRawClient().AsAIAgent( instructions: "You answer questions about the weather using the GetWeather tool.", name: "Weather Agent", description: "Answers questions about the weather in a given city.", tools: [AIFunctionFactory.Create(GetWeather)]); } + private AIAgent CreatePackingAgent() + { + return CreateRawClient().AsAIAgent( + instructions: "You suggest what to pack for a destination in one short sentence.", + name: "Packing Agent", + description: "Suggests what to pack for a destination."); + } + + private AIAgent CreateResearchAgent() + { + return CreateRawClient().AsAIAgent( + instructions: "You research travel topics. Always call the Packing Agent tool once and base your answer on its reply.", + name: "Research Agent", + description: "Researches what to pack for a destination.", + tools: [CreatePackingAgent().WithTracking()]); + } + + private IChatClient CreateRawClient() + { + // Inner agents run over raw (untracked) chat clients, so the only usage the outer + // pipeline can attribute to an agent tool comes from WithTracking's usage capture. + return new AzureOpenAIClient( + new Uri(_fixture.Settings.Endpoint!), + new AzureKeyCredential(_fixture.Settings.ApiKey!)) + .GetChatClient(_fixture.Settings.Deployment!) + .AsIChatClient(); + } + [SkippableFact] public async Task GetStreamingResponseAsync_AgentAsTool_EmitsAgentHeaderAndClassification() { @@ -55,7 +75,7 @@ [new ChatMessage(ChatRole.User, "Use the Weather Agent tool to get the weather i ChatProgressUpdate? invoking = observer.Updates.FirstOrDefault(update => update.Kind == ChatProgressKind.ToolInvoking && update.ToolKind == ToolKind.Agent); Assert.NotNull(invoking); - Assert.Equal("Calling Weather Agent Agent", invoking.Message); + Assert.Equal("Calling Weather Agent", invoking.Message); Assert.Equal("Weather_Agent", invoking.ToolName); Assert.Equal("Weather Agent", invoking.ToolSource); @@ -98,6 +118,49 @@ [new ChatMessage(ChatRole.User, "Use the Weather Agent tool to get the weather i "The report total should exceed the assistant-only usage because the agent's usage is included."); } + [SkippableFact] + public async Task GetStreamingResponseAsync_AgentToolInsideAgent_EmitsChildScopeWithUsage() + { + Skip.IfNot(_fixture.IsConfigured, AzureOpenAIFixture.SkipReason); + + var observer = new RecordingObserver(); + IChatClient client = _fixture.CreatePipeline(options => + { + options.UseAgentToolClassification(); + options.Observers.Add(observer); + }); + + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync( + [new ChatMessage(ChatRole.User, "Use the Research Agent tool to find out what to pack for Quito, then confirm.")], + new ChatOptions { Tools = [CreateResearchAgent().WithTracking()] })) + { + } + + ChatProgressUpdate? childInvoking = observer.Updates.FirstOrDefault(update => + update.Kind == ChatProgressKind.ToolInvoking && update.ToolName == "Packing_Agent"); + Assert.NotNull(childInvoking); + Assert.Equal(ToolKind.Agent, childInvoking.ToolKind); + Assert.Equal("Packing Agent", childInvoking.ToolSource); + Assert.NotNull(childInvoking.ParentScopeId); + ChatProgressUpdate? parentInvoking = observer.Updates.FirstOrDefault(update => + update.Kind == ChatProgressKind.ToolInvoking && update.ScopeId == childInvoking.ParentScopeId); + Assert.NotNull(parentInvoking); + Assert.Equal("Research_Agent", parentInvoking.ToolName); + + Assert.NotNull(observer.Report); + ToolCallUsage? researchCall = observer.Report.ToolCalls.FirstOrDefault(call => + call.ToolName == "Research_Agent" && call.Children.Any(child => child.ToolName == "Packing_Agent")); + Assert.NotNull(researchCall); + ToolCallUsage packingCall = researchCall.Children.First(child => child.ToolName == "Packing_Agent"); + Assert.Equal(ToolKind.Agent, packingCall.Kind); + Assert.NotNull(packingCall.Usage); + Assert.True(packingCall.Usage.TotalTokenCount > 0, "The nested agent's run should attribute non-zero usage to its child scope."); + Assert.NotNull(researchCall.Usage); + Assert.True( + researchCall.Usage.TotalTokenCount > packingCall.Usage.TotalTokenCount, + "The parent agent's rollup should include its own usage on top of the nested agent's."); + } + private sealed class RecordingObserver : IChatProgressObserver { private readonly Lock _lock = new(); diff --git a/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentFunctionCallReportingTests.cs b/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentFunctionCallReportingTests.cs index 5c7dfec..9eebb42 100644 --- a/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentFunctionCallReportingTests.cs +++ b/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentFunctionCallReportingTests.cs @@ -18,7 +18,7 @@ public async Task WithTracking_ReportFunctionCalls_EmitsCallingFunctionSubheader update => update.Kind == ChatProgressKind.ToolProgress && update.Message == "Calling GetWeather Tool"); Assert.Equal("Weather_Agent", subheader.ToolName); ChatProgressUpdate invoking = Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); - Assert.Equal("Calling Weather Agent Agent", invoking.Message); + Assert.Equal("Calling Weather Agent", invoking.Message); } [Fact] diff --git a/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentToolClassificationTests.cs b/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentToolClassificationTests.cs index b5a8545..ad989d8 100644 --- a/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentToolClassificationTests.cs +++ b/tests/Andes.Extensions.AI.Agent.Unit.Test/AgentToolClassificationTests.cs @@ -133,7 +133,7 @@ public async Task AgentHeader_EndToEnd_EmitsCallingAgentAgent() List progress = TestPipeline.ProgressOf(updates); ChatProgressUpdate invoking = Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); - Assert.Equal("Calling Weather Agent Agent", invoking.Message); + Assert.Equal("Calling Weather Agent", invoking.Message); Assert.Equal(ToolKind.Agent, invoking.ToolKind); Assert.Equal("Weather Agent", invoking.ToolSource); diff --git a/tests/Andes.Extensions.AI.Agent.Unit.Test/NestedAgentScopeTests.cs b/tests/Andes.Extensions.AI.Agent.Unit.Test/NestedAgentScopeTests.cs new file mode 100644 index 0000000..0756699 --- /dev/null +++ b/tests/Andes.Extensions.AI.Agent.Unit.Test/NestedAgentScopeTests.cs @@ -0,0 +1,215 @@ +using Andes.Extensions.AI.Unit.Test.Infrastructure; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.Agent.Unit.Test; + +public class NestedAgentScopeTests +{ + [Fact] + public async Task WithTracking_AgentToolInsideAgent_EmitsChildScopeInOuterStream() + { + AIFunction packing = CreatePackingAgent(ScriptedTurn.Text("packed", Usage(100))).WithTracking(); + AIAgent research = CreateResearchAgent(packing); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Research_Agent", new Dictionary { ["query"] = "pack?" }), + ScriptedTurn.Text("Done.", Usage(30))); + IChatClient client = TestPipeline.Build(scripted, options => options.UseAgentToolClassification()); + + List updates = await TestPipeline.CollectAsync( + client, + new ChatOptions { Tools = [research.WithTracking()] }); + List progress = TestPipeline.ProgressOf(updates); + + List invoking = progress.Where(update => update.Kind == ChatProgressKind.ToolInvoking).ToList(); + Assert.Equal(2, invoking.Count); + ChatProgressUpdate outer = invoking[0]; + ChatProgressUpdate child = invoking[1]; + Assert.Equal("Research_Agent", outer.ToolName); + Assert.Equal("Packing_Agent", child.ToolName); + Assert.Equal(ToolKind.Agent, child.ToolKind); + Assert.Equal("Packing Agent", child.ToolSource); + Assert.Equal(outer.ScopeId, child.ParentScopeId); + Assert.Equal(outer.Depth + 1, child.Depth); + Assert.Equal("inner-call", child.CallId); + + int childCompleted = progress.FindIndex(update => + update.Kind == ChatProgressKind.ToolCompleted && update.ScopeId == child.ScopeId); + int outerCompleted = progress.FindIndex(update => + update.Kind == ChatProgressKind.ToolCompleted && update.ScopeId == outer.ScopeId); + Assert.True(childCompleted >= 0 && outerCompleted >= 0 && childCompleted < outerCompleted); + } + + [Fact] + public async Task WithTracking_AgentToolInsideAgent_UsageAttributedToChildScope() + { + AIFunction packing = CreatePackingAgent(ScriptedTurn.Text("packed", Usage(100))).WithTracking(); + AIAgent research = CreateResearchAgent(packing); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Research_Agent", new Dictionary { ["query"] = "pack?" }), + ScriptedTurn.Text("Done.", Usage(30))); + IChatClient client = TestPipeline.Build(scripted, options => options.UseAgentToolClassification()); + + List updates = await TestPipeline.CollectAsync( + client, + new ChatOptions { Tools = [research.WithTracking()] }); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + ToolCallUsage call = Assert.Single(report.ToolCalls); + ToolCallUsage child = Assert.Single(call.Children); + Assert.Equal("Packing_Agent", child.ToolName); + Assert.Equal(ToolKind.Agent, child.Kind); + Assert.NotNull(child.Usage); + Assert.Equal(100, child.Usage.TotalTokenCount); + Assert.NotNull(call.Usage); + Assert.Equal(150, call.Usage.TotalTokenCount); + Assert.Equal(180, report.TotalUsage.TotalTokenCount); + } + + [Fact] + public async Task WithTracking_InvokedDirectlyInsideFunctionTool_OpensChildScopeWithNullCallId() + { + AIFunction packing = CreatePackingAgent(ScriptedTurn.Text("packed", Usage(100))).WithTracking(); + AIFunction planTrip = AIFunctionFactory.Create( + async (CancellationToken cancellationToken) => + (string?)await packing.InvokeAsync(new AIFunctionArguments { ["query"] = "pack?" }, cancellationToken), + "PlanTrip"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "PlanTrip"), + ScriptedTurn.Text("Done.", Usage(30))); + IChatClient client = TestPipeline.Build(scripted, options => options.UseAgentToolClassification()); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [planTrip] }); + List progress = TestPipeline.ProgressOf(updates); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + List invoking = progress.Where(update => update.Kind == ChatProgressKind.ToolInvoking).ToList(); + Assert.Equal(2, invoking.Count); + Assert.Equal("PlanTrip", invoking[0].ToolName); + Assert.Equal(ToolKind.Function, invoking[0].ToolKind); + Assert.Equal("Packing_Agent", invoking[1].ToolName); + Assert.Equal(ToolKind.Agent, invoking[1].ToolKind); + Assert.Equal(invoking[0].ScopeId, invoking[1].ParentScopeId); + Assert.Null(invoking[1].CallId); + + ToolCallUsage call = Assert.Single(report.ToolCalls); + ToolCallUsage child = Assert.Single(call.Children); + Assert.NotNull(child.Usage); + Assert.Equal(100, child.Usage.TotalTokenCount); + Assert.NotNull(call.Usage); + Assert.Equal(100, call.Usage.TotalTokenCount); + Assert.Equal(130, report.TotalUsage.TotalTokenCount); + } + + [Fact] + public async Task WithTracking_WrappedByOuterTracker_OpensExactlyOneScope() + { + AIFunction packing = CreatePackingAgent(ScriptedTurn.Text("packed", Usage(100))).WithTracking(); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Packing_Agent", new Dictionary { ["query"] = "pack?" }), + ScriptedTurn.Text("Done.", Usage(30))); + IChatClient client = TestPipeline.Build(scripted, options => options.UseAgentToolClassification()); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [packing] }); + List progress = TestPipeline.ProgressOf(updates); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); + ToolCallUsage call = Assert.Single(report.ToolCalls); + Assert.Empty(call.Children); + Assert.NotNull(call.Usage); + Assert.Equal(100, call.Usage.TotalTokenCount); + Assert.Equal(130, report.TotalUsage.TotalTokenCount); + } + + [Fact] + public async Task WithTracking_NestedAgentRunFails_ChildScopeMarkedFailed() + { + AIFunction packing = CreatePackingAgent().WithTracking(); + AIFunction planTrip = AIFunctionFactory.Create( + async (CancellationToken cancellationToken) => + { + try + { + await packing.InvokeAsync(new AIFunctionArguments { ["query"] = "pack?" }, cancellationToken); + } + catch (InvalidOperationException) + { + // The empty script throws; the tool itself still succeeds. + } + + return "planned"; + }, + "PlanTrip"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "PlanTrip"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [planTrip] }); + List progress = TestPipeline.ProgressOf(updates); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolFailed && update.ToolName == "Packing_Agent"); + ToolCallUsage call = Assert.Single(report.ToolCalls); + Assert.True(call.Succeeded); + ToolCallUsage child = Assert.Single(call.Children); + Assert.False(child.Succeeded); + } + + [Fact] + public async Task WithTracking_NestedAgentWithTrackUsageFalse_ChildScopeHasNoOwnUsage() + { + AIFunction packing = CreatePackingAgent(ScriptedTurn.Text("packed", Usage(100))).WithTracking(trackUsage: false); + AIFunction planTrip = AIFunctionFactory.Create( + async (CancellationToken cancellationToken) => + (string?)await packing.InvokeAsync(new AIFunctionArguments { ["query"] = "pack?" }, cancellationToken), + "PlanTrip"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "PlanTrip"), + ScriptedTurn.Text("Done.", Usage(30))); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [planTrip] }); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + ToolCallUsage call = Assert.Single(report.ToolCalls); + ToolCallUsage child = Assert.Single(call.Children); + Assert.Null(child.Usage); + Assert.Equal(30, report.TotalUsage.TotalTokenCount); + } + + [Fact] + public async Task WithTracking_InvokedOutsideTrackedRequest_RunsWithoutScope() + { + AIFunction packing = CreatePackingAgent(ScriptedTurn.Text("packed")).WithTracking(); + + object? result = await packing.InvokeAsync(new AIFunctionArguments { ["query"] = "pack?" }); + + Assert.NotNull(result); + } + + private static AIAgent CreatePackingAgent(params ScriptedTurn[] turns) + { + return new ScriptedChatClient(turns).AsAIAgent( + instructions: "You suggest what to pack.", + name: "Packing Agent"); + } + + private static AIAgent CreateResearchAgent(AIFunction packingTool) + { + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("inner-call", "Packing_Agent", new Dictionary { ["query"] = "pack?" }), + ScriptedTurn.Text("researched", Usage(50))); + return scripted.AsAIAgent( + instructions: "You research travel topics.", + name: "Research Agent", + description: "Researches travel topics.", + tools: [packingTool]); + } + + private static UsageDetails Usage(int total) + { + return new UsageDetails { TotalTokenCount = total }; + } +} diff --git a/tests/Andes.Extensions.AI.Mcp.Integration.Test/McpToolTrackingIntegrationTests.cs b/tests/Andes.Extensions.AI.Mcp.Integration.Test/McpToolTrackingIntegrationTests.cs index 8f97372..cfa0c43 100644 --- a/tests/Andes.Extensions.AI.Mcp.Integration.Test/McpToolTrackingIntegrationTests.cs +++ b/tests/Andes.Extensions.AI.Mcp.Integration.Test/McpToolTrackingIntegrationTests.cs @@ -47,7 +47,7 @@ [new ChatMessage(ChatRole.User, "Use the echo tool to echo the text 'integration update.Kind == ChatProgressKind.ToolInvoking && update.ToolKind == ToolKind.McpTool); Assert.NotNull(invoking); Assert.Equal("Andes Test MCP", invoking.ToolSource); - Assert.Equal("Calling Andes Test MCP MCP", invoking.Message); + Assert.Equal("Calling Andes Test MCP", invoking.Message); Assert.Equal("echo", invoking.ToolName); Assert.NotNull(observer.Report); diff --git a/tests/Andes.Extensions.AI.Mcp.Unit.Test/McpToolClassificationTests.cs b/tests/Andes.Extensions.AI.Mcp.Unit.Test/McpToolClassificationTests.cs index a726aa6..beb195e 100644 --- a/tests/Andes.Extensions.AI.Mcp.Unit.Test/McpToolClassificationTests.cs +++ b/tests/Andes.Extensions.AI.Mcp.Unit.Test/McpToolClassificationTests.cs @@ -96,7 +96,8 @@ public async Task McpHeader_EndToEnd_EmitsCallingServerMcp() List progress = TestPipeline.ProgressOf(updates); ChatProgressUpdate invoking = Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); - Assert.Equal($"Calling {InMemoryMcpFixture.ServerName} MCP", invoking.Message); + // ServerName already ends with "MCP", so the default header does not append the kind word again. + Assert.Equal($"Calling {InMemoryMcpFixture.ServerName}", invoking.Message); Assert.Equal(ToolKind.McpTool, invoking.ToolKind); Assert.Equal(InMemoryMcpFixture.ServerName, invoking.ToolSource); diff --git a/tests/Andes.Extensions.AI.Mcp.Unit.Test/NestedMcpScopeTests.cs b/tests/Andes.Extensions.AI.Mcp.Unit.Test/NestedMcpScopeTests.cs new file mode 100644 index 0000000..af2db51 --- /dev/null +++ b/tests/Andes.Extensions.AI.Mcp.Unit.Test/NestedMcpScopeTests.cs @@ -0,0 +1,116 @@ +using Andes.Extensions.AI.Mcp.Unit.Test.Infrastructure; +using Andes.Extensions.AI.Unit.Test.Infrastructure; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +namespace Andes.Extensions.AI.Mcp.Unit.Test; + +public class NestedMcpScopeTests(InMemoryMcpFixture fixture) : IClassFixture +{ + private readonly InMemoryMcpFixture _fixture = fixture; + + [Fact] + public async Task WithTracking_InvokedDirectlyInsideFunctionTool_OpensChildScope() + { + AIFunction echo = _fixture.EchoTool.WithTracking(_fixture.Client); + AIFunction outer = AIFunctionFactory.Create( + async (CancellationToken cancellationToken) => + { + await echo.InvokeAsync(new AIFunctionArguments { ["message"] = "hi" }, cancellationToken); + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted, options => options.UseMcpToolClassification()); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [outer] }); + List progress = TestPipeline.ProgressOf(updates); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + List invoking = progress.Where(update => update.Kind == ChatProgressKind.ToolInvoking).ToList(); + Assert.Equal(2, invoking.Count); + Assert.Equal("Outer", invoking[0].ToolName); + Assert.Equal("echo", invoking[1].ToolName); + Assert.Equal(ToolKind.McpTool, invoking[1].ToolKind); + Assert.Equal(InMemoryMcpFixture.ServerName, invoking[1].ToolSource); + Assert.Equal(invoking[0].ScopeId, invoking[1].ParentScopeId); + Assert.Null(invoking[1].CallId); + Assert.Single( + progress, + update => update.Kind == ChatProgressKind.ToolCompleted && update.ScopeId == invoking[1].ScopeId); + + ToolCallUsage call = Assert.Single(report.ToolCalls); + ToolCallUsage child = Assert.Single(call.Children); + Assert.Equal("echo", child.ToolName); + Assert.Equal(ToolKind.McpTool, child.Kind); + Assert.True(child.Succeeded); + } + + [Fact] + public async Task WithTracking_BridgedProgressInsideNestedScope_LandsOnChildScope() + { + var ack = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _fixture.ProgressAck = ack; + int progressCount = 0; + var observer = new NotifyingProgressObserver + { + Callback = update => + { + if (update.Kind == ChatProgressKind.ToolProgress && Interlocked.Increment(ref progressCount) == 3) + { + ack.TrySetResult(); + } + }, + }; + AIFunction countDown = _fixture.CountDownTool.WithTracking(_fixture.Client); + AIFunction outer = AIFunctionFactory.Create( + async (CancellationToken cancellationToken) => + { + await countDown.InvokeAsync( + new AIFunctionArguments { ["steps"] = 3, ["waitForAck"] = true }, + cancellationToken); + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted, options => + { + options.UseMcpToolClassification(); + options.Observers.Add(observer); + }); + + await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [outer] }); + + ChatProgressUpdate childInvoking = Assert.Single( + observer.Updates, + update => update.Kind == ChatProgressKind.ToolInvoking && update.ToolName == "count_down"); + List statuses = observer.Updates + .Where(update => update.Kind == ChatProgressKind.ToolProgress) + .ToList(); + Assert.Equal(3, statuses.Count); + Assert.All(statuses, status => Assert.Equal(childInvoking.ScopeId, status.ScopeId)); + } + + [Fact] + public async Task WithTracking_WrappedByOuterTracker_OpensExactlyOneScope() + { + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "echo", new Dictionary { ["message"] = "hi" }), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted, options => options.UseMcpToolClassification()); + + List updates = await TestPipeline.CollectAsync( + client, + new ChatOptions { Tools = [_fixture.EchoTool.WithTracking(_fixture.Client)] }); + List progress = TestPipeline.ProgressOf(updates); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); + ToolCallUsage call = Assert.Single(report.ToolCalls); + Assert.Empty(call.Children); + } +} diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/Andes.Extensions.AI.UI.Unit.Test.csproj b/tests/Andes.Extensions.AI.UI.Unit.Test/Andes.Extensions.AI.UI.Unit.Test.csproj new file mode 100644 index 0000000..1a4a5f5 --- /dev/null +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/Andes.Extensions.AI.UI.Unit.Test.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs new file mode 100644 index 0000000..db6836e --- /dev/null +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs @@ -0,0 +1,114 @@ +namespace Andes.Extensions.AI.UI.Unit.Test; + +public class AssistantStatusReducerTests +{ + [Fact] + public void Apply_NestedActivities_BuildsHierarchyWithSubStatuses() + { + var reducer = new AssistantStatusReducer(); + + reducer.Apply(new AssistantUiEvent { Kind = AssistantUiEventKind.Status, Message = "Thinking…" }); + reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityStarted, + ScopeId = "scope-1", + DisplayName = "Research Agent", + ToolKind = ToolKind.Agent, + Source = "Research Agent", + }); + reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityStarted, + ScopeId = "scope-2", + ParentScopeId = "scope-1", + DisplayName = "SearchDocs", + ToolKind = ToolKind.Function, + }); + reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityProgress, + ScopeId = "scope-2", + Message = "Summarizing…", + }); + reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityCompleted, + ScopeId = "scope-2", + DurationSeconds = 1.5, + }); + AssistantStatusSnapshot snapshot = reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityCompleted, + ScopeId = "scope-1", + DurationSeconds = 2.1, + }); + + Assert.Equal("Thinking…", snapshot.AssistantStatus); + AssistantActivity agent = Assert.Single(snapshot.Activities); + Assert.Equal("Research Agent", agent.DisplayName); + Assert.Equal(ToolKind.Agent, agent.Kind); + Assert.Equal(ActivityState.Completed, agent.State); + Assert.Equal(2.1, agent.DurationSeconds); + + AssistantActivity child = Assert.Single(agent.Children); + Assert.Equal("SearchDocs", child.DisplayName); + Assert.Equal(ActivityState.Completed, child.State); + Assert.Equal(1.5, child.DurationSeconds); + + SubStatus sub = Assert.Single(child.SubStatuses); + Assert.Equal("Summarizing…", sub.Message); + } + + [Fact] + public void Apply_ActivityFailed_MarksCardFailed() + { + var reducer = new AssistantStatusReducer(); + + reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityStarted, + ScopeId = "scope-1", + DisplayName = "GetForecast", + ToolKind = ToolKind.Function, + }); + AssistantStatusSnapshot snapshot = reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityFailed, + ScopeId = "scope-1", + DurationSeconds = 0.2, + }); + + AssistantActivity activity = Assert.Single(snapshot.Activities); + Assert.Equal(ActivityState.Failed, activity.State); + } + + [Fact] + public void Apply_FinishedEvent_SetsPhaseAndUsage() + { + var reducer = new AssistantStatusReducer(); + + AssistantStatusSnapshot snapshot = reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.Finished, + Usage = new UsageSummary { InputTokens = 10, OutputTokens = 20, TotalTokens = 30 }, + }); + + Assert.Equal(ActivityState.Completed, snapshot.Phase); + Assert.Equal(30, snapshot.Usage?.TotalTokens); + } + + [Fact] + public void Apply_TextDelta_AccumulatesText() + { + var reducer = new AssistantStatusReducer(); + + reducer.Apply(new AssistantUiEvent { Kind = AssistantUiEventKind.TextDelta, Text = "Hello " }); + AssistantStatusSnapshot snapshot = reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.TextDelta, + Text = "world", + }); + + Assert.Equal("Hello world", snapshot.Text); + } +} diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantUiJsonContextTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantUiJsonContextTests.cs new file mode 100644 index 0000000..b7650bd --- /dev/null +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantUiJsonContextTests.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +namespace Andes.Extensions.AI.UI.Unit.Test; + +public class AssistantUiJsonContextTests +{ + [Fact] + public void Serialize_Snapshot_UsesCamelCaseStringEnumsAndOmitsNulls() + { + var snapshot = new AssistantStatusSnapshot + { + AssistantStatus = "Working", + Phase = ActivityState.Running, + Activities = + [ + new AssistantActivity + { + ScopeId = "scope-1", + DisplayName = "Andes Test MCP", + Kind = ToolKind.McpTool, + Source = "Andes Test MCP", + State = ActivityState.Completed, + Children = + [ + new AssistantActivity + { + ScopeId = "scope-2", + DisplayName = "SearchDocs", + Kind = ToolKind.Function, + }, + ], + }, + ], + }; + + string json = JsonSerializer.Serialize(snapshot, AssistantUiJsonContext.Default.AssistantStatusSnapshot); + + Assert.Contains("\"displayName\":\"Andes Test MCP\"", json); + Assert.Contains("\"kind\":\"McpTool\"", json); + Assert.Contains("\"activities\":", json); + Assert.DoesNotContain("MCP MCP", json); + Assert.DoesNotContain("\"usage\"", json); + Assert.DoesNotContain("\"text\"", json); + } + + [Fact] + public void SerializeRoundTrip_Event_PreservesFields() + { + var uiEvent = new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityStarted, + ScopeId = "scope-1", + DisplayName = "Research Agent", + ToolKind = ToolKind.Agent, + Source = "Research Agent", + Depth = 1, + }; + + string json = JsonSerializer.Serialize(uiEvent, AssistantUiJsonContext.Default.AssistantUiEvent); + AssistantUiEvent? roundTripped = JsonSerializer.Deserialize(json, AssistantUiJsonContext.Default.AssistantUiEvent); + + Assert.Contains("\"kind\":\"ActivityStarted\"", json); + Assert.Contains("\"toolKind\":\"Agent\"", json); + Assert.NotNull(roundTripped); + Assert.Equal(AssistantUiEventKind.ActivityStarted, roundTripped!.Kind); + Assert.Equal("Research Agent", roundTripped.DisplayName); + Assert.Equal(ToolKind.Agent, roundTripped.ToolKind); + } +} diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs new file mode 100644 index 0000000..4738f3e --- /dev/null +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs @@ -0,0 +1,105 @@ +using Andes.Extensions.AI.Unit.Test.Infrastructure; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.UI.Unit.Test; + +public class ChatResponseUiExtensionsTests +{ + [Fact] + public async Task ToUiEventsAsync_FunctionTool_ProducesCleanDisplayNameAndKind() + { + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "GetForecast"), + ScriptedTurn.Text("Done.")); + AIFunction tool = AIFunctionFactory.Create( + () => + { + ChatProgress.Report("Contacting the forecast service…"); + return "sunny"; + }, + "GetForecast"); + IChatClient client = TestPipeline.Build(scripted); + + List events = await CollectAsync(client, new ChatOptions { Tools = [tool] }); + + AssistantUiEvent started = Assert.Single(events, e => e.Kind == AssistantUiEventKind.ActivityStarted); + Assert.Equal("GetForecast", started.DisplayName); + Assert.Equal(ToolKind.Function, started.ToolKind); + Assert.Contains( + events, + e => e.Kind == AssistantUiEventKind.ActivityProgress && e.Message == "Contacting the forecast service…"); + Assert.Contains(events, e => e.Kind == AssistantUiEventKind.ActivityCompleted); + Assert.Contains(events, e => e.Kind == AssistantUiEventKind.Finished); + } + + [Theory] + [InlineData(ToolKind.McpTool, "Andes Test MCP")] + [InlineData(ToolKind.Agent, "Research Agent")] + public async Task ToUiEventsAsync_NameEndingWithKindWord_DisplayNameHasNoRepeat(ToolKind kind, string source) + { + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "do_work"), + ScriptedTurn.Text("Done.")); + AIFunction tool = AIFunctionFactory.Create(() => "ok", "do_work"); + IChatClient client = TestPipeline.Build(scripted, options => + options.ToolClassifier = candidate => new ToolDescriptor + { + Name = candidate.Name, + Kind = kind, + Source = source, + }); + + List events = await CollectAsync(client, new ChatOptions { Tools = [tool] }); + + AssistantUiEvent started = Assert.Single(events, e => e.Kind == AssistantUiEventKind.ActivityStarted); + Assert.Equal(source, started.DisplayName); + Assert.Equal(kind, started.ToolKind); + Assert.DoesNotContain("MCP MCP", started.DisplayName); + Assert.DoesNotContain("Agent Agent", started.DisplayName); + } + + [Fact] + public async Task ToStatusSnapshotsAsync_FunctionTool_FoldsIntoCompletedActivityCard() + { + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "GetForecast"), + ScriptedTurn.Text("Done.")); + AIFunction tool = AIFunctionFactory.Create( + () => + { + ChatProgress.Report("Working…"); + return "sunny"; + }, + "GetForecast"); + IChatClient client = TestPipeline.Build(scripted); + + AssistantStatusSnapshot? last = null; + await foreach (AssistantStatusSnapshot snapshot in client + .GetStreamingResponseAsync("prompt", new ChatOptions { Tools = [tool] }) + .ToStatusSnapshotsAsync()) + { + last = snapshot; + } + + Assert.NotNull(last); + AssistantActivity activity = Assert.Single(last!.Activities); + Assert.Equal("GetForecast", activity.DisplayName); + Assert.Equal(ToolKind.Function, activity.Kind); + Assert.Equal(ActivityState.Completed, activity.State); + Assert.Contains(activity.SubStatuses, s => s.Message == "Working…"); + Assert.Equal(ActivityState.Completed, last.Phase); + } + + private static async Task> CollectAsync(IChatClient client, ChatOptions options) + { + var events = new List(); + await foreach (AssistantUiEvent uiEvent in client + .GetStreamingResponseAsync("prompt", options) + .ToUiEventsAsync()) + { + events.Add(uiEvent); + } + + return events; + } +} diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/LiveNestedActivityTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/LiveNestedActivityTests.cs new file mode 100644 index 0000000..0b29a79 --- /dev/null +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/LiveNestedActivityTests.cs @@ -0,0 +1,88 @@ +using Andes.Extensions.AI.Unit.Test.Infrastructure; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.UI.Unit.Test; + +public class LiveNestedActivityTests +{ + [Fact] + public async Task ToStatusSnapshotsAsync_NestedScopeInsideTool_ProducesChildActivityLive() + { + AIFunction tool = AIFunctionFactory.Create( + () => + { + using (ChatProgress.BeginToolScope(NestedDescriptor())) + { + ChatProgress.Report("Condensing…"); + } + + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.", new UsageDetails { TotalTokenCount = 20 })); + IChatClient client = TestPipeline.Build(scripted); + + AssistantStatusSnapshot? last = null; + await foreach (AssistantStatusSnapshot snapshot in client + .GetStreamingResponseAsync("prompt", new ChatOptions { Tools = [tool] }) + .ToStatusSnapshotsAsync()) + { + last = snapshot; + } + + Assert.NotNull(last); + AssistantActivity root = Assert.Single(last.Activities); + Assert.Equal(ActivityState.Completed, root.State); + AssistantActivity child = Assert.Single(root.Children); + Assert.Equal("Summarizer", child.DisplayName); + Assert.Equal(ToolKind.Agent, child.Kind); + Assert.Equal(ActivityState.Completed, child.State); + SubStatus subStatus = Assert.Single(child.SubStatuses); + Assert.Equal("Condensing…", subStatus.Message); + } + + [Fact] + public async Task ToSnapshot_ReportWithNestedChildren_MapsChildActivitiesWithUsage() + { + AIFunction tool = AIFunctionFactory.Create( + () => + { + using (ChatProgress.BeginToolScope(NestedDescriptor())) + { + ChatProgress.ReportUsage(new UsageDetails { TotalTokenCount = 5 }); + } + + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.", new UsageDetails { TotalTokenCount = 20 })); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + AssistantStatusSnapshot snapshot = TestPipeline.ReportOf(updates).ToSnapshot(); + + AssistantActivity root = Assert.Single(snapshot.Activities); + Assert.NotNull(root.Usage); + Assert.Equal(5, root.Usage.TotalTokens); + AssistantActivity child = Assert.Single(root.Children); + Assert.Equal("Summarizer", child.DisplayName); + Assert.Equal(ToolKind.Agent, child.Kind); + Assert.NotNull(child.Usage); + Assert.Equal(5, child.Usage.TotalTokens); + } + + private static ToolDescriptor NestedDescriptor() + { + return new ToolDescriptor + { + Name = "summarize", + DisplayName = "Summarizer", + Kind = ToolKind.Agent, + Source = "Summarizer", + }; + } +} diff --git a/tests/Andes.Extensions.AI.Unit.Test/ChatProgressToolScopeTests.cs b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressToolScopeTests.cs new file mode 100644 index 0000000..2983e32 --- /dev/null +++ b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressToolScopeTests.cs @@ -0,0 +1,372 @@ +using Andes.Extensions.AI.Unit.Test.Infrastructure; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.Unit.Test; + +public class ChatProgressToolScopeTests +{ + [Fact] + public void BeginToolScope_OutsideTrackedRequest_ReturnsInactiveNoOp() + { + using ChatProgressToolScope scope = ChatProgress.BeginToolScope(new ToolDescriptor { Name = "nested" }); + + Assert.False(scope.IsActive); + Assert.Null(scope.ScopeId); + scope.Fail(); + scope.Dispose(); + } + + [Fact] + public void BeginToolScope_NullDescriptor_Throws() + { + Assert.Throws(() => ChatProgress.BeginToolScope(null!)); + } + + [Fact] + public async Task BeginToolScope_InsideTool_EmitsChildInvokingWithParentScopeAndDepth() + { + bool wasActive = false; + AIFunction tool = AIFunctionFactory.Create( + () => + { + using ChatProgressToolScope scope = ChatProgress.BeginToolScope( + new ToolDescriptor { Name = "nested", DisplayName = "Nested", Kind = ToolKind.Function }); + wasActive = scope.IsActive; + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + Assert.True(wasActive); + List invoking = progress.Where(update => update.Kind == ChatProgressKind.ToolInvoking).ToList(); + Assert.Equal(2, invoking.Count); + ChatProgressUpdate outer = invoking[0]; + ChatProgressUpdate child = invoking[1]; + Assert.Equal("nested", child.ToolName); + Assert.Equal(outer.ScopeId, child.ParentScopeId); + Assert.Equal(outer.Depth + 1, child.Depth); + + ChatProgressUpdate completed = Assert.Single( + progress, + update => update.Kind == ChatProgressKind.ToolCompleted && update.ScopeId == child.ScopeId); + Assert.NotNull(completed.Duration); + } + + [Fact] + public async Task BeginToolScope_OwnerMatchesAmbientScope_OpensNoSecondScope() + { + AIFunction inner = AIFunctionFactory.Create(() => "done", "Nested"); + var wrapper = new ChildScopeFunction(inner, new ToolDescriptor { Name = "Nested" }); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Nested"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [wrapper] }); + List progress = TestPipeline.ProgressOf(updates); + + Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); + Assert.False(wrapper.LastScopeWasActive); + } + + [Fact] + public async Task BeginToolScope_OwnerBehindUserDelegatingWrapper_StillDeduplicates() + { + AIFunction inner = AIFunctionFactory.Create(() => "done", "Nested"); + var wrapper = new ChildScopeFunction(inner, new ToolDescriptor { Name = "Nested" }); + var userWrapped = new PassThroughFunction(wrapper); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Nested"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [userWrapped] }); + List progress = TestPipeline.ProgressOf(updates); + + Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); + Assert.False(wrapper.LastScopeWasActive); + } + + [Fact] + public async Task BeginToolScope_NullOwner_AlwaysOpensScope() + { + AIFunction inner = AIFunctionFactory.Create(() => "done", "Nested"); + var wrapper = new ChildScopeFunction(inner, new ToolDescriptor { Name = "Nested" }, passOwner: false); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Nested"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [wrapper] }); + List progress = TestPipeline.ProgressOf(updates); + + Assert.Equal(2, progress.Count(update => update.Kind == ChatProgressKind.ToolInvoking)); + Assert.True(wrapper.LastScopeWasActive); + } + + [Fact] + public async Task BeginToolScope_Fail_EmitsToolFailedAndReportMarksChildFailed() + { + string? childScopeId = null; + AIFunction tool = AIFunctionFactory.Create( + () => + { + using ChatProgressToolScope scope = ChatProgress.BeginToolScope( + new ToolDescriptor { Name = "nested", DisplayName = "Nested" }); + childScopeId = scope.ScopeId; + scope.Fail(); + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + Assert.NotNull(childScopeId); + Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolFailed && update.ScopeId == childScopeId); + ToolCallUsage call = Assert.Single(report.ToolCalls); + Assert.True(call.Succeeded); + ToolCallUsage child = Assert.Single(call.Children); + Assert.False(child.Succeeded); + } + + [Fact] + public async Task BeginToolScope_ReportUsageInsideChild_AttributedToChildAndRolledUpToParent() + { + AIFunction tool = AIFunctionFactory.Create( + () => + { + ChatProgress.ReportUsage(new UsageDetails { TotalTokenCount = 2 }); + using (ChatProgress.BeginToolScope(new ToolDescriptor { Name = "nested" })) + { + ChatProgress.ReportUsage(new UsageDetails { TotalTokenCount = 5 }); + } + + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.", new UsageDetails { TotalTokenCount = 20 })); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + ChatUsageReport report = TestPipeline.ReportOf(updates); + + ToolCallUsage call = Assert.Single(report.ToolCalls); + Assert.NotNull(call.Usage); + Assert.Equal(7, call.Usage.TotalTokenCount); + ToolCallUsage child = Assert.Single(call.Children); + Assert.NotNull(child.Usage); + Assert.Equal(5, child.Usage.TotalTokenCount); + Assert.Equal(27, report.TotalUsage.TotalTokenCount); + } + + [Fact] + public async Task BeginToolScope_SubStatusInsideChild_AttachesToChildScope() + { + string? childScopeId = null; + AIFunction tool = AIFunctionFactory.Create( + () => + { + using ChatProgressToolScope scope = ChatProgress.BeginToolScope(new ToolDescriptor { Name = "nested" }); + childScopeId = scope.ScopeId; + ChatProgress.Report("Working…"); + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + ChatProgressUpdate childInvoking = Assert.Single( + progress, + update => update.Kind == ChatProgressKind.ToolInvoking && update.ScopeId == childScopeId); + ChatProgressUpdate subStatus = Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolProgress); + Assert.Equal(childScopeId, subStatus.ScopeId); + Assert.Equal(childInvoking.Depth + 1, subStatus.Depth); + } + + [Fact] + public async Task BeginToolScope_NonStreamingRequest_ReportContainsChildren() + { + AIFunction tool = AIFunctionFactory.Create( + () => + { + using (ChatProgress.BeginToolScope(new ToolDescriptor { Name = "nested" })) + { + ChatProgress.ReportUsage(new UsageDetails { TotalTokenCount = 5 }); + } + + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + ChatResponse response = await client.GetResponseAsync("prompt", new ChatOptions { Tools = [tool] }); + + ChatUsageReport report = Assert.IsType( + response.AdditionalProperties?[ToolTrackingChatClient.UsageReportPropertyName]); + ToolCallUsage call = Assert.Single(report.ToolCalls); + ToolCallUsage child = Assert.Single(call.Children); + Assert.Equal("nested", child.ToolName); + Assert.NotNull(child.Usage); + Assert.Equal(5, child.Usage.TotalTokenCount); + } + + [Fact] + public async Task BeginToolScope_OwnerNotCurrentFunctionContext_CallIdIsNull() + { + AIFunction inner = AIFunctionFactory.Create(() => "leaf", "Nested"); + var nestedFunction = new ChildScopeFunction(inner, new ToolDescriptor { Name = "Nested" }); + AIFunction outerTool = AIFunctionFactory.Create( + async (CancellationToken cancellationToken) => + (string?)await nestedFunction.InvokeAsync(cancellationToken: cancellationToken), + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [outerTool] }); + List progress = TestPipeline.ProgressOf(updates); + + List invoking = progress.Where(update => update.Kind == ChatProgressKind.ToolInvoking).ToList(); + Assert.Equal(2, invoking.Count); + Assert.Equal("call-1", invoking[0].CallId); + Assert.Equal("Nested", invoking[1].ToolName); + Assert.Null(invoking[1].CallId); + Assert.Equal(invoking[0].ScopeId, invoking[1].ParentScopeId); + } + + [Fact] + public async Task Dispose_CalledTwiceOnActiveHandle_EmitsSingleCompletion() + { + string? childScopeId = null; + AIFunction tool = AIFunctionFactory.Create( + () => + { + ChatProgressToolScope scope = ChatProgress.BeginToolScope(new ToolDescriptor { Name = "nested" }); + childScopeId = scope.ScopeId; + scope.Dispose(); + scope.Dispose(); + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + Assert.NotNull(childScopeId); + Assert.Single( + progress, + update => update.Kind is ChatProgressKind.ToolCompleted or ChatProgressKind.ToolFailed + && update.ScopeId == childScopeId); + } + + [Fact] + public async Task BeginToolScope_AfterChildDisposed_AmbientRestoredToToolScope() + { + string? childScopeId = null; + AIFunction tool = AIFunctionFactory.Create( + () => + { + using (ChatProgressToolScope scope = ChatProgress.BeginToolScope(new ToolDescriptor { Name = "nested" })) + { + childScopeId = scope.ScopeId; + } + + ChatProgress.Report("after"); + return "done"; + }, + "Outer"); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Outer"), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + ChatProgressUpdate outerInvoking = Assert.Single( + progress, + update => update.Kind == ChatProgressKind.ToolInvoking && update.ToolName == "Outer"); + ChatProgressUpdate after = Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolProgress); + Assert.Equal(outerInvoking.ScopeId, after.ScopeId); + Assert.NotEqual(childScopeId, after.ScopeId); + } + + [Fact] + public async Task BeginToolScope_RecursiveSelfInvocation_StaysFlat() + { + AIFunction? wrapper = null; + AIFunction inner = AIFunctionFactory.Create( + async (bool recurse, CancellationToken cancellationToken) => + { + if (recurse) + { + await wrapper!.InvokeAsync(new AIFunctionArguments { ["recurse"] = false }, cancellationToken); + } + + return "done"; + }, + "Nested"); + wrapper = new ChildScopeFunction(inner, new ToolDescriptor { Name = "Nested" }); + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "Nested", new Dictionary { ["recurse"] = true }), + ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [wrapper] }); + List progress = TestPipeline.ProgressOf(updates); + + // The recursive invocation's owner matches the ambient scope's owner, so no child opens — + // the documented recursion limitation. + Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); + } + + private sealed class ChildScopeFunction(AIFunction innerFunction, ToolDescriptor descriptor, bool passOwner = true) + : DelegatingAIFunction(innerFunction) + { + public bool LastScopeWasActive { get; private set; } + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + using ChatProgressToolScope scope = ChatProgress.BeginToolScope(descriptor, owner: passOwner ? this : null); + LastScopeWasActive = scope.IsActive; + try + { + return await base.InvokeCoreAsync(arguments, cancellationToken); + } + catch + { + scope.Fail(); + throw; + } + } + } + + private sealed class PassThroughFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction); +} diff --git a/tests/Andes.Extensions.AI.Unit.Test/ToolClassificationTests.cs b/tests/Andes.Extensions.AI.Unit.Test/ToolClassificationTests.cs index 1719b52..e81d125 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/ToolClassificationTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/ToolClassificationTests.cs @@ -75,6 +75,39 @@ public async Task CustomClassifier_RenamesDescriptor_EmitsNoPhantomDuplicateHead Assert.Equal("Calling Weather Service Tool", invoking.Message); } + [Theory] + [InlineData(ToolKind.Function, "GetForecast", null, "Calling GetForecast Tool")] + [InlineData(ToolKind.Function, "Weather Tool", null, "Calling Weather Tool")] + [InlineData(ToolKind.Agent, "Research", null, "Calling Research Agent")] + [InlineData(ToolKind.Agent, "Research Agent", null, "Calling Research Agent")] + [InlineData(ToolKind.McpTool, null, "weather-server", "Calling weather-server MCP")] + [InlineData(ToolKind.McpTool, null, "Andes Test MCP", "Calling Andes Test MCP")] + [InlineData(ToolKind.McpTool, null, "andes test mcp", "Calling andes test mcp")] + public async Task DefaultHeader_NameEndingWithKindWord_DoesNotRepeatIt( + ToolKind kind, + string? name, + string? source, + string expectedHeader) + { + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "get_forecast"), + ScriptedTurn.Text("Done.")); + AIFunction tool = AIFunctionFactory.Create(() => "sunny", "get_forecast"); + IChatClient client = TestPipeline.Build(scripted, options => + options.ToolClassifier = candidate => new ToolDescriptor + { + Name = name ?? candidate.Name, + Kind = kind, + Source = source, + }); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + ChatProgressUpdate invoking = Assert.Single(progress, update => update.Kind == ChatProgressKind.ToolInvoking); + Assert.Equal(expectedHeader, invoking.Message); + } + [Fact] public void CreateDefault_AIFunction_ReturnsFunctionKind() {