Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<ChatResponseUpdate>` 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<AIAgent>()` 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).

Expand All @@ -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 <dll>`.
- `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
Expand All @@ -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)

Expand Down Expand Up @@ -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

Expand Down
16 changes: 9 additions & 7 deletions .github/workflows/nuget.yml
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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 <Version> into a green no-op.
Expand All @@ -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 <Version> in the three csprojs" >&2
echo "Release tag $RELEASE_TAG expects $pkg.$expected.nupkg — bump <Version> in the four csprojs" >&2
exit 1
fi
done
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions Andes.Extensions.AI.Agent/AgentToolTrackingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ public static class AgentToolTrackingExtensions
/// through <see cref="ChatProgress.Report(string)"/> already surface beneath the agent's header
/// without any configuration here.
/// <para>
/// 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.
/// </para>
/// <para>
/// Pass <paramref name="trackUsage"/> as <see langword="false"/> when the agent's own chat
/// client pipeline uses <c>UseToolTracking()</c>: the nested pipeline already rolls its total
/// usage up into the calling tool's scope, and reporting <see cref="AgentResponse.Usage"/> on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Andes.Extensions.AI</RootNamespace>
<PackageId>Andes.Extensions.AI.Agent</PackageId>
<Version>0.2.0</Version>
<Version>0.3.0</Version>
<Authors>Rodrigo Rojas</Authors>
<Description>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.</Description>
<PackageTags>AI;IChatClient;Microsoft.Extensions.AI;AgentFramework;Microsoft.Agents.AI;agents;middleware;progress;tools</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/RorroRojas3/Enterprise.AI</RepositoryUrl>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
Expand All @@ -19,6 +20,7 @@

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
Expand Down
37 changes: 36 additions & 1 deletion Andes.Extensions.AI.Agent/Internal/AgentTrackingAIFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ namespace Andes.Extensions.AI;

/// <summary>
/// Wraps the function produced by <c>AIAgent.AsAIFunction()</c> so that classification can
/// discover the agent behind it through <see cref="AITool.GetService(Type, object?)"/>.
/// discover the agent behind it through <see cref="AITool.GetService(Type, object?)"/>, and so
/// that nested invocations open their own child tracking scope.
/// </summary>
/// <remarks>
/// The wrapper answers <see cref="GetService"/> requests with the original agent — never the
/// internal usage-reporting decorator — so callers can also probe for concrete agent types.
/// A user's own <see cref="DelegatingAIFunction"/> around the wrapper still classifies as an
/// agent through the probe chain; wrappers are never unwrapped or bypassed.
/// <para>
/// 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 <see cref="ChatProgress.BeginToolScope"/> — 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.
/// </para>
/// </remarks>
internal sealed class AgentTrackingAIFunction(AIFunction function, AIAgent agent) : DelegatingAIFunction(function)
{
Expand All @@ -31,4 +39,31 @@ internal sealed class AgentTrackingAIFunction(AIFunction function, AIAgent agent
? Agent
: base.GetService(serviceType, serviceKey);
}

protected override async ValueTask<object?> 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,
};
}
}
4 changes: 3 additions & 1 deletion Andes.Extensions.AI.Agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync("pr
{
foreach (ChatProgressContent progress in update.Contents.OfType<ChatProgressContent>())
{
// 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);
}
}
```

`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.
Expand Down
4 changes: 3 additions & 1 deletion Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Andes.Extensions.AI</RootNamespace>
<PackageId>Andes.Extensions.AI.Mcp</PackageId>
<Version>0.2.0</Version>
<Version>0.3.0</Version>
<Authors>Rodrigo Rojas</Authors>
<Description>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.</Description>
<PackageTags>AI;IChatClient;Microsoft.Extensions.AI;MCP;ModelContextProtocol;middleware;progress;tools</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/RorroRojas3/Enterprise.AI</RepositoryUrl>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
Expand All @@ -19,6 +20,7 @@

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading