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
Expand Up @@ -8,6 +8,7 @@ Project memory for **Andes.Extensions.AI** — a C#/.NET solution that ships **`

- Tracks every `AIFunction` invocation made by the assistant by wrapping tools in an internal `TrackingAIFunction : DelegatingAIFunction` (request-scoped; the caller's `ChatOptions` is cloned, never mutated).
- 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).
- **Request-level statuses are detection-driven (v0.5)**: the middleware never auto-emits a request-start or synthetic "Thinking" status — the first in-band event of a tool request is `ToolInvoking`. `ChatProgressKind.Reasoning` (renamed from `Thinking` in 0.5.0) is emitted **once per model turn** when `TextReasoningContent` is detected in the stream (OpenAI Responses API today; content-based, so any provider surfacing it works), re-armed by `AdvanceIteration()` after each tool round-trip, mirrored post-hoc (once per request, observers-only) for non-streaming responses, and never carries reasoning text; it has **no public factory** — apps can never emit it. A matching `ChatProgressKind.ReasoningCompleted` (value 8, message "Reasoning completed") closes each detected turn at most once — raised when the first answer text or function call follows the reasoning, or at stream end (in-band, before the trailing `RequestCompleted`), with the elapsed reasoning time in `ChatProgressUpdate.Duration` (streaming only; `null` on the post-hoc mirror, where observers get a balanced pair) and never the reasoning text. `ChatProgressKind.Custom` (renamed from `RequestStarted` in 0.5.0; numeric value 0 preserved) is the developer-constructed status the middleware never emits: built via the sole public factory `ChatProgressUpdate.CreateCustom(message)` (message **required** — `ArgumentException.ThrowIfNullOrEmpty`; stamped `ChatProgressUpdate.ExternalScopeId`, depth 0, current UTC time) and wrapped with `ToResponseUpdate()` into the same synthetic shape the middleware writes. The UI package surfaces the reasoning summary *text* separately: `AssistantUiEventKind.ReasoningDelta` events (one per non-empty in-band `TextReasoningContent`; encrypted-only items skipped) accumulate into `AssistantStatusSnapshot.ReasoningText` (verbatim across tool round-trips; TypeScript mirror updated) — sourced only from in-band model content, never from progress metadata.
- 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).
Expand All @@ -26,11 +27,13 @@ Privacy invariant: progress events and reports never carry prompt content, tool
- `Andes.Extensions.AI.Agent\` — the Agent Framework satellite package (same RootNamespace convention). Public `AgentToolTrackingExtensions` + `ToolTrackingOptionsAgentExtensions` at the root; `AgentTrackingAIFunction`/`UsageReportingAIAgent` in `Internal\`.
- `tests\Andes.Extensions.AI.Unit.Test\` — core unit tests; no network. The `Infrastructure\ScriptedChatClient` fake replays scripted `ChatResponseUpdate` turns and drives the **real** `FunctionInvokingChatClient`.
- `tests\Andes.Extensions.AI.Mcp.Unit.Test\` — MCP unit tests; no network. Links the core test infrastructure files (`<Compile Include Link>`), and `Infrastructure\InMemoryMcpFixture` hosts a **real** MCP client/server pair over in-process pipes (with a `ProgressAck` gate so progress tests are deterministic).
- `tests\Andes.Extensions.AI.Integration.Test\` — Azure OpenAI tests. Configuration comes from a **gitignored `appsettings.integration.json`** (copy `appsettings.integration.sample.json`; section `AzureOpenAI` with `Endpoint`/`ApiKey`/`Deployment`). **Never environment variables.** Tests `[SkippableFact]`-skip cleanly when the file is missing or incomplete. Do not set `Temperature` in integration tests — reasoning-model deployments reject non-default values.
- `tests\Andes.Extensions.AI.Integration.Test\` — Azure OpenAI tests. Configuration comes from a **gitignored `appsettings.integration.json`** (copy `appsettings.integration.sample.json`; section `AzureOpenAI` with `Endpoint`/`ApiKey`/`Deployment`, plus optional `ResponsesDeployment` — a reasoning-capable deployment that gates the Responses API tests, which otherwise skip). **Never environment variables.** Tests `[SkippableFact]`-skip cleanly when the file is missing or incomplete. Do not set `Temperature` in integration tests — reasoning-model deployments reject non-default values.
- `tests\Andes.Extensions.AI.Mcp.Integration.Test\` — MCP Azure OpenAI tests; links the sibling's `AzureOpenAIFixture.cs` and its gitignored `appsettings.integration.json` (single config location), and spawns `Andes.Extensions.AI.TestMcpServer` over stdio.
- `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>`.
- `samples\Andes.Extensions.AI.Demo\` — interactive Spectre.Console chat exercising all four packages over Azure OpenAI Chat Completions (gitignored `appsettings.json`, copy the sample file; `samples\Directory.Build.props` makes samples non-packable).
- `samples\Andes.Extensions.AI.Demo.Responses\` — sibling demo over the **Azure OpenAI Responses API** with stable packages only: plain `OpenAIClient` against the OpenAI-v1-compatible endpoint (`{endpoint}/openai/v1`), `GetResponsesClient().AsIChatClient(deployment)`, `ChatOptions.Reasoning` with `Output = ReasoningOutput.Full` (**not** `Summary` — M.E.AI.OpenAI 10.8.3 maps it to summary verbosity "concise", which gpt-5-series deployments reject; `Full` maps to "detailed"); needs a reasoning-capable deployment and `NoWarn OPENAI001` (the Responses surface is still `[Experimental]` in OpenAI 2.12). The demo renders a dim live tail of `AssistantStatusSnapshot.ReasoningText` under the status header.
- `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`).
Expand All @@ -43,7 +46,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 four packages version in **lockstep** (all `0.3.0` today); each satellite's `ProjectReference` to core becomes a `>= {version}` NuGet dependency automatically.
- The four packages version in **lockstep** (all `0.5.0` today); each satellite's `ProjectReference` to core becomes a `>= {version}` NuGet dependency automatically.

## C# coding standards (always)

Expand Down Expand Up @@ -126,7 +129,7 @@ The full guidelines live in `.claude/rules/` and **load automatically when you e
`.claude/settings.json` sets `enableAllProjectMcpServers: true`, so the servers configured in `@.mcp.json` are available. Use them when relevant:

- **`microsoft-learn`** — Ground .NET/Azure answers in official Microsoft Learn docs. Before answering a version-specific .NET, Microsoft.Extensions.AI, Agent Framework, or Azure question, query it (`microsoft_docs_search` → `microsoft_code_sample_search` → `microsoft_docs_fetch`) instead of relying on memory.
- **`terraform`** — infrastructure-as-code, if deployment automation is added.
- **`context7`** — current documentation for libraries and frameworks outside learn.microsoft.com (and for OSS sources like dotnet/extensions); resolve the library id first, then query.

## Delegation rules

Expand Down
8 changes: 0 additions & 8 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,6 @@
"type": "http",
"url": "https://learn.microsoft.com/api/mcp"
},
"terraform": {
"command": "docker",
"args": ["run", "-i", "--rm", "hashicorp/terraform-mcp-server"]
},
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
},
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Andes.Extensions.AI</RootNamespace>
<PackageId>Andes.Extensions.AI.Agent</PackageId>
<Version>0.4.0</Version>
<Version>0.5.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>
Expand Down
2 changes: 1 addition & 1 deletion Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Andes.Extensions.AI</RootNamespace>
<PackageId>Andes.Extensions.AI.Mcp</PackageId>
<Version>0.4.0</Version>
<Version>0.5.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>
Expand Down
2 changes: 1 addition & 1 deletion Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Andes.Extensions.AI</RootNamespace>
<PackageId>Andes.Extensions.AI.UI</PackageId>
<Version>0.4.0</Version>
<Version>0.5.0</Version>
<Authors>Rodrigo Rojas</Authors>
<Description>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.</Description>
<PackageTags>AI;IChatClient;Microsoft.Extensions.AI;UI;Blazor;TypeScript;progress;streaming;middleware;tools</PackageTags>
Expand Down
6 changes: 6 additions & 0 deletions Andes.Extensions.AI.UI/AssistantStatusReducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public sealed class AssistantStatusReducer
private string? _assistantStatus;
private ActivityState _phase = ActivityState.Running;
private string? _text;
private string? _reasoningText;
private UsageSummary? _usage;

/// <summary>
Expand Down Expand Up @@ -78,6 +79,10 @@ public AssistantStatusSnapshot Apply(AssistantUiEvent uiEvent)
_text = (_text ?? string.Empty) + uiEvent.Text;
break;

case AssistantUiEventKind.ReasoningDelta:
_reasoningText = (_reasoningText ?? string.Empty) + uiEvent.Text;
break;

case AssistantUiEventKind.Finished:
_phase = ActivityState.Completed;
_usage = uiEvent.Usage;
Expand Down Expand Up @@ -121,6 +126,7 @@ private AssistantStatusSnapshot BuildSnapshot()
Phase = _phase,
Activities = [.. _roots.Select(root => root.ToImmutable())],
Text = _text,
ReasoningText = _reasoningText,
Usage = _usage,
};
}
Expand Down
9 changes: 8 additions & 1 deletion Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace Andes.Extensions.AI;
public sealed record AssistantStatusSnapshot
{
/// <summary>
/// Gets the current request-level status line, such as "Thinking…", or <see langword="null"/>
/// Gets the current request-level status line, such as "Reasoning…", or <see langword="null"/>
/// before the first status arrives.
/// </summary>
public string? AssistantStatus { get; init; }
Expand All @@ -33,6 +33,13 @@ public sealed record AssistantStatusSnapshot
/// </summary>
public string? Text { get; init; }

/// <summary>
/// Gets the model's reasoning summary text accumulated so far, when the provider streams
/// reasoning content (for example the OpenAI Responses API); otherwise <see langword="null"/>.
/// Deltas accumulate verbatim across the whole request, including across tool round-trips.
/// </summary>
public string? ReasoningText { get; init; }

/// <summary>
/// Gets the total token usage for the request, set once it finishes.
/// </summary>
Expand Down
10 changes: 7 additions & 3 deletions Andes.Extensions.AI.UI/AssistantUiEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ namespace Andes.Extensions.AI;
/// Fold a sequence of these into an <see cref="AssistantStatusSnapshot"/> with
/// <see cref="AssistantStatusReducer"/>, or consume them directly. Project them from a tracked chat
/// stream with <c>ChatResponseUiExtensions.ToUiEventsAsync</c>. Like the core progress contract,
/// events never carry prompt content, tool arguments, or tool results.
/// events never carry prompt content, tool arguments, or tool results. Reasoning summary text
/// appears only on <see cref="AssistantUiEventKind.ReasoningDelta"/> events, sourced from in-band
/// model content — never from progress metadata.
/// </remarks>
public sealed record AssistantUiEvent
{
Expand Down Expand Up @@ -76,7 +78,8 @@ public sealed record AssistantUiEvent
public double? DurationSeconds { get; init; }

/// <summary>
/// Gets the answer text chunk for a <see cref="AssistantUiEventKind.TextDelta"/> event.
/// Gets the answer text chunk for a <see cref="AssistantUiEventKind.TextDelta"/> event, or the
/// reasoning summary chunk for a <see cref="AssistantUiEventKind.ReasoningDelta"/> event.
/// </summary>
public string? Text { get; init; }

Expand All @@ -87,7 +90,8 @@ public sealed record AssistantUiEvent

/// <summary>
/// Gets the time at which the underlying progress event was raised. Only meaningful for
/// status and activity events; <see cref="AssistantUiEventKind.TextDelta"/> and
/// status and activity events; <see cref="AssistantUiEventKind.TextDelta"/>,
/// <see cref="AssistantUiEventKind.ReasoningDelta"/>, and
/// <see cref="AssistantUiEventKind.Finished"/> events, which have no source progress event,
/// leave it at its default. Consume events in stream order rather than sorting by this value.
/// </summary>
Expand Down
9 changes: 8 additions & 1 deletion Andes.Extensions.AI.UI/AssistantUiEventKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Andes.Extensions.AI;
public enum AssistantUiEventKind
{
/// <summary>
/// A request-level status line, such as "Thinking…" — carried by <see cref="AssistantUiEvent.Message"/>.
/// A request-level status line, such as "Reasoning…" — carried by <see cref="AssistantUiEvent.Message"/>.
/// </summary>
Status,

Expand Down Expand Up @@ -36,6 +36,13 @@ public enum AssistantUiEventKind
/// </summary>
TextDelta,

/// <summary>
/// A chunk of the model's reasoning summary text, carried by <see cref="AssistantUiEvent.Text"/>.
/// Sourced from in-band <see cref="Microsoft.Extensions.AI.TextReasoningContent"/> on the tracked
/// stream; encrypted-only reasoning items (empty text) are never surfaced.
/// </summary>
ReasoningDelta,

/// <summary>
/// The request finished; the total token <see cref="AssistantUiEvent.Usage"/> is available.
/// </summary>
Expand Down
15 changes: 13 additions & 2 deletions Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public static class ChatResponseUiExtensions
{
/// <summary>
/// Translates a tracked streaming response into a stream of <see cref="AssistantUiEvent"/>
/// deltas — one per progress flush, per answer-text chunk, and one final
/// <see cref="AssistantUiEventKind.Finished"/> event.
/// deltas — one per progress flush, per answer-text chunk, per reasoning-summary chunk, and
/// one final <see cref="AssistantUiEventKind.Finished"/> event.
/// </summary>
/// <param name="updates">The tracked streaming response.</param>
/// <param name="cancellationToken">A token to cancel enumeration.</param>
Expand Down Expand Up @@ -54,6 +54,17 @@ public static async IAsyncEnumerable<AssistantUiEvent> ToUiEventsAsync(
case UsageReportContent usage:
yield return ToFinishedEvent(usage.Report);
break;

// Reasoning summary text is model content that already flows in-band; surface it
// as its own delta kind. Encrypted-only items (empty text, ProtectedData set) are
// skipped — they carry nothing renderable.
case TextReasoningContent { Text.Length: > 0 } reasoning:
yield return new AssistantUiEvent
{
Kind = AssistantUiEventKind.ReasoningDelta,
Text = reasoning.Text,
};
break;
}
}

Expand Down
Loading